Problems with nil and how to avoid them
Have you recently got an exception saying NoMethodError: undefined method `name' for nil:NilClass
? Most likely more than once. And how did you solve it? Maybe you used try
and thought the problem is solved… until the same exception happened in a different place! Using methods like try
is just treating symptoms, it doesn't even touch the real problem. Maybe the right question would be: why was it nil in the first place? Could it be avoided? Was the possibility of nil a desired behavior? And why at all is nil
even a problem? Let's find out and investigate some usecases.
Finding the origin of nil
Take a look at the code below and try to find the places where nil
could occur:
class SomeController
def show
@view_object = SomeViewObject.new(params)
end
end
class SomeViewObject
attr_reader :email
private :email
def initialize(params)
@email = params[:emal]
end
def user
@user ||= User.find_by(email: email)
end
def user_name
user.name
end
end
# views
<%= @view_object.user_name %>
Running the above code will raise NoMethodError: undefined method `name' for nil:NilClass
in the view. You would be probably confused why the user
turned out to be nil
. So the first place to look at would be the user
method and the find_by
. But why the user was nil
? Was the email
incorrect? Or maybe the email
itself was a nil? Let's try to dig deeper. In constructor we extract email from params
and it looks like there's a typo! Probably that was an issue. Or there was no email
in the params
hash. It took some time to debug why the user was nil and that was a trivial usecase. In complex logic that would be even worse. Can such problems be avoided? Is it possible to catch nil in the exact place where it occurs? Fortunately, the answer is yes. You just need to:
Use the right set of methods
Most occurences of nils in Rails applications originate from hashes and ActiveRecord finders returning nil
where it was not supposed to be nil. When it comes to hashes, the fix is pretty simple: always use Hash#fetch
instead of Hash#[]
unless you have good reasons for it. How is Hash#fetch
different? It works like []
, but raises KeyError
if the specified key can't be found. You can also return a default value, raise custom error or whatever else you want; just pass it as a second argument or in a block:
params.fetch(:email, "default@email.com")
params.fetch(:email) { raise EmailNotFoundError }
You may always stick to using blocks instead of second argument, even for returning default values - if you have some expensive computation it will always be called in second argument, whereas in block it will be called when the key is not found. What about ActiveRecord finders? There's a little inconsistency in Rails as find
raises ActiveRecord::RecordNotFound
if the record is not found, but find_by
returns nil. It's not really a big deal if you already know it: just use bang equivalent:
User.find_by!(email: email)
Applying the right methods can save you a lot of time and makes it much easier to trace nil
's origin. But what if the user may be nil and it's a desired behavior? In such case you can't use bang finders (unless you want to rescue from errors). Maybe adding a conditional in view would solve the problem:
<% if @view_object.user %>
<%= @view_object.user_name %>
<% else %>
Anonymous user
<% end %>
You will avoid errors that way, but it's not really a great solution. It would be much better to use:
The Null Object Pattern
Null Object is an object with some default behavior that implements the same interface as an other object that might be used in a given case. Ok, cool, but how can we apply it to the example below? Let's start with the user
method:
def user
@user ||= User.find_by(email: email) || NullUser.new
end
And now we can implement NullUser
:
class NullUser
def name
"Anonymous user"
end
end
Last step is to refactor views:
<%= @view_object.user_name %>
No conditionals, no nils and the code looks beautiful and is bulletproof at the same time. Let's add some more features and see what else we can achieve with The Null Object Pattern. How would we eg. handle comments for null user? If there's no user, there are probably no comments. Sounds like empty array:
class NullUser
def name
"Anonymous user"
end
def comments
[]
end
end
No problems so far. But what happens if we would like to use methods like where
or order
, for example to display ten last comments? The best way would be to add ten_last_comments
to User
and implement the same method in NullUser
which would still return an empty array. But let's imagine that for some reason we can't do that.
Since Rails 4 we can use NullRelation
(Null Object pattern again) which implements the same interface as any other "real" relation. We just need to call none
on Comment
:
class NullUser
def name
"Anonymous user"
end
def comments
Comment.none
end
end
Now we can call whatever relation methods we want on null user's comments! The Null Object pattern can be applied in many other usecases. Imagine have a service object where we create a user and want to log that the user has been created if the logger is provided. Without using Null Object pattern we would have something like that:
class User::Create
attr_reader :logger
private :logger
def initialize(logger: nil)
@logger = logger
end
def process(params)
User.create(params)
logger.info("User created") if logger
end
end
Doesn't really look that great. And we've got some serious problems if we forget about the if
statement and the logger
is nil. Let's try to make it bulletproof:
class User::Create
attr_reader :logger
private :logger
def initialize(logger: NullLogger.new)
@logger = logger
end
def process(params)
User.create(params)
logger.info("User created")
end
class NullLogger
def info(*)
end
end
end
Feels great - no conditionals, no possibility of accidental errors.
It might look that most of the cases are covered so far, but not really. Let's continue with user and the comments example. We have a blogging platform, a user added some comments and decides to cancel his/her account which causes User record to be deleted. What are the possible implications? We probably don't want to delete all the comments related to that user.
Does the comment without assigned user makes sense? Well, it might, we can use Null Object pattern - if there's no user_id
persisted with comment - we would just return instance of NullUser. But what if it doesn't make sense in our domain to have a comment without a user, because we want to display e.g. an email in views, like:
<%= @comment.author.email %>
and we forgot about implementing soft delete for users? We would have a nasty error because the user has been already deleted. The good news is that we can easily guard ourselves by:
Using database constraints
The most common constraint for such problems (if you use a relational database like Postgres) is using null: false
constraint on foreign keys. But is it enough? Not really, it just means that the user_id
can't be nil, nothing protects us against deleting user and having a reference to the deleted record. Fortunately, we can use foreign key constraints, which are supported since Rails 4.2 (or you can just use gems like schema_plus for that). With foreign keys it is impossible to delete a record which is referenced by other records and the protection is on database level, so there is no possiblity to bypass it. We would still have a bug, as the user who wants to delete own account would see that an error have occured, but at least we can easily fix it and nothing will break in comments section.
Let's imagine another usecase: we forgot about database constraints and soft delete. Futhermore, the user has already deleted the account. Can we do anything about it besides adding some conditionals in views or using try
? Let's do a little refactoring and take a look at the views again:
<%= @comment.author.email %>
The problem with this code is that it introduces some structural coupling (and makes Demeter unhappy): to display the email
we know that we need to ask the author of the comment for that. Do we really need to know everywhere that a comment belongs to an author? And what if we decide to denormalize data and add an author_email
field to comments for performance or other reasons? We would need to change it everywhere. Why not use comment.author_email
in the first place? Thanks to ActiveSupport, we can use:
The Delegate Macro
The delegate
macro offers a feature similar to Forwardable, but comes with some additional options and better syntax. We can easily delegate email
to author
with the following code:
class Comment < ActiveRecord::Base
delegate :email, to: author, prefix: true, allow_nil: true
end
It will add author_email
method (with prefix
) which would return nil
if author
is not present. That way we reduced structural coupling and are guarded NoMethodError
from non-existent user.
Bonus: persisting records with null objects
Sometimes you may come across a usecase, where you have an object which can be either a real model or a null object and want to assign it to other model and maybe even persist it. But you can't do it out-of-the-box, if you try to do something like: Comment.new(author: NullUser.new)
, you will get ActiveRecord::AssociationTypeMismatch
. With persistence you will have even more problems. Fortunately, you can implement a simple concern to make ActiveRecord happy and simply treat the null object in persistence context as if it was blank. Here's an example:
module NullObjectPersistable
extend ActiveSupport::Concern
included do
def self.mimics_persistence_from(real_model_class)
@real_model_class = real_model_class
end
def self.real_model_class
@real_model_class
end
def self.table_name
@real_model_class.to_s.tableize
end
def self.primary_key
"id"
end
end
def real_model_class
self.class.real_model_class
end
def id
end
def [](*)
end
def is_a?(klass)
if klass == real_model_class
true
else
super
end
end
def destroyed?
false
end
def new_record?
false
end
def persisted?
false
end
end
And just use it in the NullUser
class:
class NullUser
include NullObjectPersistable
mimics_persistence_from User
end
There's no magic here, it's just adding methods one by one to make ActiveRecord not raise any error and behave as we want it to.
Wrapping up
Having accidental errors with nil
is in most cases a symptom of bad design decisions, lack of some concepts in domain or not adding proper constraints to database. Fortunately, you can easily protect yourself from such problems with the discussed strategies.