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.
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
return nil unless value.present?
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.
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)
sanitized = sanitize_input(value)
decoded = ActiveSupport::JSON.decode(value)
sanitized = sanitize_input(decoded)
private def sanitize_input(input)
input.slice(:type, :value, :unit)
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
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.
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.