iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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; end
Try it Yourself »

Discussion

Loading…