Back to Course

LSP Series · 1 of 2

Duck Typing as Liskov

Where the Liskov Substitution Principle comes from, why Ruby's duck typing is its native idiom, and how Rails' ActiveRecord::Relation can substitute for an Array because they expose the same protocol.

Where this rule comes from

The Liskov Substitution Principle is the L in SOLID. The original statement is from Barbara Liskov's 1987 keynote at a conference of programming-language theorists. Her formulation was dense and mathematical, but the working version every programmer remembers is: if S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking the program.

That is a statement about contracts. The subtype is allowed to do more, but it cannot do less. If callers expect a Bird#fly method that takes no arguments and returns a flight log, a Penguin subclass that raises CannotFlyError from its #fly method is a Liskov violation. The penguin is technically a bird, but it cannot be substituted for one in code that depends on flight.

In statically typed languages like Java, this rule is partly enforced by the compiler — you cannot remove a method from a subclass, and method signatures must match. In Ruby, the language refuses to help. There is no compiler to check that an object honors a contract; the responsibility falls entirely on the developer. The Ruby community has a name for the discipline this requires: duck typing.

The slogan, attributed to James Whitcomb Riley by way of the Python community, is: "If it walks like a duck and quacks like a duck, it is a duck." An object that responds to the same methods as another object, in the same way, can substitute for it. The relationship is not inheritance; it is shared protocol. Two unrelated classes can be substitutable as long as both honor the same set of method calls.

Liskov, in a Ruby context, becomes a duck-typing rule: any object that responds to the methods callers expect, with the same return shape, can stand in for the type the caller was originally thinking about. The senior move is to write callers in terms of the protocol, not the class.

The anti-pattern

Picture a Rails app where a service tallies up some statistics. The team wrote it assuming an Array of records:

class RevenueReport
  def initialize(orders)
    raise ArgumentError unless orders.is_a?(Array)
    @orders = orders
  end

  def total
    @orders.sum(&:total_cents)
  end

  def by_currency
    @orders.group_by(&:currency)
  end

  def average
    return 0 if @orders.empty?
    total / @orders.length
  end
end

The is_a?(Array) check is the bug. A caller that wants to compute a revenue report from a database query — the natural Rails move — has to materialize the entire query into an Array first: RevenueReport.new(Order.recent.to_a). For 100 orders, that is fine. For 100,000 orders, the report loads every row into memory before computing anything. The class works in unit tests with three orders and falls over in production.

Worse, every other place that wants to use the report has the same problem. A controller that scopes by current_user has to call .to_a. A job that processes orders in batches has to call .to_a. The type check pushed the burden onto every caller.

How Rails solves it

ActiveRecord's design assumes Liskov as a baseline. ActiveRecord::Relation is not a subclass of Array (the inheritance chain goes through Object), but it responds to most of Array's interface: each, map, select, sum, group_by, first, empty?, length. It can stand in for an Array in any code that uses those methods.

It also adds methods Array does not have: where, includes, order, limit. The subtype is allowed to do more. What Liskov forbids is doing less, and Relation never does that. Anywhere an Array works, a Relation works — but the Relation can also lazy-load, push computation to the database, and stream results.

The fix to the revenue report is to delete the type check:

class RevenueReport
  def initialize(orders)
    @orders = orders
  end

  def total
    @orders.sum(&:total_cents)
  end

  def by_currency
    @orders.group_by(&:currency)
  end

  def average
    return 0 if @orders.empty?
    total / @orders.length
  end
end

# Now callable with either:
RevenueReport.new(Order.recent)                  # ActiveRecord::Relation
RevenueReport.new(Order.recent.to_a)             # Array
RevenueReport.new([build(:order), build(:order)]) # Array of in-memory objects
RevenueReport.new(orders_from_csv_import)        # custom Enumerable

The class accepts anything that responds to the same protocol. A Relation. An Array. A custom Enumerable wrapping a CSV stream. The class does not care which one; it asks the questions, and any duck that quacks the same way gives a correct answer.

Where Rails relies on this constantly

