Rails' hidden type system

Table of contents

Recently I stumbled over the following code snippet in the Ruby on Rails API documentation:

class Person
  include ActiveModel::Attributes
  
  attribute :name, :string
  attribute :age, :integer
end
      
Person.attribute_names

I went looking for how to define an ActiveModel again and happened to find this. What caught my attention were the type annotations: :string and :integer.

The attribute definitions looked a lot like what I knew from dry-types except that I didn't expect any of this in the Rails core.

Since the ActiveModel modules are usually a good candidate for implementing domain model objects that do not depend on persistence logic, this discovery was very exciting!

Finally a way to define domain objects as (mostly) POROs! Without an additional dependency in the project!

The excitement was followed by a deep dive into how these types work and what you can do with them. Documentation on the web was scarce, so here's an overview:

What's in a type?

The main purpose of the types as implemented by ActiveModel is to run a bit of code to transform values when they are assigned to an attribute.

On the surface this doesn't sound very exciting, however it offers a nice set of benefits:

  • you can convert incoming values into domain objects encapsulating logic (e.g. turn a String into a DateTime)
  • you have an overview of what attributes to expect on a domain object,
  • you can easily enforce the validity of data coming into the system.

When you define a model like the Person class above, ActiveModel does the following:

  • it uses the symbol naming the type, e.g. :integer, to look up the type definition in the type registry
  • it passes any remaining arguments used to call attribute to the type's constructor
  • it defines an accessor method for the attribute that invokes Type#assert_valid_value followed by Type#cast for the assigned value.

As it happens the constructor defined by ActiveModel::Model also uses attribute accessor methods to assign all attributes so you can be sure that your type is used when instantiating new objects and when updating attributes on existing objects.

Usually #cast would do one of two things: return any value or raise an exception.

Rails' builtin types

Rails ships with quite a few types that are preregistered.

The definition is somewhat hidden in active_model/type.rb:

register(:big_integer, Type::BigInteger)
register(:binary, Type::Binary)
register(:boolean, Type::Boolean)
register(:date, Type::Date)
register(:datetime, Type::DateTime)
register(:decimal, Type::Decimal)
register(:float, Type::Float)
register(:immutable_string, Type::ImmutableString)
register(:integer, Type::Integer)
register(:string, Type::String)
register(:time, Type::Time)

These types would cover most of the basic needs one has for types, except that they are all non-strict.

This means that they do their best to conform the incoming value to the desired type.

Here's how the boolean type works:

class FeatureFlag
  include ActiveModel::Attributes
  attribute :enabled, :boolean, default: false
end

puts FeatureFlag.new(enabled: 'off').enabled # => false
puts FeatureFlag.new(enabled: 'on').enabled # => true

There are a few string values that are interpreted as booleans, confusingly yes and no are not among them:

# === Coercion
# Values set from user input will first be coerced into the appropriate ruby type.
# Coercion behavior is roughly mapped to Ruby's boolean semantics.
#
# - "false", "f" , "0", +0+ or any other value in +FALSE_VALUES+ will be coerced to +false+
# - Empty strings are coerced to +nil+
# - All other values will be coerced to +true+
class Boolean < Value
  FALSE_VALUES = [
    false, 0,
    "0", :"0",
    "f", :f,
    "F", :F,
    "false", :false,
    "FALSE", :FALSE,
    "off", :off,
    "OFF", :OFF,
  ].to_set.freeze

So Rails ships with a lot of types, but the guarantees they provide are very weak. This begs the question then of how to define your own type?

Adding your own types

To implement your own type you need to do two things:

  1. define a class with a #cast(value) and #assert_valid_value(value) instance method,
  2. (optionally) register that class with the ActiveModel::Type registry.

