DEV Community

loading...

Avoid nil propagation

M Bellucci
Software Engineer. 5+ years working with Ruby & Rails
・6 min read

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 
Enter fullscreen mode Exit fullscreen mode

The fact that nil is Falsy makes us love😍 it.

if (user.latest_post) do
  post = user.latest_post
end
Enter fullscreen mode Exit fullscreen mode

try and &. give us syntactic sugar to hide this conditional.

post = user.try(:latest_post)
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode
  • An Uninitialized variable
if condition
  user = {name: "John"}
end
...
puts user.name
# when the condition is `false` user will be nil
Enter fullscreen mode Exit fullscreen mode
  • 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Avoid the introduction of nil
  2. 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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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!'
Enter fullscreen mode Exit fullscreen mode

Deal with nil effectively

Guard clause

    user = User.find_by(id: params[:person_id])
    return unless user
    EmailSender.invite(user)
Enter fullscreen mode Exit fullscreen mode

Assertion

def assert(condition, message = "Error")
   raise StandardError, message unless condition
end
Enter fullscreen mode Exit fullscreen mode
user = User.find_by(id: params[:person_id])
assert(!user.nil?, "User not found.")
EmailSender.invite(user)
Enter fullscreen mode Exit fullscreen mode

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:

  1. Arrify
  2. 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)
=> []
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

NullObject conforms to User API

NullObject.new.id.name.other_user_attr.nil?
=> true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Discussion (2)

Collapse
cghan profile image
Carlos Ghan

Nice topic, @delbetu !

In the ActiveRecord example, you can also use ActiveRecord::Base#none as null-object pattern and remove the early return:

user = User.find_by(id: params[:person_id]) || User.none
EmailSender.invite(user)
Enter fullscreen mode Exit fullscreen mode

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.

Collapse
delbetu profile image
M Bellucci Author

oh, that is cool, I didn't know about ActiveRecord::Base#none
Thank you!