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

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

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)

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


Where is this useful?


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 12 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.