TypedAttrs
A Ruby gem that brings type-safe structured data to ActiveRecord using Sorbet's T::Struct
. Store complex, nested objects in JSON/JSONB columns with full type safety, validation, and seamless integration with Sorbet's type system.
Key Features
- 🎯 Rich Types: Nested structs, arrays, hashes, union types (discriminated unions), and nilable types
- ✅ Validation: Automatic validation with indexed error messages for nested structures
- 🗄️ Database Agnostic: PostgreSQL, MySQL, and SQLite support
- 🔄 Transparent Conversion: Seamless conversion between
T::Struct
and JSON - 🛠️ Tapioca Integration: Automatic RBI file generation
Installation
Add this line to your application's Gemfile:
gem 'typed_attrs'
And then execute:
bundle install
Usage
Basic Usage (Single T::Struct)
# Define your Sorbet struct
class Product < T::Struct
const :name, String
const :price, Integer
end
# Use it as a typed attribute
class Order < ActiveRecord::Base
attribute :product, T::Attr[Product]
end
# Assign with Hash
order = Order.new
order.product = { name: "Ruby Book", price: 3000 }
# Assign with T::Struct
order.product = Product.new(name: "Ruby Book", price: 3000)
# Access as T::Struct
order.product.name # => "Ruby Book"
order.product.price # => 3000
# Save to database (stored as jsonb)
order.save
Array Type
class Color < T::Struct
const :name, String
const :hex, String
end
class Palette < ActiveRecord::Base
attribute :colors, T::Attr[T::Array[Color]]
end
palette = Palette.new
palette.colors = [
{ name: "Red", hex: "#FF0000" },
{ name: "Blue", hex: "#0000FF" }
]
palette.colors[0].name # => "Red"
Hash Type
class BoxSize < T::Struct
const :width, Integer
const :height, Integer
end
class Layout < ActiveRecord::Base
attribute :box_sizes, T::Attr[T::Hash[String, BoxSize]]
end
layout = Layout.new
layout.box_sizes = {
"small" => { width: 100, height: 50 },
"large" => { width: 200, height: 100 }
}
layout.box_sizes["small"].width # => 100
Union Type (Discriminated Union)
module PetType
extend T::Helpers
extend TypedAttrs::DiscriminatedUnion
sealed!
discriminator_key "animal_type"
end
class Dog < T::Struct
include PetType
def self.discriminator = "dog"
const :name, String
const :breed, String
end
class Cat < T::Struct
include PetType
def self.discriminator = "cat"
const :name, String
const :lives, Integer
end
class PetOwner < ActiveRecord::Base
attribute :pet, T::Attr[PetType]
end
owner = PetOwner.new
owner.pet = { animal_type: "dog", name: "Buddy", breed: "Golden Retriever" }
# => #<Dog name="Buddy" breed="Golden Retriever">
owner.pet = { animal_type: "cat", name: "Whiskers", lives: 9 }
# => #<Cat name="Whiskers" lives=9>
Nested Structs
class Address < T::Struct
const :street, String
const :city, String
end
class Company < T::Struct
const :name, String
const :address, Address
end
class Employee < ActiveRecord::Base
attribute :company, T::Attr[Company]
end
employee = Employee.new
employee.company = {
name: "Acme Corp",
address: { street: "123 Main St", city: "Springfield" }
}
employee.company.address.city # => "Springfield"
Default Values
class Settings < T::Struct
const :theme, String, default: "light"
const :notifications, T::Boolean, default: true
const :page_size, Integer, default: 20
end
class User < ActiveRecord::Base
attribute :settings, T::Attr[Settings]
end
user = User.new
user.settings = {}
user.settings.theme # => "light"
user.settings.notifications # => true
user.settings.page_size # => 20
Validation
TypedAttrs integrates with ActiveModel::Validations:
class Product < T::Struct
include ActiveModel::Validations
const :name, String
const :price, Integer
validates :name, presence: true
validates :price, numericality: { greater_than: 0 }
end
class Order < ActiveRecord::Base
attribute :product, T::Attr[Product]
validates :product, typed_attrs: true
end
order = Order.new
order.product = { name: "", price: -100 }
order.valid? # => false
order.errors.full_messages
# => ["Product.name can't be blank", "Product.price must be greater than 0"]
Array elements are validated with indexed error keys:
class Tag < T::Struct
include ActiveModel::Validations
const :name, String
validates :name, presence: true
end
class Article < T::Struct
const :title, String
const :tags, T::Array[Tag]
end
class Post < ActiveRecord::Base
attribute :article, T::Attr[Article]
validates :article, typed_attrs: true
end
post = Post.new
post.article = { title: "Hello", tags: [{ name: "ruby" }, { name: "" }] }
post.valid? # => false
post.errors.full_messages
# => ["Article.tags[1].name can't be blank"]
Supported Features
- ✅ Type-safe attributes using Sorbet
T::Struct
- ✅ PostgreSQL, MySQL, and SQLite (json/jsonb columns)
- ✅ Primitive types:
String
,Integer
,Float
,Boolean
,Symbol
,Date
,Time
- ✅ Complex types:
T::Array
,T::Hash
, Union types,T.nilable
- ✅ Nested structs with arbitrary depth
- ✅ Default values
- ✅ Validation with indexed error messages
- ✅ Automatic RBI generation via Tapioca
Type Mapping
TypedAttrs automatically converts between Ruby/Sorbet types and JSON for database storage:
Ruby/Sorbet Type | JSON Type | Notes |
---|---|---|
String |
string |
Direct mapping |
Integer |
number |
Direct mapping |
Float |
number |
Direct mapping |
T::Boolean |
boolean |
Direct mapping |
Symbol |
string |
Serialized to string, deserialized back to symbol |
Date |
string |
ISO 8601 format (e.g., "2025-10-08" ) |
Time |
string |
ISO 8601 with timezone (e.g., "2025-10-08T15:30:00+09:00" ) |
T::Struct |
object |
Nested object with properties |
T::Array[T] |
array |
Array of specified type |
T::Hash[String, T] |
object |
Object with string keys and typed values |
T.nilable(T) |
null or type |
Allows null values |
Union (discriminated) | object |
Object with discriminator key |
Examples
# Symbol type - stored as string in JSON
class Tag < T::Struct
const :name, String
const :status, Symbol # :active, :archived, etc.
end
# Database: { "name": "ruby", "status": "active" }
# Ruby: Tag.new(name: "ruby", status: :active)
# Date and Time types - stored as ISO 8601 strings
class Event < T::Struct
const :title, String
const :date, Date
const :start_time, Time
end
# Database: { "title": "Meeting", "date": "2025-10-08", "start_time": "2025-10-08T15:30:00+09:00" }
# Ruby: Event.new(title: "Meeting", date: Date.new(2025, 10, 8), start_time: Time.new(2025, 10, 8, 15, 30, 0, "+09:00"))
# Nested struct - stored as nested object
class Address < T::Struct
const :street, String
const :city, String
end
class Company < T::Struct
const :name, String
const :address, Address
end
# Database: { "name": "Acme", "address": { "street": "123 Main", "city": "NYC" } }
# Ruby: Company.new(name: "Acme", address: Address.new(...))
# Array type - stored as JSON array
attribute :colors, T::Attr[T::Array[Color]]
# Database: [{ "name": "Red", "hex": "#FF0000" }, ...]
# Ruby: [Color.new(name: "Red", hex: "#FF0000"], ...]
# Hash type - stored as JSON object
attribute :sizes, T::Attr[T::Hash[String, BoxSize]]
# Database: { "small": { "width": 100, "height": 50 }, ... }
# Ruby: { "small" => BoxSize.new(width: 100, height: 50), ... }
Development
After checking out the repo, run bundle install
to install dependencies.
Running Tests
# Run all tests and linters (default task)
bundle exec rake
# Run tests only
bundle exec rspec
# Run unit tests only
bundle exec rspec --tag type:unit
# Run integration tests only (with SQLite, MySQL, PostgreSQL)
bundle exec rspec --tag type:integration
Code Quality and Type Checking
# Run RuboCop (linter)
bundle exec rubocop
bundle exec rubocop -a # Auto-fix
# Run Sorbet type checker
bundle exec srb tc
bundle exec rake sorbet:tc
# Update RBI files (Tapioca)
bundle exec rake sorbet:update
License
MIT License. See LICENSE file for details.