Avoid nil propagation
Tags: ruby, rails, programming
Published: 2021-05-30 13:46:30

## Introduction

Nil causes hard-to-track bugs and particularly represents a big problem in ruby because ruby returns nil in many cases.
```ruby
irb(main):001:0> [][1]                                            
=> nil                                                            
irb(main):002:0> {}[1]                                            
=> nil 
```

The fact that `nil` is Falsy makes us love😍 it.
```ruby
if (user.latest_post) do
  post = user.latest_post
end
```


`try` and `&.` give us syntactic sugar to hide this conditional.
```ruby
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.

```ruby
...
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

```ruby
config = YAML::load(File.read('config/database.yml'))
username = config["development"]["username"]
# username not configured yet would give us a nil
```

- Handling optional data

```ruby
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
```ruby
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.
```ruby
# 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)

```ruby
##### 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:
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.

```ruby
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.
```ruby
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
```ruby
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.
```ruby
[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
```ruby
    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
```

```ruby
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`:  
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
```ruby
irb(main):001:0> Array(nil)
=> []
```
So if we do
```ruby
    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)
```ruby
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*  
```ruby
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 
```ruby
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:
```ruby
   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               |