Extending ActiveRecord with custom types

Wood letters acting as an analogy for custom types in ActiveRecord.

In the last article, we saw how to use the ActiveRecord Attributes API to make Ruby on Rails models more communicative. The two explicit benefits of doing so from that article were a more discoverable data model and a uniform way of defining different types of attributes. The article also hinted at using richer data types to model attributes. That is the topic of this article.

The Attributes API is extensible and allows you to define clean interfaces for using Ruby objects as attribute types. Instead of relying on primitives, you can create your domain logic as Ruby objects that ActiveRecord then coerces form data into. This is a powerful technique that allows you to limit the Primitive Obsession code smell in your projects.

This article details how to create your own ActiveRecord type classes. Once we have a type class, we’ll register it to easily use it within a model, though we’ll also cover how to use it without registering it. Then, we’ll go over a concrete example. Lastly, we’ll talk through some rules of thumb for creating custom type classes.

Type classes

To create a type class, you only need to define a plain old Ruby class that meets a four method interface:

  1. #cast(value) performs a type cast on input, like when using the setter methods from accepting a form submission.
  2. #serialize(value) casts the value from the Ruby type to the type needed by the database.
  3. #deserialize(value) casts the value from the database into the Ruby type.
  4. #type returns a Symbol corresponding to the Symbol used to register it with the type registry.

In some cases, you can use private helper methods to run similar transformations for these interface methods. For example, if you end up serializing the value into a String-like field, your #cast and #deserialize methods might be able to share code.

If you need to be able to configure your type, you will also want to define an #initialize method to set the instance variables you will need as configuration.

While you can use any class that conforms to this interface, sometimes it makes sense to inherit from a built-in base class from ActiveRecord.

Built-in base classes

ActiveRecord comes with base classes that can help you create type classes for certain patterns. For example, if you have an immutable value type, inheriting from ActiveRecord::Type::Value might make it easier to implement your value type.

Likewise, when you want to add special behavior on top of an existing one, it can help to look at the built-ins. ActiveRecord types are a superset of ActiveModel types. This means that you will need to look at both the ActiveRecord::Type and ActiveModel::Type modules to get the list of built-in types.

Registering a type class

Once you have created your type, the easiest way to make it usable from your model is to register it in the ActiveRecord::Type registry. Doing so looks like this:

ActiveRecord::Type.register :my_identifier, MyClass

With the above registration, you can use your new type class with the following:

class MyModel < ApplicationRecord
  attribute :my_attribute, :my_identifier
end

Configurable type declarations

For configurable types, you can pass arguments on the attribute declaration to forward them to the type class instance. For example, given a class called MyClass with an initializer like #initialize(limit:, precision:):

class MyModel < ApplicationRecord
  attribute(
    :my_attribute,
    :my_identifier,
    limit: 2,
    precision: 5
  )
end

Positional arguments work as well, like with a normal method call.

Using a type class without registering it

While registering the type makes it easier to use, you don’t have to register it to use it. To use an unregistered type, pass an instance of the class to the attribute class method:

class MyModel < ApplicationRecord
  attribute :field, MyClass.new
end

For configurable classes, pass the arguments to the constructor:

class MyModel < ApplicationRecord
  attribute :field, MyClass.new(limit: 2, precision: 5)
end

Now that you know how to create, register, and use a class, let’s look at an example.

Example: GlobalID

GlobalID is a default Rails gem that enables you to store a reference to an object using a URI specific to your application. In cases where you have multiple Rails applications — or Rails applications that talk to non-Rails applications — they can be handy for loading information from other services. The behavior of a GlobalID is what allows ActiveJob to transparently load records from the database.

Sometimes it makes sense to store a reference to an object in the database as a GlobalID. You could store the GlobalID as a simple string, but that makes for a clumsy way to look up the reference. Instead, let’s create an ActiveRecord type that accepts a string value but transforms it into a GlobalID object.

module ActiveRecord
  module Type
    class GlobalID < String
      def serialize(value)
        value.to_s.presence
      end

      def type
        :global_id
      end

      private

      def cast_value(value)
        value = value.to_gid if value.respond_to?(:to_gid)
        value = super(value)
        ::GlobalID.parse(value)
      end
    end

    register :global_id, GlobalID
  end
