Introduction
Nil causes hard-to-track bugs and particularly represents a big problem in ruby because ruby returns nil in many cases.
irb(main):001:0> [][1]
=> nil
irb(main):002:0> {}[1]
=> nil
The fact that nil
is Falsy makes us love😍 it.
if (user.latest_post) do
post = user.latest_post
end
try
and &.
give us syntactic sugar to hide this conditional.
post = user.try(:latest_post)
but all these options present the same problem.
When latest_post is nil, then it will flow out through our program, until eventually our program breaks.
...
post.title # => NoMethodError (undefined method `title' for nil:NilClass)
How often nil is introduced?
After checking our error tracking service I found that around 40% of the errors were related to undefined
or nil
(typescript + ruby).
So you can think that about 40% of the bugs could be avoided by handling this problem when programming.
Comparing costs
Cost of writing an extra test case:
- 30 minutes to 1 hour
Cost of finding and solving a bug (considering the find-solve-review-merge-deploy process):
- from 5 hours to days 🤔 ???
Nil introduction examples
Nil can be introduced in many ways.
Here I list just a couple of them.
- Missing environment configuration
config = YAML::load(File.read('config/database.yml'))
username = config["development"]["username"]
# username not configured yet would give us a nil
- Handling optional data
uri = URI('https://jsonplaceholder.typicode.com/posts')
res = Net::HTTP.get_response(uri)
name = JSON.parse(res.body).dig(0, "data", "name")
# data is not in the response path so name will be nil
- An Uninitialized variable
if condition
user = {name: "John"}
end
...
puts user.name
# when the condition is `false` user will be nil
- Missing return value for if, unless, empty methods.
# a programmer implements this function
def half(x)
if x % 2 == 0
x / 2
end
end
# suppose a that another programer doesn't read the implementation just uses the method
[1,2].map(&method(:half))
=> [nil, 1]
Lets pick another example and look closer
Using a method (from framework or library) that returns nil.
In this case, we mock the implementation for User.find_by
with a hash.
(I always like to highlight the distinction between provider and consumer code)
##### PROVIDER CODE #####
class User
attr_reader :name, :id
def initialize(id, name)
@id = id
@name = name
end
def self.find_by(id:)
{ 1 => new(1, "Emma"), 2 => new(2, "James") }[id]
end
end
class EmailSender
def self.invite(user)
UserInviterJob.perform_later!(user_id: user.id, name: user.name)
end
end
class InvitationsController
attr_reader :params
def initialize(params:)
@params = params
end
def create
user = User.find_by(id: params[:person_id])
EmailSender.invite(user)
end
end
# Consumer Code
InvitationsController.new(params: { person_id: 3 }).create
The execution would cause:
Traceback (most recent call last):
2: from example.rb:36:in `<main>'
1: from example.rb:32:in `create'
example.rb:15:in `invite': undefined method `name' for nil:NilClass (NoMethodError)
The problem with this traceback is that it doesn't lead you to the point where the nil
is introduced so you may be tempted to "solve the problem" by using a .try(:name)
in line 15.
but what is the real problem that we want to solve here?
This is the real execution:
-
find_by
returns nil - The controller passes nil to the email sender
- The email sender is accessing an attribute on the person which is nil
The line that introduces the nil is not present in the traceback and this happens when the introduction of the nil is not local to the usage of it, resulting in hard-to-track bugs.
What can we do?
we can do either one of these:
- Avoid the introduction of nil
- Deal with nil effectively
Avoid the introduction of nil
Avoid using methods(or lang features) that can potentially return nil.
Avoid using []
for accessing hash
or arrays
.
In this case, accessing a hash using fetch
would raise KeyError which would result in an easy-to-track bug.
irb(main):002:0> {1 => "Emma", 2 => "James" }.fetch(3)
Traceback (most recent call last):
5: from /Users/delbetu/.rbenv/versions/2.7.2/bin/irb:23:in `<main>'
4: from /Users/delbetu/.rbenv/versions/2.7.2/bin/irb:23:in `load'
3: from /Users/delbetu/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
2: from (irb):2
1: from (irb):2:in `fetch'
KeyError (key not found: 3)
fetch
can be used with arrays too.
irb(main):018:0> [1,2,3].fetch(0)
=> 1
irb(main):019:0> [1,2,3].fetch(3)
Traceback (most recent call last):
5: from /Users/delbetu/.rbenv/versions/2.7.2/bin/irb:23:in `<main>'
4: from /Users/delbetu/.rbenv/versions/2.7.2/bin/irb:23:in `load'
3: from /Users/delbetu/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/irb-1.2.6/exe/irb:11:in `<top (required)>'
2: from (irb):19
1: from (irb):19:in `fetch'
IndexError (index 3 outside of array bounds: -3...3)
fetch
also provides you a fallback method
irb(main):021:0> [1,2,3].fetch(3) { |param| "No entries for #{param}" }
=> "No entries for 3"
In a similar way with Rails
you should prefer find
over find_by
.
As a general rule bang methods usually raise errors too.
[1] pry(main)> User.find_by(id: 2)
User Load (0.9ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
=> nil
[2] pry(main)> User.find_by!(id: 2)
User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
ActiveRecord::RecordNotFound: Couldn't find User
from /Users/delbetu/.rbenv/versions/2.7.2/lib/ruby/gems/2.7.0/gems/activerecord-6.1.3.1/lib/active_record/core.rb:384:in `find_by!'
Deal with nil effectively
Guard clause
user = User.find_by(id: params[:person_id])
return unless user
EmailSender.invite(user)
Assertion
def assert(condition, message = "Error")
raise StandardError, message unless condition
end
user = User.find_by(id: params[:person_id])
assert(!user.nil?, "User not found.")
EmailSender.invite(user)
Wrapping find_by
Note that find_by
sometimes returns nil
and sometimes returns a User
.
In other words, sometimes returns something that doesn't complain to User API and sometimes it returns something that does complain to User API.
This is forcing the consumer to handle the nil case.
we can fix that by wrapping find_by and try to always return the same API.
Two possible wrapping techniques
:
- Arrify
- Special Case Object
Arrify
This means wraping the result with an array.
If you wrap with an array you will always get an array.
First note that
irb(main):001:0> Array(nil)
=> []
So if we do
users = Array(User.find_by(id: params[:person_id]))
EmailSender.invite(users)
def EmailSender.invite(users)
users.each do |user|
# ... previous code ...
end
end
When find_by returns nil, no failures happen. Instead, no emails are sent.
Wrap find_by with special-case-object.
The wrap method handle the special case (when find_by returns nil)
def Maybe(obj)
if obj.nil?
NullObject.new
else
obj
end
end
def wrapped_find_by(id:)
Maybe(find_by(id: id))
end
Before continuing remember this:
NullObject can respond to everything.
In other words, NullObject conforms to every API
class NullObject
def nil?
true
end
def present?
false
end
def blank?
true
end
def to_s
""
end
def method_missing(*args)
self
end
end
NullObject conforms to User API
NullObject.new.id.name.other_user_attr.nil?
=> true
Coming back to the strategy, how the execution goes now?
Email sender would receive a null object:
user = User.find_by(id: params[:person_id])
EmailSender.invite(user)
def EmailSender.invite(user)
# enque job with null object
end
The job would try to send an email to ""
empty string which probably causes an error in our smtp provider.
Causing a hard to track issue. So NullObject is not the best strategy for this case.
In A Nutshell
You must think and decide which strategy is the best the case.
This table will help you to make that decision.
Strategy | Behavior | Consequence |
---|---|---|
Use no-nil functionality (fetch,bang-mehods) | stop execution raises an error | If the error handler doesn't catch it It will produce an easy to track error |
Provide Default values | executes with no error | executes happy path |
Special Case Obj for nil (NullObject) | execution continues | may final user see empty value or execution fails when trying to use an empty value (hard to track error) |
Arrify | execution continues | executes happy path |
Guard clause | skip parts of the execution that doesn't make sense. execution finishes ok! | some parts of the process are skipped make sure that is correct |
Assertion | stop execution raises an error | If the error handler doesn't catch it It will produce an easy to track error |
Top comments (2)
Nice topic, @delbetu !
In the
ActiveRecord
example, you can also use ActiveRecord::Base#none as null-object pattern and remove the early return:My personal preference is to avoid (if possible) guard clauses and assertions since those make it harder to read and reason about the code, but I agree that sometimes it's not feasible.
oh, that is cool, I didn't know about
ActiveRecord::Base#none
Thank you!