Statically-checked duck typing (structural typing) for Sorbet. Quack!
Sorbet Duck is a
tool which adds statically-checked duck typing (sometimes called structural typing) to Sorbet using static code generation. This means you can
define a Sorbet type which accepts any object with a particular method.
Suppose we wanted to define our own empty?
function, and allow it to accept
absolutely any object which has a length
method returning an Integer. This
isn’t possible in pure Sorbet (you’d need to explicitly implement an interface
on all types passed in), but you can do it with Sorbet Duck! It looks like this:
# Define our statically-checked duck type
# ,--- A name to describe what this type is checking for
# | ,--- The method to check for
# | | ,--- The expected sig body of that method
# .-------. .----. .--------------.
duck(:HasLength, :length) { returns(Integer) }
#
# ,--- Now use our duck type!
# .-------------.
sig { params(x: Duck::HasLength).returns(T::Boolean) }
def empty?(x)
x.length == 0
end
# Later...
empty?([1, 2, 3]) # passes static type check
empty?("hello") # also passes
empty?(64) # fails static type check - there is no Integer#length method
Sorbet Duck runs as a pre-processing step before Sorbet, generating a single
Ruby file which creates interfaces and implements them where required.
This works using Sorbet’s LSP implementation to detect what new interface
implementations are needed, then dynamically generates them using
Parlour.
This is absolutely not production-ready: it has no formal tests, has
several limitations (see below), and is all-round a bit clunky. Still, it’s an
interesting proof-of-concept to show that some amount of duck typing is possible
in Sorbet.
sorbet_duck
to your Gemfile/gemspec and install itsorbet_duck
, like you would require sorbet-runtime
srb-duck
in the root of your project to generate duck.rb
(don’tduck.rb
at runtime, this is entirely for static checking)srb tc
to typecheck your projectYou must run srb-duck
before every time you run srb tc
to regenerateduck.rb
. If you want to make this easy, you could always create a Rake task
which runs one after the other.
First of all, no waterfowl were harmed in the creation of this gem.
This works by generating an interface for each defined duck type (likeHasLength
in that usage example), and then implementing that interface for any
type we attempt to pass into a method accepting that duck type. The interfaces
and implementations are written into duck.rb
. (This can’t be duck.rbi
and
I’m not entirely sure why…)
The process of doing this is roughly as follows:
duck
) by searching the project’sffast
gembundle exec
is available.hidden-definitions
or similar for the Duck