Amir Sharif
Engineer.
Weekend hacker.
Self improvement enthusiast.

Adopting Sorbet

Sorbet has been a great addition to the Ruby ecosystem. It helps me write code faster and more correctly. However, it has some gotchas, so I’ll be adding those here along with remediations.

Upgrade ActiveModel::Type::ImmutableString to support Enums

By default, it’s tempting to want to do:

class EventName < T::Enum
  enums do 
    SPECIAL = new
  end 
end

MyModel.where(name: EventName::SPECIAL)
# throws:
# /app/vendor/bundle/ruby/3.2.0/gems/activerecord-7.0.4.1/lib/active_record/connection_adapters/abstract/quoting.rb:43:in `type_cast': can't cast EventName (TypeError)
# raise TypeError, "can't cast #{value.class.name}"

You can fix it with:

MyModel.where(name: EventName::SPECIAL.serialize)

I found that too easy to forget, and you get no warnings when you forget to do it. Instead, you can add an initializer to allow Rfails to support casting Sorbet enums properly to a string. (h/t to this StackOverflow question).

Add config/initializers/active_record.rb so that if you forget to serialize a value before sending it to the database, it’ll be done automatically.

# typed: true
module TEnumSupport
  def serialize(value)
    case value
    when T::Enum
      value.serialize
    else
      super
    end
  end
end

ActiveModel::Type::ImmutableString.include(TEnumSupport)

Using T::Struct for a JSON database column

If you have a model that has a JSON column and you want that JSON column typed with a struct, try this:

class CustomDataDataType < ActiveRecord::Type::Value
  def cast(value)
    if value.is_a?(BlogPostExtraData)
      value
    elsif value.is_a?(String)
      CustomData.from_hash(JSON.parse(value))
    elsif value.is_a?(Hash)
      CustomData.from_hash(value)
    else
      fail ArgumentError, "Unsupported type for BlogPostExtraData: #{value.class}"
    end
  end

  def serialize(value)
    value.serialize.to_json
  end

  def deserialize(value)
    if value.is_a?(String)
      CustomData.from_hash(JSON.parse(value) || {})
    else
      CustomData.new
    end
  end

  def type
    :my_custom_data
  end
end

ActiveRecord::Type.register(:my_custom_data, CustomData)

You can now get/set the attribute like so:

class MyModel
  # ...
  attribute :extra_data, :blog_post_extra_data
end

my_model.extra_data = blog_post.extra_data.with(other_prop: [])
# OR
my_model.extra_data = { 'other_prop' => [1, 2, 3] }
# OR
my_model.extra_data = { 'other_prop' => [1, 2, 3] }.to_json

my_model.save

# This will not work!
my_model.extra_data.other_prop = [1,2,3]
my_model.save

Using Time

These share the same interface but are different classes. Use Time-ish to solve this to get the autocomplete benefits.

# config/intializer/sorbet.rb
Timeish = T.type_alias { T.any(ActiveSupport::TimeWithZone, DateTime, Time) }

With RSpec and FactoryBot

When using FactoryBot and RSpec you’ll run into issues since FactoryBot adds a bunch of dynamical methods via it’s DSL.

I’ve found disable it like this is the easiest solution because it allows you to still get autocomplete but it does disable the types.

# typed: true
# frozen_string_literal: true

RSpec.describe(Model) do
  T.bind(self, T.untyped)

  it 'can do stuff' do
    instance = create(:model)
  end
end


Date
January 6, 2024