Custom Attribute Types in Rails 5+

Back when Rails 5 was released a new attributes API was introduced to us. It allowed us to take advantage of a type casting mechanism that had long been used internally in Rails.

Ever noticed how the parameters that are passed to a controller come in as strings but then once they are assigned to a model instance they are converted to dates, integers, decimals, etc.? That all happens through the ActiveModel::Types API. The team went one step further with the release - we can now also use this for ActiveModel classes, which is actually a much more common use case in my experience.

In both ActiveRecord and ActiveModel the process is the same for creating custom types and casting attributes using those types.

First we need to create a custom type, then we register the new type to ActiveRecord or ActiveModel, then we can use it when defining our attributes.

What works for one app won't work for another, customization is what we do

Introduction to Custom Attribute Types


Custom attribute types are a powerful feature in Rails that allows developers to define custom types for attributes in their models. Introduced in Rails 5, this feature has been a game-changer for many developers. With custom attribute types, you can control how values are converted to and from SQL when assigned to a model. This means you can change the behavior of values passed to ActiveRecord::Base.where and use domain objects across much of Active Record without relying on implementation details or monkey patching. By leveraging custom types, you can ensure that your data is handled precisely the way you need it to be.

Creating a custom type


In Rails, it's common practice to work in UTC time. However, here in Thailand a lot of the APIs we work with expect us to accept values in GMT+7. To accommodate this we can create a new BangkokDateTimeType to cast values to the Bangkok timezone. ActiveModel already includes a DateTime so we can build on top of that and modify the cast method to cast into the correct timezone

class BangkokDateTimeType < ActiveModel::Type::DateTime
  def cast(value)
    return nil unless value.present?

    value.in_time_zone('Bangkok')
  end
end

Understanding the Type


In Rails, the type of an attribute is determined by the cast_type parameter when defining an attribute using the attribute method. The cast_type parameter can be a symbol such as :string or :integer, or it can be a type object. Type objects are instances of classes that implement the cast and serialize methods. These methods are crucial as they convert values to and from SQL when assigned to a model. By defining custom type objects, you can create custom attribute types that can be used in your models, giving you greater control over how your data is processed and stored.

Registering types with ActiveRecord and ActiveModel


Now we need to register this type to ActiveRecord and ActiveModel. We'll create an initializer to handle this.
The first argument will be the symbol used to identify the type when we define our attributes.

# config/initializers/attribute_types.rb
ActiveRecord::Type.register(:bangkok_datetime, BangkokDateTimeType)
ActiveModel::Type.register(:bangkok_datetime, BangkokDateTimeType)


Formatting Attribute Values to the Desired Output


Formatting attribute values is an essential aspect of working with data in Rails. By using custom attribute types, you can control how values are formatted when assigned to a model. For instance, you might want to format date values in a specific way. This can be achieved by implementing the cast method in the custom type object to format the value accordingly. Additionally, the serialize method can be implemented to format the value when it is written to the database. This ensures that your data is consistently formatted both in your application and in the database.

Automatically Casting Params with the Rails Attributes API


The Rails Attributes API can automatically cast params to the correct type when using the attribute method to define attributes in a model. This feature is particularly useful when working with form data or API requests, where data is often passed as strings. By defining attributes with the correct type, you can ensure that data is converted to the correct type automatically. This not only simplifies your code but also reduces the amount of boilerplate code in form objects, report objects, and other Model-ish Ruby classes. The Rails Attributes API makes it easier to handle data consistently and accurately.

Using our custom types


Now that we've defined the type, we can use it in ActiveRecord and ActiveModel classes. Both classes use exactly the same syntax:

class Payment < ApplicationRecord
  attribute :transaction_time, :bangkok_datetime
end

Now we've defined the transaction_time attribute on Payment we can pass date or time values and they will be cast to our timezone, e.g.

Payment.new(transaction_time: '2020-01-01T00:00:00').transaction_time.iso8601
=> "2020-01-01T00:00:00+07:00"

Notice we didn't supply the timezone but Rails is now correctly casting to the Bangkok timezone. We can still pass the timezone and Rails will know how to handle this

Payment.new(transaction_time: '2020-01-01T00:00:00Z').transaction_time.iso8601
=> "2020-01-01T07:00:00+07:00"

Now Rails respects the timezone passed in but displays the value in Bangkok time.

Here at OOZOU we hand make our apps, even down to our attribute types

Looking for a new challenge? Join Our Team


Best Practices and Considerations


When working with custom attribute types and the Rails Attributes API, there are several best practices and considerations to keep in mind. Firstly, it is important to define custom attribute types in a separate module or class to keep the code organized and reusable. Secondly, when implementing custom type objects, it is crucial to follow the guidelines set out in the Rails documentation to ensure that the type object is correctly implemented. Finally, when using the Rails Attributes API, it is essential to test the code thoroughly to ensure that data is being converted correctly. By adhering to these best practices, you can make the most of custom attribute types and ensure that your application handles data efficiently and accurately.

Where is this useful in the database?


We can create a DowncasedStringType to ensure emails are always downcased, or a ClampedPercentageType to ensure we only accept values between 0.0 and 100.0. We can also do more complex things like give structure to a jsonb column

class RewardDetailType < ActiveRecord::Type::Value
  REWARD_DETAIL = Struct.new(:type, :value, :unit, keyword_init: true)

  def type
    :jsonb
  end

  def cast(value)
    sanitized = sanitize_input(value)
    REWARD_DETAIL.new(sanitized)
  end

  def deserialize(value)
    decoded = ActiveSupport::JSON.decode(value)
    sanitized = sanitize_input(decoded)
    REWARD_DETAIL.new(sanitized)
  end

  def serialize(value)
    ActiveSupport::JSON.encode(value)
  end

  private def sanitize_input(input)
    input.slice(:type, :value, :unit)
  end
end

This type seems to have a specific use case so registering it probably doesn't serve much purpose. We can tell the attribute to use this class instead of using the symbol lookup.

class Reward < ApplicationRecord
  attribute :detail, RewardDetailType.new
end

Now we can pass in a hash or call a record from a database and then treat the json data like a struct, because it is a struct, e.g.

reward = Reward.new(detail: { type: :magic, value: Float::INFINITY, unit: :coffee })
reward.detail.type
=> :magic
reward.detail.unit
=> :coffee

All coffee is magic isn't it?

The attributes API is really powerful and often overlooked. You can cast, serialize, and deserialize values, and even use it to specify default values for an attribute. I highly recommend checking it out.
Like 13 likes
Joe Woodward
I'm Joe Woodward, a Ruby on Rails fanatic working with OOZOU in Bangkok, Thailand. I love Web Development, Software Design, Hardware Hacking.
Share:

Join the conversation

This will be shown public
All comments are moderated

Comments

Alexander
July 30th, 2021
Initialization should be like:
> ActiveSupport.on_load(:active_record) do
> ActiveRecord::Type.register(...)
> end

Otherwise active record is loaded earlier in the pipeline and that can be an issue for CI.

Guilherme
June 22nd, 2022
Hey Joe, nice article! Thanks for it!

Where do you place your types? Is it in "app/types"?

Joe Woodward
June 22nd, 2022
Hey Guilherme thanks

Yeah I normally keep them in app/attribute_types

Get our stories delivered

From us to your inbox weekly.