0.0
No release in over 3 years
TypedAttrs integrates Sorbet's T::Struct with ActiveRecord's JSON/JSONB columns, providing type-safe structured data storage with runtime and static type checking. Supports nested structs, arrays, hashes, union types, and automatic validation.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
 Dependencies

Runtime

 Project Readme

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.