How a cat can quack

Duck typing has a slogan, and the slogan is good:
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
The appeal is obvious. You stop caring what something is and start caring what it can do. A function works on anything that happens to have the right shape, and you never have to make two unrelated libraries agree on a common base class.
There are two things wrong with it, and only one of them is the one people usually mention.
The problem everyone talks about
Nothing checks the shape until you run the program. Pass something without a
quack and you find out in production, at the call site, at the worst possible
moment. That’s the familiar complaint about dynamic typing, and it’s real.
The problem that actually matters
Even with the check, the slogan quietly demands that the object already knows how to quack. The capability has to be baked into the thing.
So what happens when it isn’t? You have a Cat, or an Int, or a class from a
library you didn’t write and can’t change, and you need it to quack. Duck typing
has no answer: the duck test only tells you whether something passes, never how
to make it pass.
Inheritance has an answer, but it comes with a deadline. class Cat extends Quacks works only if you decide at the moment you define Cat that quacking
matters. Miss that moment — or don’t own Cat at all — and the door is closed.
For Int it was closed decades ago.
Teach it instead
Type classes make a different move, and once you see it you can’t unsee it:
The cat is not a duck. It never was. And you can teach it to quack anyway.
The capability doesn’t live in the type. It lives in a separate piece of evidence that says “here’s how this type quacks”, written by whoever needs it, whenever they need it, without touching the type at all.
Writing one
Start with the capability, on its own, as a trait parameterised by the type it applies to:
trait Quacks[A]:
extension (a: A) def quack: String
That’s the whole declaration. It says: for some type A, there is a way to make
an A quack. Note what it does not say — nothing about cats, nothing about
ducks, no requirement that A inherit from anything.
Now the evidence. A given is an instance of that trait for one specific type:
case class Cat(name: String)
given Quacks[Cat] with
extension (c: Cat) def quack = s"${c.name} says quack"
Cat is untouched. It doesn’t extend anything, doesn’t know Quacks exists,
and would compile fine if you deleted the given tomorrow. The knowledge of how
a cat quacks lives outside the cat.
That’s the whole idea. Everything else is mechanics.
Using one
A function asks for the evidence rather than for a shape:
def makeItQuack[A: Quacks](a: A): String = a.quack
[A: Quacks] is a context bound — sugar for “and also take a Quacks[A]”. The
compiler goes looking for one at the call site, and if it can’t find it, the
call doesn’t compile:
makeItQuack(Cat("Silvestro")) // Silvestro says quack
makeItQuack("hello") // error: no given instance of Quacks[String]
That second line is the payoff. The duck test’s failure mode was a runtime surprise. Here it’s a compile error naming exactly what’s missing.
The part that makes it worth the trouble
Here’s the move inheritance can’t make:
given Quacks[Int] with
extension (n: Int) def quack = List.fill(n)("quack").mkString(" ")
makeItQuack(3) // quack quack quack
Int is not yours. It predates your program by decades, it’s final, and no
amount of design foresight in 2004 was going to include your Quacks trait. It
quacks anyway.
Now replace Int with a type from a library you depend on — a UserId from
some SDK, a case class generated from a schema — and the same thing works. You
can give a capability to a type you don’t own, without forking it, without
wrapping it, without asking its author for anything.
Wrappers get you partway there, but they cost you: now you have QuackableInt
alongside Int, and every boundary in your program needs to remember which one
it’s holding. Type classes leave the type alone.
You have already been using them
None of this is exotic. Scala’s standard library is full of it — Ordering[A]
is exactly this pattern, which is why sort works on types the sorting code has
never heard of. So is Numeric[A].
Cats is built on it: Show[A], Eq[A], Monoid[A]. So is every JSON library
worth using — circe’s Encoder[A] is a type class, and deriving one for your
case class is exactly the act of teaching a type to quack.
Once the shape is familiar, a lot of Scala library design stops looking arbitrary.
When not to reach for one
Type classes buy you a specific thing: the ability to add a capability, later, from outside, to a type you may not control. If you don’t need that, you’re paying indirection for nothing.
If Cat is yours and quacking is genuinely part of being a cat, write a method.
It’s simpler, it’s easier to find, and nobody has to reason about implicit
scope. The type class earns its keep when the capability is separable from the
type — when it belongs to a library, a protocol, a serialisation format, or
anything else that shouldn’t get a vote in how Cat is defined.
The test I use: could two different codebases reasonably want this to work two different ways? If yes, it doesn’t belong inside the type.
Duck typing asks whether something already quacks. Type classes let you decide that it does — after the fact, from the outside, for a type that will never be a duck and doesn’t need to be.
← Back to all posts