Why Duck Typer?

https://thoughtbot.com/blog/why-duck-typer

Duck Typer is a Ruby gem that validates interface compatibility across polymorphic classes sharing the same role, so they can be used interchangeably. It detects and clearly reports interface drift directly in your test suite.

Since Duck Typer launched, there’s been some discussion about the validity of interface testing. In this post, I want to make the case for it.

“Interface tests are fragile, so you shouldn’t write them”

That’s not true without context. How is your test suite structured? What do you test? Obviously, if you write only interface tests like this:

def test_interfaces_match
  assert_interfaces_match [StripeProcessor, PaypalProcessor]
end

With no behavior tests to accompany it, that quote will be true. Why? Because you’re not testing actual code behavior. Alone, Duck Typer tests are fragile. So why should you still write them?

“But I already have behavior tests that catch mismatches”

You do, and they will catch mismatches eventually, assuming you have good test coverage. The problem is how they catch them. A behavior test will blow up with a NoMethodError or an ArgumentError, but nothing about that tells you it’s an interface problem across a group of classes. You have to figure that out yourself, then work backwards to find which class drifted and what changed.

Duck Typer short-circuits that investigation. It tells you what drifted and where, in a single message, before you ever hit a behavioral failure:

Expected StripeProcessor and PaypalProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: refund(transaction_id)
PaypalProcessor: refund not defined

There’s also a sharper version of this objection: “You can remove the implementation and the test still passes, so it’s not a good test.” That’s true, and it’s by design. Duck Typer checks shape, not behavior. It explicitly marks that a set of classes is expected to evolve together, and when one changes, the failure makes it clear. That’s a different job than verifying correctness, and both are worth doing.

It’s about quality of life

At thoughtbot, we always valued testing UX and clear error reporting. We care about the details. For example, this is a style of test generally not encouraged here:

expect(objects).to eq([post_1, post_2, post_3])

Assume that the post objects are complex Active Record instances. Can you imagine what the error message will look like if one object has differences? It will dump a huge blob of text that incurs overhead to parse. What are we really testing there? That we’re getting the right objects! Instead, we can use named identifiers to make error reporting more actionable and crystal clear:

expect(objects.map(&:title)).to eq(["Post 1", "Post 2", "Post 3"])

Duck Typer applies the same principle to interface errors. Without it, you only get generic Ruby errors that say nothing about interface drift across classes. With Duck Typer, you also get a clear, targeted failure:

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)

It communicates design intent as actionable errors

I wish Ruby had interfaces. As I said in the introductory post, I want to be alerted of interface drift because it’s a great developer experience feature.

It’s not always obvious when classes are supposed to be used interchangeably. A clear error message communicates which classes share a role and what shape their interfaces should have.

What if you join a legacy project where the original developers left a long time ago? Duck Typer would be super helpful there too.

A concrete example: Null Objects. You add a deactivate method to User, and your behavior tests for User pass. But NullUser, which is supposed to be interchangeable with User, silently drifts because nobody remembered to update it. Behavior tests on User won’t catch that. Duck Typer will, immediately, because it treats those classes as a group that must stay in sync. It also reminds you to write the actual behavior test for NullUser#deactivate.

As a developer who loves targeted feedback, that is right up my alley.

It helps you think about design

Let’s say that introducing a do_stuff public method in StripeProcessor is the easiest way to accomplish a goal. You add it, but get a test failure like the following:

Expected StripeProcessor and PaypalProcessor to implement compatible
interfaces, but the following method signatures differ:

StripeProcessor: do_stuff(data)
PaypalProcessor: do_stuff not defined

That message doesn’t just report interface drift. It actually asks:

Why are you doing that? A public method in StripeProcessor should also exist in the other processors.

Most likely, your do_stuff method is not in the right place. Maybe it belongs in a collaborator object, or maybe it should be a private method that isn’t part of the public interface at all.

The same applies to differing method parameters; if you introduce a parameter in one class but it is not needed in another class from the same interface, you are probably doing something wrong.

“But that’s just like shoulda-matchers”

Not quite. Shoulda Matchers are great for shortening TDD feedback loops when working with Rails conventions. They verify a single object’s declarations: does this model have_many :posts? Does it validate_presence_of :email? That’s inward-facing: one object, one declaration.

In fact, once the code has enough behavior coverage, you could delete the shoulda-matchers tests entirely. They’ve done their job.

Duck Typer is cross-cutting. It checks whether a group of objects agrees on a shared interface. The question isn’t “does StripeProcessor have a charge method?” but “do StripeProcessor, PaypalProcessor, and BraintreeProcessor all define charge with the same signature?” That’s a fundamentally different concern, and one that single-object matchers can’t express.

“But it doesn’t catch errors in production”

Some prefer an approach where the interface is validated at class load time: declare the contract, and if a class doesn’t conform, raise a RuntimeError immediately. That way, mismatches surface as errors in production rather than only in tests.

That’s a valid approach, although not exactly great. In a typed language, an interface mismatch would never be deployed because the code wouldn’t compile. Ruby doesn’t have a compiler, but it has its own equivalent: the test suite. And guess what inhibits bad deployments in Ruby projects? In all my years working with Ruby, I’ve never seen a project without a CI pipeline. If tests fail, your code doesn’t get deployed. In practice, the safety net is the same.

On top of that, runtime checks add metaprogramming to your production code, and you’d still need tests to verify the setup is correct.

That’s why Duck Typer deliberately stays in the test suite: it’s Ruby’s natural place to enforce constraints like this, and your implementation stays clean, without workarounds that try to mimic static typing at runtime.

If you want compile-time or runtime guarantees, tools like Sorbet or RBS take a fundamentally different approach to the same problem and you wouldn’t need Duck Typer. That said, Duck Typer gives you some of those benefits with a fraction of the effort, at least when it comes to interfaces.

Wrapping up

Duck Typer won’t replace your behavior tests, and it was never meant to. It’s a small, focused tool that gives you targeted feedback when interfaces drift. It’s usually a one-liner to add, has no runtime dependencies, and lives only in your test environment. If you value clear error messages and care about keeping polymorphic classes in sync, give it a try.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.