Proof of Concept: Go-like Implicit Interfaces with Ruby and Sorbet

By Mohammed A.
Apr 18, 2022 • 13 mins read
Proof of Concept: Go-like Implicit Interfaces with Ruby and Sorbet

The implicit interfaces in Golang are my favorite feature in the language. I think they make perfect sense to use with Object-Orientation as I’ve written about in a previous post. I love Ruby, I like the dynamic nature of it and how it relates to the Smalltalk, I was excited when I first heard about sorbet as it introduce the type checking optionally. However, I was worried that it will make the Object-Orientation in Ruby boring just like in C++ or Java.

TL;DR

Digging into the Sorbet internals to understand more about how Sorbet sees the types.

The implicit interfaces will be implemented only with Sorbet runtime, it wasn’t possible to do it with static type checking.

How Does Sorbet Work?

Sorbet works by using the existing features of ruby, to call the sig method with a block before the method definition as follows:

class Printer
  extend T::Sig
  
  sig { returns(String) }
  def print_it
    x * x
  end
end

The ordinary Ruby types work fine with Sorbet signatures with few exceptions, like Hash, Array and booleans (TrueClass/FalseClass), where they’re represented by Sorbet’s T::Hash[K, V], T::Array[T], and T:Boolean respectively which is reasonable, because Ruby has pre-defined behaviors of this (i.e. Array[1,2,3] creates an array).

Sorbet uses these signatures to learn about the types statically and dynamically. Each of them has their limitation, and Sorbet itself has its own limitation as well.

How Does Sorbet Understand The Types: Dynamically

For the type checking at runtime; sorbet creates wrappers around the method defined and validates the passed arguments against the type in the signature. In the signature, the type could be either a class, module, an array of them, or something is a T::Types::Base (i.e. subtype of it).

When you pass a class or a module Sorbet will use something similar to .is_a? on the object against that module. However, when you pass T::Types::Base then it will do more complex checking, but not always works statically.

How Does Sorbet Understand The Types: Statically

It analyses the code and tries to recognize the types without the need to run the code (I think it does it via some AST). This has some limitations and it feels far less smart than a language that does it statically. For example, if you do Calculator.extend SomeModule outside of the class, Sorbet will not able to know that statically.

It’s not perfect, but it still adds value and we use it to avoid many bugs before we even write tests for them.

Implicit Interfaces From T::Types::Base

I’ve found out that it’s possible to have basic interface checking without explicitly extending the interface (i.e. Module) by subclassing the T::Types::Base.

When you have an interface that has some methods as follows:

module Printable
  extend T::Sig
  extend T::Helpers
  interface!

  sig { abstract.returns(String) }
  def print
  end
end

It’s great that Sorbet supports the concept of interfaces.

Then now we need to subclass the T::Types::Base this will look like:

module T::Types
  # Validates that an object can respond to specific messages.
  class ProtocolOf < Base
    extend T::Sig

    Methods = T::Private::Methods

    attr_reader :type

    def initialize(type)
      @type = type
    end

    # overrides Base
    def name
      "T::Protocol[#{@type}]"
    end

    # This is how Sorbet knows if this object is compatible or not.
    # It should be performant, that's why there's very minimal checking here.
    def valid?(obj)
      instance_method_keys.all? do |key|
        obj.respond_to?(key)
      end
    end

    # This will be used for caching the value of .keys for performance.
    def instance_method_keys
      type_instance_methods.keys
    end


    # This is the 2nd checking, when the method is called.
    # This method controls the error messages.
    def error_message_for_obj(obj)
      mismatched_methods = type_instance_methods.reject do |key, method_sig|
        # Comparing the sorbet signature as well, not only the method existence.
        obj.respond_to?(key) && method_signatures_equal?(signature_of_method(obj.method(key)), method_sig)
      end
      return if mismatched_methods.empty?

      "expected #{obj.inspect} to respond to [#{instance_method_keys.join(', ')}] but it has missing: [#{mismatched_methods.keys.join(', ')}]"
    end

    def describe_obj(obj)
      obj.inspect
    end

    private

    # This has the same value for the same signature, caching will improve the performance
    def type_instance_methods
      @type_instance_methods ||= @type.instance_methods.to_h { |key| [key, signature_of_method(@type.instance_method(key))] }
    end

    # A factory that helps a bit
    def signature_of_method(method)
      Methods.signature_for_method(method)
    end

    # I want to compare the return type and the argument types against the interface.
    # But what if the object doesn't use sorbet signature? Just ignore it.
    def method_signatures_equal?(method_sig_1, method_sig_2)
      return true if method_sig_1.nil? || method_sig_2.nil?

      method_sig_1.return_type == method_sig_2.return_type &&
        method_sig_1.arg_types == method_sig_2.arg_types
    end
  end
end

Now you have the type that Sorbet can understand, however, you can’t simply use it like: sig { params(obj: T::Types::ProtocolOf.new(Printable)).void }. Sorbet follows some convention on how the type is used if we’re subclassing the T::Types::Base, It should be in the T module, this can be done as the following:

module T
  module Protocol
    extend T::Sig

    sig { params(type: Module).returns(T::Types::ProtocolOf) }
    def self.[](type)
      T::Types::ProtocolOf.new(type)
    end
  end