Once you know to look, Liskov-via-duck-typing is everywhere in Rails:

  • Form helpers accept anything ActiveModel-shaped. form_with(model: @thing) works whether @thing is an ActiveRecord model, a form object that includes ActiveModel::Model, or a plain Ruby class that defines model_name, to_partial_path, and persisted?. The form helpers do not check class; they check protocol.
  • The router accepts any controller. An ActionController::Base, an ActionController::API, or even a bare Rack app — anything that responds to #call(env) and returns a triple — can be mounted at a route.
  • Active Job adapters substitute for each other. Code that calls SomeJob.perform_later does not know whether the queue behind the scenes is Sidekiq, Solid Queue, or a test adapter. The contract is the substitutable interface.
  • Enumerable-including objects are interchangeable. Any class that defines each and includes Enumerable gets map, select, reduce, and 30 more methods for free — and can substitute for an Array in callers that use those methods.

Mastodon's Trends::Query from the SRP series is a textbook example: it includes Enumerable, and that single line makes it substitutable for any collection in any caller that iterates.

Why this design wins in Ruby specifically

Statically typed languages tie Liskov to inheritance. If you want a method to accept "either an Array or my own class," you have to either make your class inherit from a common abstract type, or write a wrapper that satisfies the type system. Both add ceremony.

Ruby's duck typing removes the ceremony. Any class that responds to the same methods can substitute. The relationship is captured at the call site, not in the class hierarchy. A class that wants to be Array-substitutable needs only to define each and include Enumerable. A class that wants to be model-substitutable needs only to define the handful of methods Rails form helpers call.

The senior move is to write callers in terms of "what does this thing need to do?" not "what is this thing?" A caller that types-checks for Array forecloses every other substitutable shape. A caller that calls each and map works with anything that walks like an Array, including future things that did not exist when the caller was written.

Tradeoffs worth naming

Duck typing's freedom has a cost. The contract is implicit. A class that quacks like an Array is acceptable until a caller invents a new method that only Arrays have. Then the substitution silently breaks, often in production, often weeks later.

Two senior habits keep this in check:

  • Name the protocol. When a caller depends on "anything that responds to each," say so in a comment or a docstring. The next person to add a method has a fighting chance of remembering the contract.
  • Test the duck. If your code expects an Enumerable, write tests that pass in something other than the obvious Array. The day a future change forgets that, the test will catch it.

The shortcut that breaks Liskov in Ruby is is_a? and kind_of? checks. Every one of them turns a duck-typed caller into a class-typed caller, and forecloses substitutions that would have worked. If you find yourself reaching for one, ask whether respond_to? would do, or whether the check is necessary at all.

The principle at play

Liskov's original formulation was about subtypes. Ruby's contribution is to relax "subtype" into "anything with the same protocol", regardless of the inheritance tree. A class that responds to the methods a caller uses, with the same return shape, can stand in for the type the caller was originally thinking about. No common parent class required.

The deeper move is captured in the slogan: program to interfaces, not to implementations. The caller's contract is a list of method calls. Any object that satisfies those method calls satisfies the contract. The class is incidental.

In Rails terms, the principle is what lets ActiveRecord::Relation pretend to be an Array, what lets ActiveModel-including classes pretend to be ActiveRecord models, and what lets Sidekiq pretend to be Solid Queue. The framework's flexibility — the thing that allows swapping backends, add new adapters, and stub for tests — is duck typing in action.

Practice exercise

  1. Run grep -rn "is_a?\\(\\|kind_of?\\(" app/ in your codebase. Each match is a Liskov-violating check. Read each one and ask whether respond_to? would do, or whether the check is necessary at all.
  2. For each match that gates a method call, replace it with a respond_to? check (or remove it entirely). Notice that this also opens the method to ducks you have not imagined yet.
  3. Open your largest service class. Look at the arguments it expects. For each argument, ask: am I checking what the object is, or am I asking what it can do? Calls to .class, .is_a?, or .kind_of? are the first kind. Method calls are the second.
  4. Bonus: pick a class that takes a collection argument. Write a second test that passes in an ActiveRecord::Relation, a custom Enumerable, and an array of fixtures. If any of them fail, the class is class-typed where it should be duck-typed.