The Ruby language leans on duck typing rather than formal interfaces. “If it walks like a duck and quacks like a duck, it’s a duck.”
bodies = [poem, essay, case_study, user_manual].map(&:body)
These objects come from different classes but all respond to body,
so you can treat them uniformly. That’s informal polymorphism: no
type annotations, no ceremony.
Duck typing is great, but what I miss is enforcement. If one class implements a method and another forgets to, I want something to complain.
The interface is a living document
In Ruby, interfaces are implicit. They live in the public methods of your classes. Private methods are implementation details and not part of the interface.
class MessagePresenter
def title; end
def url(format:); end
end
class CommentPresenter
def title; end
def url(format:); end
end
Just by looking at these two classes, we can see an implicit
interface: title and url(format:).
If CommentPresenter also had a body method, title and
url(format:) would still form a partial interface, one we might
call Linkable.
Now the question is: how do we enforce that these classes adhere to this implicit interface?
Enforcing an interface
How do we enforce that future changes don’t break this contract? Tests can help:
test "presenter interface" do
message = Message.new(title: "Dummy", path: "/dummy")
comment = Comment.new(title: "Dummy", path: "/dummy")
[message, comment].each do |object|
assert_respond_to object, :title
assert_respond_to object, :url
end
end
It works, but it feels weird to instantiate objects just to ask what methods they have. We can skip instances and check the class directly:
def assert_instance_responds_to(klass, method, arity: 0)
# asserts method is public and arity matches
end
test "presenter interface" do
[MessagePresenter, CommentPresenter].each do |klass|
assert_instance_responds_to klass, :title, arity: 0
assert_instance_responds_to klass, :url, arity: 1
end
end
Nicer, and arity gives us more confidence than just checking the method name. Still, arity alone misses keyword names, optional vs. required params, and won’t catch added or removed methods automatically.
The duck_typer gem
To solve this optimally (keeping the spirit of duck typing while
still enforcing interfaces), I wrote a tiny gem called
duck_typer. It’s a
minimal set of helpers extracted from patterns I’ve used to test
duck-typed interfaces across several projects. You don’t need to
explicitly define an interface. What matters is comparing the
objects’ public interfaces. If they match, the interface is
implemented correctly; if there are differences, the tooling surfaces
them.
After installing, you can assert that a list of classes share compatible interfaces:
test "presenter interface" do
assert_interfaces_match [MessagePresenter, CommentPresenter]
end
You can add as many classes as you want. The failure messages are very
explicit. If you add a body method to only one presenter:
Expected MessagePresenter and CommentPresenter to implement compatible
interfaces, but the following method signatures differ:
MessagePresenter: body()
CommentPresenter: body not defined
It also catches signature drift. Remove a keyword argument in one class:
MessagePresenter: url()
CommentPresenter: url(format:)
Add a positional argument:
MessagePresenter: title(truncate)
CommentPresenter: title()
Yep, everything you’d expect to break does break.
To check a partial interface, pass methods:. You can also name it,
which shows up in the failure message:
assert_interfaces_match [MessagePresenter, CommentPresenter],
name: "Linkable",
methods: %i[title url]
Expected MessagePresenter and CommentPresenter to implement compatible
"Linkable" interfaces, but the following method signatures differ:
And if you want to check class methods instead of instance methods,
pass type: :class_methods:
assert_interfaces_match [MessagePresenter, CommentPresenter],
type: :class_methods
If you’re on RSpec, the same options are available via
have_matching_interfaces or it_behaves_like "an interface", [...].
The README has full setup instructions and all available options.
Bonus feature
Suppose you create a new class that implements the interface. You still need to remember to add it to the list of classes under test.
If your classes share a namespace, you can pass it directly and
duck_typer will find them for you:
assert_interfaces_match namespace: Presenters::Linkable
If your codebase already gathers related classes in one place, you can pass that directly instead:
assert_interfaces_match Presenter::LINKABLE_CLASSES, name: "Linkable"
Either way, if someone adds a PostPresenter that forgets to
implement url(format:), you’ll get a failure without needing to
update the test list.
The same options work in RSpec via have_matching_interfaces or
it_behaves_like "an interface".
Limitations
There’s one fundamental limitation: you can’t check types of method arguments. This is Ruby; that’s the tradeoff. And honestly, types aren’t really part of a Ruby interface anyway.
Conclusion
By treating public methods as an implicit interface and enforcing that
interface through tests, we get the best of both worlds: the freedom
of Ruby with the safety net of interface checks. That’s what
duck_typer aims to
provide: a tiny tool that keeps your classes honest without
annotations or explicit interface definitions.
If you’ve ever caught yourself writing ad-hoc tests to enforce
interfaces, as was my case,
duck_typer might be
for you.