Duck Typing
“If it walks like a duck and quacks like a duck’…” In Ruby, what matters is whether an object responds to the methods you call — not what class it belongs to. Embracing duck typing makes code flexible, testable, and idiomatic.
respond_to?, design, tests, smells
EXAMPLE
# 1) Classic duck typing
def describe(thing)
puts thing.to_s
end
class User
def to_s; "User(#{@name})"; end
def initialize(name); @name = name; end
end
class Order
def to_s; "Order(#{@id})"; end
def initialize(id); @id = id; end
end
describe(User.new('Mara')) # User(Mara)
describe(Order.new(42)) # Order(42)
describe("hello") # hello
# describe doesn't check types — anything that responds to .to_s works.
# 2) The contract is the method, not the class
def render(report, writer)
writer.write(report.to_html)
end
class StringIO
def write(s); @s ||= ''; @s << s; end
def read; @s.to_s; end
end
render(html_report, File.open('out.html', 'w'))
render(html_report, StringIO.new)
render(html_report, Slack::Channel.new('#alerts')) # works if Slack channel exposes write
# 3) respond_to? — defensive checks (use sparingly)
def try_save(record)
if record.respond_to?(:save!)
record.save!
else
raise ArgumentError, "#{record.class} can't save"
end
end
# Prefer letting NoMethodError surface naturally over respond_to? guards — it tells you EXACTLY
# what was missing, instead of converting it into a generic message.
# 4) Duck-typed interfaces beat module mixins
# Idea: any object that responds to #enqueue(name, args) is a 'queue'.
class InMemoryQueue
def initialize; @jobs = []; end
def enqueue(name, args); @jobs << [name, args]; end
end
class RedisQueue
def initialize(redis); @redis = redis; end
def enqueue(name, args)
@redis.lpush('jobs', JSON.dump([name, args]))
end
end
class JobService
def initialize(queue) ; @queue = queue ; end
def book(args) ; @queue.enqueue('Book', args) ; end
end
# In production: JobService.new(RedisQueue.new(redis))
# In tests: JobService.new(InMemoryQueue.new)
# JobService doesn't know or care which queue it has.
# 5) Testing — fakes over mocks
def test_book
queue = InMemoryQueue.new
JobService.new(queue).book(reservation: 1)
assert_equal [['Book', { reservation: 1 }]], queue.instance_variable_get(:@jobs)
end
# A fake (the in-memory queue) is more honest than a mock with stubs that drift from reality.
# 6) Smell: 'is_a?' / 'kind_of?' / 'instance_of?' chains
# This usually means you're missing a method on one branch.
# BAD
def format(value)
if value.is_a?(String) value
elsif value.is_a?(Integer) value.to_s
elsif value.is_a?(Date) value.strftime('%Y-%m-%d')
else value.to_s
end
end
# GOOD — duck-typed via a method everyone implements
def format(value)
value.respond_to?(:formatted_value) ? value.formatted_value : value.to_s
end
# Or just add a #formatted_value method (a hook) to each type that needs custom formatting.
# 7) Modules as duck-typed contracts (without strict typing)
module Renderable
def render = raise(NotImplementedError, "#{self.class} must implement #render")
end
class Card
include Renderable
def render = "<div class='card'>#{@title}</div>"
end
class List
include Renderable
def render = "<ul>...</ul>"
end
# Including the module signals intent. Calling .render on either works.
# 8) When duck typing breaks down
# • Strong invariants (state machines, financial calculations) where types prevent bugs
# • Rails ActiveRecord callbacks expecting a specific class hierarchy
# • Public library APIs where consumers want IDE autocomplete + docs
#
# Then reach for: explicit type signatures (Sorbet, RBS), modules with abstract methods,
# value objects with strict initialization.
# 9) Sorbet / RBS — static type signatures for Ruby (optional)
# Sorbet
require 'sorbet-runtime'
class Queue
extend T::Sig
sig { params(name: String, args: T::Hash[Symbol, T.untyped]).void }
def enqueue(name, args); end
end
# RBS (Ruby's official type signature language) — separate .rbs files
# 10) Refinements + delegators — extend existing classes
class Reporter
def initialize(record)
@record = record
end
# delegate any unknown method to @record — duck typing on steroids
def method_missing(name, *args, **kw, &blk)
if @record.respond_to?(name)
@record.send(name, *args, **kw, &blk)
else
super
end
end
def respond_to_missing?(name, include_private = false)
@record.respond_to?(name) || super
end
end
# Reporter behaves like @record but adds extra behaviour without inheritance.
# 11) Idioms that lean on duck typing
# • to_s, to_a, to_h, to_i, to_proc — conversion contracts
# • <=> — sortable
# • each — Enumerable mixin works on anything with #each
# • call — callable (Proc, Lambda, Method, anything with #call)
# • read / write — streams
# • [] — accessible like a hash/array
# • succ / pred — Range works on anything with these (Integer, String, Date)
# 12) Common bugs / smells
# • Long is_a? chains — duck-type or use polymorphism
# • Calling .send(:method) instead of relying on the method's existence — hides design issues
# • Tests that mock every method on every collaborator — brittle; use a fake instead
# • respond_to? followed by send — usually you can just call the method and rescue NoMethodError
# • NoMethodError on nil — TrueClass / NilClass have no .name; guard with .respond_to? or nil-safe ops
# • Designing for the WRONG duck — what behaviour do you actually depend on? minimise it
Why it matters
Code to the message, not the class — if your function needs save! and to_s, any object that responds to those will work. Replace is_a? chains with a method everyone implements, prefer fakes over heavy mocks in tests, and reach for Sorbet/RBS when a contract genuinely needs to be enforced rather than convention-followed.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
# 'If it quacks like a duck…' # No interface declarations — Ruby checks at call time. def play(thing); thing.quack; endTry it Yourself »
Discussion
Loading…