end

On line 3, you’ll see that the type inherits from the String type. This makes it so you can ignore a lot of the details of the other methods since this is a specialization of a String column in your database.

On lines 4-6, you’ll see the definition of the #serialize method. At this point, we know that the value we’re dealing with is a nilable GlobalID. For this method on this specific type, we need to ensure that we transform the value into a nilable String. Converting the GlobalID to a String happens with #to_s. However, this means it will place an empty string when the value is nil. To prevent that, #presence outputs the value when it’s present and otherwise outputs nil.

On lines 10-14 is a specialization of ActiveRecord::Type::Value’s #cast_value helper. It takes a value that can be an instance of an ActiveRecord model, a String, or a GlobalID. For an ActiveRecord model, it converts the model to a GlobalID. Then, it delegates to ActiveRecord::Type::String to cast the GlobalID or String to a frozen string. And lastly, it reconverts the String into a GlobalID. This is an inefficient, but effective, workflow that we can later optimize, if necessary.

Lastly, on line 17, you’ll see the registration of the type as :global_id to make it easily usable from your models.

By leaning on the built-in class, this is all the change we need to have a fully functional GlobalID type! To use it, you can do the following:

class MyModel < ApplicationRecord
  attribute :related_to, :global_id
end

MyModel.new(related_to: User.first).related_to
#=> 'gid://myapp/User/1'

MyModel
  .new(related_to: 'gid://otherapp/Foo/123')
  .related_to
  .locate
#=> loads the record from otherapp using GlobalID

MyModel.where(related_to: 'gid://otherapp/Foo/123')
MyModel.where(related_to: GlobalID.parse('gid://otherapp/Foo/123'))

The database stores the GlobalID value object as a simple string, but when ActiveRecord loads the record the string becomes a GlobalID with all of its rich behavior. An, instead of being a one-off for this class, you can now reuse this behavior in other models as well!

After seeing an example, hopefully your creativity is flowing, so let’s talk about some rules of thumb for making custom type classes.

Rules of thumb

I have two main rules of thumb for creating ActiveRecord type classes. First, make your types robust, not strict. Second, try to use value objects for your types as much as possible.

Robustness

When creating types in other cases, generally you want to be strict so that you don’t end up introducing invalid data into your system. However, with the Attributes API, it’s best to follow the robustness principle:

Be conservative in what you do, be liberal in what you accept.

It’s tempting to have your type classes raise an error whenever the value that you receive is invalid for the attribute. However, in cases where you are either adding typing to an existing application or where multiple writers are writing to the database from which you read, it’s better to take a gentler approach. Consider converting the value to nil and then adding a validation for the attribute. Or even better, encode the invalid data as an exceptional value with a validation.

By taking the gentler route, you prevent the case where bad data in the database makes it so you can’t even inspect the object without resorting to ActiveRecord::Connection#execute or a direct database connection. Since the casting of the value raises an error, any time bad record with an invalid datum loads it will raise the exception. This is indiscriminate and can happen in a customer-facing controller, an API endpoint, or a diagnostic terminal.

Value objects

The best type classes use value objects for their Ruby values. Value objects are immutable and have identity based on the values that they wrap, rather than identity of their own. Most of the prior art that you can reference uses value objects, so it’s easier to reference examples for them. Mutability also adds an additional set of methods that you have to either include or define.

When you are unable to use a value object, consider inheriting from the JSON (or, if you’re using PostgreSQL, JSONB type). It is already a mutable type class and will handle the changes you need for mutability out of the box for you.

Conclusion

In this article, we covered creating your own custom type classes for ActiveRecord. We then found how to register them to make them easier to use, but also talked about using them without registering them. Then we went through an example with a type that models GlobalIDs and discussed two rules of thumb for creating your own type classes.

In the next article, we’ll see how to use type modifiers to make your type classes even more extensible. That article will show two examples of custom type classes, so if you’re interested in learning more about this technique, make sure check it out next week!

Have you created your own custom ActiveRecord types? What types did you model? Did you run into any issues?