You probably don’t write code like this:
if object.kind_of?(User)
do_this
else
object.do_that
end
Why not? Because Ruby encourages duck typing and polymorphism.
A hidden version
Here’s the same principle:
if object.nil?
do_this
else
object.do_that
else
This checks that object
is of type NilClass
instead of type User
.
The pattern
There’s an old pattern called Null Object that addresses this special case of avoiding type-checking in favor of duck typing.
Here’s an example of the “Introduce Null Object” refactoring to fix this problem with a Null Object in a Rails app.
The code to be refactored
Airbrake reports an error from this line in a Haml view:
= l location.orders.ascend_by_created_at.first.created_at.to_date
Demeter is displeased but let’s fix the problem for our users first.
The anti-pattern quick fix
We could do something like this:
- if location.orders.any?
= l location.orders.ascend_by_created_at.first.created_at.to_date
- else
No orders yet
Resist the urge.
A cleaner fix
First, re-create the error in the setup phase of a functional or integration test by creating a location without orders, then making an HTTP GET to the page.
We can change the view:
= l location.first_order_date
Much better. Bonus: we’ve given the line an intention-revealing name.
This new method doesn’t exist yet, though, so let’s test-drive it:
context 'first_order_date' do
setup do
@location = create(:location)
end
context 'when an order exists' do
setup do
@date = Date.today
create :order, location: @location, created_at: @date
end
should 'return first order date' do
assert_equal @date, @location.first_order_date
end
end
context 'when no orders exist' do
should 'respond to strftime' do
assert_respond_to @location.first_order_date, :strftime
end
end
end
How should the duck respond
The interesting bit is the ‘no orders’ case. Instead of returning nil
, which
caused the original error, we want to return an object that responds to
strftime
.
Why? Let’s look at the view again:
= l location.first_order_date
That l
method is an alias for localize
, which calls
I18n.translate
.
If we look inside that method, we’ll see something like:
def localize(locale, object, format = :default, options = {})
unless object.respond_to?(:strftime)
raise ArgumentError
end
# ...
end
The localize
method expects an object that responds to strftime
.
So, to get the unit test green, let’s do this in the Location
model:
def first_order_date
if first_order
first_order.created_at.to_date
else
NullDate.new 'orders'
end
end
private
def first_order
@first_order ||= orders.ascend_by_created_at.first
end
The Null Object
What’s this hipster NullDate
object? We’ll have to write it.
The failing test for NullDate
tests its strftime
method:
context 'strftime' do
setup do
@null_date = NullDate.new('orders')
end
should 'return user-friendly message' do
assert_match /No orders yet/, @null_date.strftime('anything')
end
end
The passing implementation:
class NullDate
def initialize(nullable)
@nullable = nullable
end
def strftime(string)
I18n.t 'models.null_date.strftime', nullable: @nullable
end
end
A final touch
Since the intention of this feature is to display copy in the view via the
localize
method, we localized the response of NullDate#strftime
.
In config/locales/en.yml
:
en:
models:
null_date:
strftime:
No %{nullable} yet
Summary
So that works. Is it worth it? I say yes:
- It looks like a lot more code than the quick fix, but it’s quick to test-drive.
- Since the resulting Null Object is generalized, it has a high chance for re-use. In fact, it should Just Work if you drop it in your Rails app.