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.
ActiveModel::Type::ImmutableString
to support EnumsBy 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)
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
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) }
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