Prime candidates for implementing your own types would be:

  • types for domain-specific value objects (think Money, URI, ISBN, AuthToken, Password, etc)
  • types for collections (e.g. User#type has to be either guest or subscriber),
  • types for parsing incoming values (e.g. JSON parses strings that are assigned to the attribute),
  • types for enforcing invariants (e.g. raise an ArgumentError unless a condition holds true)

Let's start with an interesting example: an :enum type that makes sure that the value belongs to a set of allowed values.

require 'set'

class EnumType
  def initialize(*allowed_values)
    @allowed_values = Set.new(allowed_values)
  end

  def cast(value)
    value
  end

  def assert_valid_value(value)
    return if @allowed_values.include?(value)

    raise ArgumentError,
          "#{value.inspect} not included in #{@allowed_values.inspect}"
  end
end

This implements the minimal interface for a type:

  • assert_valid_value throws an error if the value is invalid,
  • cast transforms the value if necessary.

In our case we don't want to transform the value if it is valid, but we do want to raise an error if the value is not one of the preapproved values.

Let's use this this in a model:

require 'active_model'

class User
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :type, EnumType.new('guest', 'subscriber')
end

Note that we include ActiveModel::Model to define a constructor for us that assigns all attributes from a hash.

With this in place we can now use User.new(params) and be sure that type is always either guest or subscriber.

Here are the specs to prove this:

require 'minitest/spec'

class EnumTypeSpec < Minitest::Spec
  describe 'when an invalid value is provided' do
    it 'raises an ArgumentError in the constructor' do
      value(proc { User.new(type: 'invalid') }).must_raise(ArgumentError)
    end

    it 'raises an ArgumentError when assigning the attribute' do
      user = User.new(type: 'guest')
      value(proc { user.type = 'invalid' }).must_raise(ArgumentError)
    end
  end

  describe 'when a valid value is provided' do
    it 'assigns the value' do
      user = User.new(type: 'guest')
      expect(user.type).must_equal('guest')
    end
  end
end

Minitest.run

Using this type still leaves a bit to be desired:

  • it's inconsistent: other attribute types are defined via symbols, this one is not
  • it cannot be discovered programmatically (all types are registered, except this one).

Luckily we don't need to do much to change this: we change our class definition to register the type and accept an keyword argument as an option:

class EnumType
  def initialize(of:)
    @allowed_values = Set.new(of)
  end
  
  # cast and assert_valid_value stay the same

  ActiveModel::Type.register(:enum, self)
end

Now we can use it like this:

class User
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :type, :enum, of: %w[guest subscriber]
end

Looks just like the builtin types and our specs still pass!

Integrating with ActiveRecord

If you look at the documentation for the generic type ActiveModel::Type::Value, you see that there a quite a few more methods than what we implemented on our EnumType.

This is because Rails assumes that your models will be ActiveRecord objects and thus will have to be persisted to a database and read from a database.

In order for a type to support this there is a pair of methods:

  • deserialize which is responsible to cast the value to a Ruby value from the raw database value,
  • serialize which is responsible for converting to Ruby value to one of the basic types understood by a database.

If you are looking at ActiveModel, you are probably not implementing an object that's backed by a database.

Still it makes sense to keep these two methods in mind.

To make compatibility with ActiveRecord easier, it's a good idea to have your custom types inherit from ActiveModel::Type::Value.

This clearly communicates that you are defining a type and makes the migration to an ActiveRecord-compatible type easier when you need to.

Integrating with the Rails directory structure

Always knowing where code goes is one of Rails' strengths. In the case of custom attribute types there is no existing convention I'm aware of with regards to the placement of files.

I suggest:

+ app
|
`-+ types
| |
| `- enum_type.rb
| `- ..._type.rb
`-+ models
  |
  `- application_model.rb

Putting new types under app/types works well with Rails' autoloading and matches the conventions for other architectural components (controllers, etc).

In a similar vein application_model.rb can hold a base class for all your models, just like ApplicationRecord does for ActiveRecord-based models:

class ApplicationModel
  include ActiveModel::Model
  include ActiveModel::Attributes
end

The bigger picture

Over the last few years concepts like types and schemas (be they static or at runtime), separating domain logic from persistence logic (e.g. ROM) and code that handles state transitions from the code that describes the current state of things (i.e. service/command/interaction objects) have gained more popularity.

This has led to the birth of many useful and interesting libraries and frameworks like the dry-rb family of gems and Hanami.

Rails builtin tooling can be leveraged for implementing these ideas without having to introduce a new dependency into your project or having to train your whole team on wholly new concepts like monads.

Just by using ActiveModel based classes for your domain objects you:

  • keep persistence logic out of your domain logic,
  • don't fall victim to ActiveRecord callbacks,
  • ensure that your domain logic is easy to test -- no need to stub out a database.

Additionally you can use the ActiveModel based classes for modelling state transitions in your system, like form- or parameter objects that are handled by your domain services or controllers.

By staying with Rails' builtin tooling you also reduce the likelihood of problems when it's time to upgrade to the next Rails version. Likewise you can carry your knowledge over to every other Rails project you are working on, since you are not relying on additional gems.