end

# Then use it later like:
class SomeService
  extend T::Sig
  
  sig { params(obj: T::Protocol[Printable]).void }
  def self.call(obj)
    # ...
  end
end

Now we can finally call the service either one of the ways:

printer_1 = Printer.new
SomeService.call(printer_1)

printer_2 = Struct.new(:print_it).new('print something')
# Yes! This works, if ignores the signature if not existed
SomeService.call(printer_2)

\

What If We Don’t Want To Create an Interface Or With No Type Signature?

Sometimes we don’t need to write ~10 lines of code of empty interface, what if we can just tell the method names? Like:

class SomeService
  extend T::Sig

  sig { params(obj: T::Protocol[%i[print_it]]).void }
  def self.call(obj)
    # ...
  end
end

This is doable, since we are optionally checking for the Sorbet signature, and we are checking if the object responds to these messages or not. This will be supported in the final solution below. However, this doesn’t check against the parameters of the function.

The Final Solution

Here’s the final solution with support for on the fly types (i.e. Array of symbols) (With Sorbet Types):

module T::Types
  # Validates that an object can respond to specific messages.
  class ProtocolOf < Base
    extend T::Sig

    # Unfortunately Sorbet doesn't support T::Private namespace, so it's untyped.
    Methods = T.let(T::Private::Methods, T.untyped)

    sig { returns(T.any(Module, T::Array[Symbol])) }
    attr_reader :type

    sig { params(type: T.any(Module, T::Array[Symbol])).void }
    def initialize(type)
      @type = type

      @type_instance_methods = T.let(nil, T.nilable(T::Hash[Symbol, T.untyped]))
      @instance_method_keys = T.let(nil, T.nilable(T::Array[Symbol]))
    end

    # overrides Base
    sig { returns(String) }
    def name
      "T::Protocol[#{@type}]"
    end


    # No signature here, because it hugely affect the performance.
    def valid?(obj)
      instance_method_keys.all? do |key|
        obj.respond_to?(key)
      end
    end

    # No signature, because performances is affected
    def instance_method_keys
      @instance_method_keys ||= type_instance_methods.keys
    end

    sig { params(obj: Object).returns(T.nilable(String)) }

    def error_message_for_obj(obj)
      mismatched_methods = type_instance_methods.reject do |key, method_sig|
        obj.respond_to?(key) && method_signatures_equal?(signature_of_method(obj.method(key)), method_sig)
      end
      return if mismatched_methods.empty?

      "expected #{obj.inspect} to respond to [#{instance_method_keys.join(', ')}] but it has missing: [#{mismatched_methods.keys.join(', ')}]"
    end

    # overrides Base
    sig { params(obj: Object).returns(String) }

    def describe_obj(obj)
      obj.inspect
    end

    private

    sig { returns(T::Hash[Symbol, T.untyped]) }
    def type_instance_methods
      @type_instance_methods ||= case @type
        when Array
          @type.to_h { |key| [key, nil] }
        when Module
          @type.instance_methods.to_h { |key| [key, signature_of_method(@type.instance_method(key))] }
      end
    end

    sig { params(method: T.any(Method, UnboundMethod)).returns(T.untyped) }
    def signature_of_method(method)
      Methods.signature_for_method(method)
    end

    sig { params(method_sig_1: T.untyped, method_sig_2: T.untyped).returns(T::Boolean) }
    def method_signatures_equal?(method_sig_1, method_sig_2)
      return true if method_sig_1.nil? || method_sig_2.nil?

      method_sig_1.return_type == method_sig_2.return_type &&
        method_sig_1.arg_types == method_sig_2.arg_types
    end
  end
end

By the way, I tried to support hash as well, something like { print_it: T.proc.returns(String) }, however, it seems that wasn’t very helpful, what I got was the builder of the signature, not the method signature.

I also tried to generate the Module on the fly, but it didn’t work since I used define_method which is not recognized by Sorbet.

Benchmarking

I’ve tested the performance with 100k calls.

require 'benchmark'

printer = Printer.new

puts Benchmark.measure {
  100_000.times do
    SomeService.call(printer)
  end
}

After 3 runs, it gave me the following:

Without any caching and with all typing: 1.184733   0.003098   1.187831 (  1.195597)

After caching the instance_method_keys andtype_instance_methods it gave: 0.465355 0.000549 0.465904 ( 0.465954)

After removing the type signature from valid?and instance_method_keys: It gave me: 0.260309   0.000382   0.260691 (  0.260742) which is close to using a simple class (it will be ~0.20)

Limitations

  • Although this solution works with sorbet-runtime, it doesn’t support static checking.
  • No IDE support since the IDE has no idea what the type of parameter was sent from a non-standard sorbet type.
  • It requires T.unsafe when used in union type (with T.any(..)) when statically checked, however, it still complains even after adding it. Maybe it’s a bug.
  • Uses a private sorbet API like the T::Private::Methods, and they might break in the future.

Conclusion

It was nice getting my hands dirty playing and learning a lot about Sorbet types on the weekend. I wished that this supports static type checking, however, it’s not that easy at the current stage, I hope that sorbet changes that in the future, probably creating an API that helps with such parsing.