If it quacks like a duck, it's a duck... or is it?
DuckTyper enforces duck-typed interfaces in Ruby by comparing the public method signatures of classes, surfacing mismatches through your test suite.
Ruby is a duck-typed language. When multiple classes play the same role, what matters is not what they are, but what they do — the methods they respond to and the signatures they expose. No base class required. No type annotations. No interface declarations.
Most approaches to enforcing this kind of contract pull Ruby away
from its dynamic nature: abstract base classes that raise
NotImplementedError, type-checking libraries that annotate method
signatures, or inheritance hierarchies that couple unrelated
classes. These work, but they're not very Ruby.
DuckTyper takes a different approach. It compares public method signatures directly and reports mismatches through your test suite — the natural place to enforce design constraints in Ruby. There's nothing to annotate and nothing to inherit from. The classes remain independent; DuckTyper simply verifies that they're speaking the same language. The interface itself needs no declaration — it is the intersection of methods your classes define in common, a living document that evolves naturally.
It's also useful during active development. When an interface evolves, implementations can easily fall out of sync. DuckTyper catches that immediately and reports clear, precise error messages showing exactly which signatures diverged — keeping your classes aligned as the design changes.
Add to your Gemfile:
gem "duck_typer", group: :testThen run:
bundle installWhen interfaces don't match, DuckTyper reports the differing signatures:
Expected StripeProcessor and BraintreeProcessor to implement compatible
interfaces, but the following method signatures differ:
StripeProcessor: charge(amount, currency:)
BraintreeProcessor: charge(amount, currency:, description:)
StripeProcessor: refund(transaction_id)
BraintreeProcessor: refund(transaction_id, amount)
Require the Minitest integration and include the module in your test class:
require "duck_typer/minitest"
class PaymentProcessorTest < Minitest::Test
include DuckTyper::Minitest
endTo make assert_interfaces_match available across all tests,
require the integration in test_helper.rb and include the module
in your base test class:
# In test_helper.rb
require "duck_typer/minitest"
class ActiveSupport::TestCase
include DuckTyper::Minitest
endIf you're not using Rails, include it in Minitest::Test directly:
class Minitest::Test
include DuckTyper::Minitest
endThen use assert_interfaces_match to assert that a list of
classes share compatible interfaces:
def test_payment_processors_have_compatible_interfaces
assert_interfaces_match [
StripeProcessor,
PaypalProcessor,
BraintreeProcessor
]
endIf you prefer duck typing terminology,
assert_duck_types_matchis available as an alias.
By default, DuckTyper checks instance method interfaces. To check
class-level interfaces instead, pass type: :class_methods:
assert_interfaces_match [StripeProcessor, PaypalProcessor],
type: :class_methodsTo check only a subset of methods (partial interface), use methods::
assert_interfaces_match [StripeProcessor, PaypalProcessor],
methods: %i[charge refund]This is useful if your class implements multiple interfaces, in which case you can write an assertion for each.
To enforce that positional argument names also match (strict
mode), pass strict: true:
assert_interfaces_match [StripeProcessor, PaypalProcessor],
strict: trueBy default, positional argument names are ignored — only their count and kind (required, optional, rest) are compared. In strict mode, names must match exactly. Keyword argument names always matter regardless of this setting.
Require the RSpec integration in your spec_helper.rb:
require "duck_typer/rspec"Use have_matching_interfaces to assert that a list of classes
share compatible interfaces:
RSpec.describe "payment processors" do
it "have compatible interfaces" do
expect([StripeProcessor, PaypalProcessor, BraintreeProcessor])
.to have_matching_interfaces
end
endIf you prefer duck typing terminology,
have_matching_duck_typesis available as an alias.
For class-level interfaces, pass type: :class_methods:
expect([StripeProcessor, PaypalProcessor])
.to have_matching_interfaces(type: :class_methods)To check only a subset of methods, use methods::
expect([StripeProcessor, PaypalProcessor])
.to have_matching_interfaces(methods: %i[charge refund])To enforce that positional argument names also match, pass
strict: true:
expect([StripeProcessor, PaypalProcessor])
.to have_matching_interfaces(strict: true)If you prefer shared examples, register one in spec_helper.rb
by calling:
DuckTyper::RSpec.define_shared_exampleThis registers a shared example named "an interface". The name
can be changed by passing a custom one:
DuckTyper::RSpec.define_shared_example("a compatible interface")Then use it in your specs:
RSpec.describe "payment processors" do
it_behaves_like "an interface", [
StripeProcessor,
PaypalProcessor,
BraintreeProcessor
]
endThe same type:, methods:, and strict: options are supported:
it_behaves_like "an interface", [StripeProcessor, PaypalProcessor],
type: :class_methods,
methods: %i[charge refund],
strict: trueBy default, DuckTyper checks the structure of public method signatures — the number of parameters, their kinds (required, optional, keyword, rest, block), and keyword argument names. In strict mode, positional argument names are also compared. It does not verify the following, which should be covered by your regular test suite:
- Parameter types. DuckTyper only checks that both methods
declare an
amountparameter — not what type of value it expects. Two methods with identical signatures may still be incompatible if they expect different types. - Return types. Two methods can have identical signatures but return completely different things.
- Behavior. Matching signatures are a necessary but not sufficient condition for duck typing to work correctly at runtime. DuckTyper catches structural drift, not semantic divergence.
Some things are intentionally out of scope:
- Private methods and
initialize. Private methods are not part of a class's public interface — they are implementation details and intentionally excluded. The same applies toinitialize: how an object is constructed is not an interface concern.
DuckTyper is intentionally minimal. It reflects Ruby's own method introspection API, which rarely changes — so the gem rarely needs to either. When it does change, it will most likely be for additive reasons: new API options, better error messages, or broader test framework support. It is safe to depend on without worrying about churn.
After checking out the repo, run bin/setup to install
dependencies. You can also run bin/console for an interactive
prompt that will allow you to experiment.
To install this gem onto your local machine, run
bundle exec rake install. To release a new version, update the
version number in version.rb, and then run
bundle exec rake release, which will create a git tag for the
version, push git commits and the created tag, and push the .gem
file to rubygems.org.
See the CONTRIBUTING document. Thank you, contributors!
DuckTyper is Copyright (c) thoughtbot, inc. It is free software, and may be redistributed under the terms specified in the LICENSE file.
This repo is maintained and funded by thoughtbot, inc. The names and logos for thoughtbot are trademarks of thoughtbot, inc.
We love open source software! See our other projects. We are available for hire.
