Sorbet Schema
Serialize and deserialize Sorbet T::Struct and T::ImmutableStruct values.
Requires Ruby 3.3 or newer. CI tests Ruby 3.3, 3.4, and 4.0.
Installation
Install the gem and add to the application's Gemfile by executing:
$ bundle add sorbet-schema
If bundler is not being used to manage dependencies, install the gem by executing:
$ gem install sorbet-schema
Usage
Sorbet Schema supports both T::Struct and T::ImmutableStruct, and addresses common pitfalls when deserializing and serializing either kind of struct.
Getting Started
While you can directly define a Typed::Schema to be used for your serialization needs, you'll typically use the provided helper class method to generate a Schema from an existing T::Struct or T::ImmutableStruct.
class Person < T::Struct
const :name, String
const :age, Integer
end
schema = Person.schema # => <Typed::Schema
# fields=[....]
# target=Person>Once you have a schema, you can use the built-in serializers (or a custom one that inherits from the Typed::Serializer abstract base class) to create new instances of the struct or convert an instance of the struct to the target format.
json_serializer = Typed::JSONSerializer.new(schema: Person.schema)
# Deserialize from target format
result = json_serializer.deserialize('{"name":"Max","age":29}')
max = result.payload # == Person.new(name: "Max", age: 29)
result = json_serializer.serialize(max)
result.payload # == '{"name":"Max","age":29}'Alternatively, use the built-in helper methods on either struct class to quickly use the built-in serializers.
result = Person.deserialize_from(:json, '{"name":"Max","age":29}')
max = result.payload # == Person.new(name: "Max", age: 29)
result = max.serialize_to(:json)
result.payload # == '{"name":"Max","age":29}':hash, :json, :csv, :yml, and :msgpack are all supported as the first argument to deserialize_from/serialize_to (and the equivalent from_hash/from_json/from_csv/from_yml/from_msgpack methods on Typed::Schema), corresponding to the built-in serializers below.
Notice that both deserialize and serialize return Typed::Results (from the sorbet-result gem) that need to be checked for success or failure before being used. Check out that gem's README for more information on how to interact with Results.
One benefit of using Results is we can add much more details information about why a format is unsuccessfully deserialized or serialized, to provide call sites with more information for error handling, messaging and formatting.
# Unparsable JSON
result = json_serializer.deserialize('{"name""Max","age":29}')
result.error # == Typed::ParseError: json could not be parsed. Check for typos.
# Missing required field
result = json_serializer.deserialize('{"age": 29}')
result.error # == Typed::Validations::RequiredFieldError: name is required.
result = json_serializer.deserialize('{"age":"29-0"}')
result.error # == Typed::Validations::MultipleValidationError: Multiple validation errors found: name is required. | '29-0' cannot be coerced into Integer.Finally, there are built-in coercers that do their best effort to convert common types from the source format to the required schema type.
# Deserialize from target format, with integer coercion
result = json_serializer.deserialize('{"name":"Max","age":"29"}')
max = result.payload # == Person.new(name: "Max", age: 29)Immutable structs
Use T::ImmutableStruct when deserialization must create a frozen struct:
class ImmutablePerson < T::ImmutableStruct
const :name, String
const :age, Integer
end
result = ImmutablePerson.deserialize_from(:json, '{"name":"Ada","age":37}')
ada = result.payload
ada.frozen? # => true
ada.serialize_to(:json).payload # => '{"name":"Ada","age":37}'The helper, serializer factory, and schema APIs retain the concrete immutable
result type in Sorbet. Nested fields, array elements, and hash values declared
as immutable structs are constructed as frozen instances as well. Sorbet does
not recursively freeze strings, arrays, hashes, or mutable child structs.
T::InexactStruct and its other subclasses remain unsupported.
Rails Example
Here's an extended example of how Sorbet Schema can be combined with a normal Rails request to easily convert between formats.
def verify
Typed::HashSerializer
.new(schema: Address.schema) # Generate schema from the `Address` Struct
.deserialize(address_params.to_h) # Use Rails' strong parameters to deserialize into the struct
.and_then { |address| VerifyAddress.new.call(address: T.cast(address, Address)) } # Use sorbet-result's chaining
.and_then do |address|
return render json: Typed::JSONSerializer.new(schema: Address.schema).serialize(address).payload # return a JSON response from the Address struct instance
end
.on_error do |failure| # Use sorbet-result's error handling
case failure
when AddressNotFoundError
head :not_found
when GeoNotSupportedError
head :not_implemented
else
render json: failure, status: :bad_request # use `Typed::Failure`s built-in `to_json` behavior
end
end
endAvailable Serializers
These are the currently available serializers. For more information about implementing a custom one (or contributing one back!), see Custom Coercers.
HashSerializer, JSONSerializer, and YMLSerializer work out of the box with no extra dependencies. CSVSerializer and ArraySerializer with format: :csv require the csv gem; MessagePackSerializer and ArraySerializer with format: :msgpack require the msgpack gem. These gems are not runtime dependencies of sorbet-schema; add the one you use to your own Gemfile. Referencing :csv/:msgpack via serializer/deserialize_from/serialize_to, or constructing an ArraySerializer with either format, without the gem installed raises a clear ArgumentError telling you which gem to add.
ArraySerializer
Typed::ArraySerializer is the explicit API for a document whose root is an array of one schema's structs. It accepts the schema and document format up front, supports both T::Struct and T::ImmutableStruct, and preserves the concrete element type in deserialization results. Deserialization Result payloads are always arrays; serialization payloads are an array for :hash and a format-specific String for :json, :yml, :msgpack, and :csv. Supported formats are :hash, :json, :yml, :msgpack, and :csv; ActiveRecord is intentionally not a collection format.
people = Typed::ArraySerializer.new(schema: Person.schema, format: :json)
result = people.deserialize('[{"name":"Max","age":29},{"name":"Alex","age":31}]')
result.payload # == [Person.new(name: "Max", age: 29), Person.new(name: "Alex", age: 31)]
people.serialize(result.payload).payload
# == "[{\"name\":\"Max\",\"age\":29},{\"name\":\"Alex\",\"age\":31}]"Malformed documents, non-array roots, and invalid records return a failure. When a record fails, the error names its zero-based index so callers can identify the first invalid item.
For CSV, ArraySerializer writes and reads all data rows. An empty collection is represented by a header-only CSV document; blank input also deserializes to an empty collection. Headers use each field's serialized name, including aliases, and must match the schema. This differs deliberately from CSVSerializer, which remains a single-record API and reads only the first data row for backward compatibility. Both CSV serializers reject nested hashes and arrays rather than producing lossy cells.
JSONSerializer
See Getting Started for more information on how to use the JSONSerializer.
HashSerializer
While not strictly serialization, converting structs to and from Ruby Hashes has traditionally had many pitfalls (well-documented in the Sorbet docs). The Typed::HashSerializer supports both struct classes with the same Result handling for invalid or missing data and coercion behavior.
To use it, simply instantiate and use it like the JSONSerializer:
hash_serializer = Typed::HashSerializer.new(schema: Person.schema)
# Deserialize from target format
result = hash_serializer.deserialize({"name" => "Max", age: 29})
max = result.payload # == Person.new(name: "Max", age: 29)By default, the HashSerializer will not serialize values when converting to a Hash. For instance, if a field is an T::Enum type, when it is serialized to a Hash the value will be the Enum and not the String representation. The should_serialize_values option can be passed during initialization to serialize the values when converting to the Hash.
CSVSerializer
Accepts and returns a CSV string representing a single record, with a header row followed by one data row:
csv_serializer = Typed::CSVSerializer.new(schema: Person.schema)
# Deserialize from target format
result = csv_serializer.deserialize("name,age\nMax,29\n")
max = result.payload # == Person.new(name: "Max", age: 29)
# Serialize to target format
result = csv_serializer.serialize(max)
result.payload # == "name,age\nMax,29\n"CSV is a flat, row-based format, so nested structs, Hashes, and Arrays cannot be represented as their own columns. Rather than writing a lossy representation into a cell that can never be parsed back correctly, CSVSerializer#serialize returns a Typed::SerializeError naming the offending field(s). Prefer this serializer for schemas with scalar fields only, or use an inline_serializer (see Inline Serializers) to flatten a nested field into a scalar before serializing it to CSV.
YMLSerializer
Works just like the JSONSerializer, but with YAML strings:
yml_serializer = Typed::YMLSerializer.new(schema: Person.schema)
# Deserialize from target format
result = yml_serializer.deserialize("---\nname: Max\nage: 29\n")
max = result.payload # == Person.new(name: "Max", age: 29)
# Serialize to target format
result = yml_serializer.serialize(max)
result.payload # == "---\nname: Max\nage: 29\n"MessagePackSerializer
Works just like the JSONSerializer, but with MessagePack binary-encoded strings, using the msgpack gem:
message_pack_serializer = Typed::MessagePackSerializer.new(schema: Person.schema)
# Deserialize from target format
result = message_pack_serializer.deserialize(MessagePack.pack({"name" => "Max", "age" => 29}))
max = result.payload # == Person.new(name: "Max", age: 29)
# Serialize to target format
result = message_pack_serializer.serialize(max)
result.payload # == MessagePack.pack({"name" => "Max", "age" => 29})Unlike CSVSerializer, MessagePack natively supports nested maps and arrays, so nested structs, Hashes, and Arrays round-trip without any inline_serializer needed.
ActiveRecordSerializer
Requires the activerecord gem in your application's Gemfile. The gem is loaded lazily when a serializer is constructed; both .serializer(:activerecord) and direct construction raise a clear ArgumentError if it is unavailable. Loading or reflecting on the serializer class does not require ActiveRecord to be installed. The Typed::ActiveRecordSerializer converts between ActiveRecord::Base model instances and either supported struct class, matching fields by attribute name and mapping associated records through the model's declared associations. It requires a model_class option, in addition to schema, so it knows which ActiveRecord::Base subclass to deserialize from and serialize to:
ar_serializer = Typed::ActiveRecordSerializer.new(schema: Person.schema, model_class: PersonModel)
# Sorbet infers ActiveRecordSerializer[PersonModel, Person], retaining both result types.
# Deserialize from target format
result = ar_serializer.deserialize(PersonModel.new(name: "Max", age: 29))
max = result.payload # == Person.new(name: "Max", age: 29)
result = ar_serializer.serialize(max)
result.payload # == PersonModel instance with name: "Max", age: 29When serializing, only fields that map to a column (or to an association's declared foreign key) on model_class are assigned; the rest are ignored.
Nested belongs_to associations are deserialized recursively to arbitrary depth, including immutable struct fields backed by a belongs_to association.
Deserializing reads through the model's association readers, so it is subject to the same N+1 query behavior as any other Rails association access. Preloading with includes works transparently and is the recommended way to avoid a query per record per association when deserializing a collection:
# 1 query for people + 1 query for locations, instead of 1 + N
PersonModel.includes(:location).find_each do |person_model|
ar_serializer.deserialize(person_model)
endDiffering Serialized Keys
Sorbet's name: prop option is honored, so a struct can keep snake_case props while reading and writing the key an external payload actually uses:
class WebhookPayload < T::Struct
const :event_id, String, name: "eventId"
const :email_address, String, name: "emailAddress"
end
WebhookPayload.deserialize_from(:hash, {eventId: "evt-1", emailAddress: "user@example.com"})
# <Typed::Success value=#<WebhookPayload event_id="evt-1", email_address="user@example.com">>
WebhookPayload.new(event_id: "evt-1", email_address: "user@example.com").serialize_to(:hash)
# {eventId: "evt-1", emailAddress: "user@example.com"}Typed::Field exposes this as serialized_name, which defaults to name when the prop declares no name: option. Hand-built schemas can set it directly: Typed::Field.new(name: :event_id, type: String, serialized_name: :eventId).
JSON Schema
A schema can describe the hash it deserializes from as a JSON Schema (draft 2020-12), e.g. to hand it to an API client or an LLM tool definition:
class Job < T::Struct
const :title, String, extra: {description: "Job title"}
const :start_date, T.nilable(Date)
end
Job.schema.to_json_schema
# => {
# "type" => "object",
# "properties" => {
# "title" => {"type" => "string", "description" => "Job title"},
# "start_date" => {"type" => ["string", "null"], "format" => "date"}
# },
# "required" => ["title"]
# }Properties are keyed by the serialized name, values are in serialized form (a T::Enum as the enum of its serialized values, a Date as a string), and a field is required when it is neither nilable nor defaulted. Deserialization ignores unknown keys, so objects are open by default; to_json_schema(additional_properties: false) closes every struct object, e.g. to catch typos in a config file or for LLM structured-output APIs that require it. Nested T::Struct and T::ImmutableStruct values are described through their own schema; pass struct_schema: to Typed::JSONSchema.generate to resolve them differently. A type without a JSON form raises Typed::JSONSchema::UnsupportedTypeError.
Customization
From the get-go, Sorbet Schema is designed to be extensible to model more complex data validation requirements and many serialization formats. We try out best to include built-in, battle-tested coercers and serializers from real world use cases and would love to see/upstream any customizations that the community have found useful!
Custom Coercers
At their simplest forms, coercers are any class that inherit from the Typed::Coercion::Coercer abstract base class. The list of default coercers that are applied can be found in the CoercerRegistry. Let's look at the DateCoercer's implementation:
require "date"
class DateCoercer < Coercer
extend T::Generic
Target = type_member { {fixed: Date} }
sig { override.params(type: T::Types::Base).returns(T::Boolean) }
def self.used_for_type?(type)
T::Utils.coerce(type) == T::Utils.coerce(Date)
end
sig { override.params(type: T::Types::Base, value: Value).returns(Result[Target, CoercionError]) }
def coerce(type:, value:)
return Failure.new(CoercionError.new("Type must be a Date.")) unless self.class.used_for_type?(type)
return Success.new(value) if value.is_a?(Date)
Success.new(Date.parse(value))
rescue Date::Error, TypeError
Failure.new(CoercionError.new("'#{value}' cannot be coerced into Date."))
end
endNotice that this utilizes sorbet generic, so the target type must be defined using type_member. For dates, this is the built-in std lib type Date.
From there, implement the used_for_type? method which receives a type and returns true if the coercer can be used to coerce to that type or false if it should not be used. Notice that we use the T::Types module directly from Sorbet, which allows us to model the built-in Sorbet types, such as T::Boolean and T::Array. Typically, T::Utils.coerce(TargetType) is used to match the target type. For dates, this is a very simple type check for a Date.
Finally, implement the coerce method. If a coercion is successful, return a Success.new(coerced_value). If not, return a Failure with a coercion error Failure.new(CoercionError.new("I can't coerce to the type")). Take care to handle any exceptions that could arise from the attempted coercion. For dates, first it checks and make sure the type given matches the target type. This is a common check and is largely an edge case check for completeness. Next, if the value is already a Date we simply return a Success with it. Finally, we use the built-in Date.parse method to actually attempt a coercion. Since this can throw a Date::Error and a TypeError, rescue from those with a Failure.
Once a custom coercer is defined, the last step is to register it with Sorbet Schema during initialization. Typically, this is after sorbet-schema has been required or during the bootstrapping step of a framework, such as Rails' initializers. Call register_coercer like so:
Typed::Coercion.register_coercer(MyCoercer) # make sure `MyCoercer` is loaded by this pointNote Custom coercers are prepended to the list of available coercers so that they are checked during deserialization before the built-in coercers. This allows consuming projects to override default behavior by creating a coercer that re-implements the coerce method for that type.
Inline Serializers
Sometimes, there is custom behavior that needs to be added to how a field is serialized (represented as a String), such as when you need to use a different strftime format for Dates and Times. This can be accomplished with an InlineSerializer (defined in Typed::Field), which is a Proc that takes the value and returns a different representation. At present, these are both very loose T.untyped types to allow for flexibility. Typically, a String is returned.
The serializer can be used when creating a Schema and defining its fields, or with the add_serializer helper on Schemas.
my_date_serializer = ->(date) { date.strftime("%Y/%m") }
# use directly on a Schema - `Typed::Schema` is generic over its target struct,
# so prefer `from_struct`, which infers the type argument, over `Schema.new`
Typed::Schema.from_struct(SchemaWithDateField).add_serializer(:date, my_date_serializer)
# or, if you need custom `fields` up front, specify the type argument explicitly
Typed::Schema[SchemaWithDateField].new(
target: SchemaWithDateField,
fields: [
Typed::Field.new(name: :date, type: Date, inline_serializer: my_date_serializer)
]
)
# use `add_serializer` helper
SchemaWithDateField.schema.add_serializer(:date, my_date_serializer)Implementing Custom Serializers
While Sorbet Schema ships with popular serializers, you can define your own by inheriting from Typed::Serializer. Let's look at the JSONSerializer:
require "json"
class JSONSerializer < Serializer
Input = type_member { {fixed: String} }
Output = type_member { {fixed: String} }
StructT = type_member { {upper: T::InexactStruct} }
sig { override.params(source: Input).returns(Result[StructT, DeserializeError]) }
def deserialize(source)
parsed_json = JSON.parse(source)
return Failure.new(ParseError.new(format: :json)) unless parsed_json.is_a?(Hash)
creation_params = schema.fields.each_with_object(T.let({}, Params)) do |field, hsh|
hsh[field.name] = parsed_json[field.name.to_s]
end
deserialize_from_creation_params(creation_params)
rescue JSON::ParserError
Failure.new(ParseError.new(format: :json))
end
sig { override.params(struct: StructT).returns(Result[Output, SerializeError]) }
def serialize(struct)
return Failure.new(SerializeError.new("'#{struct.class}' cannot be serialized to target type of '#{schema.target}'.")) if struct.class != schema.target
Success.new(JSON.generate(serialize_from_struct(struct: struct, should_serialize_values: true)))
end
endSince Serializer is a generic class, we need to define our Input, Output, and StructT types. For JSON, deserialization and serialization both use JSON strings, so Input/Output are both String. StructT is the struct type the serializer round-trips to/from - it must always be re-declared as type_member { {upper: T::InexactStruct} } in the subclass (Sorbet requires each generic type member to be re-declared down the inheritance chain). Schemas themselves continue to accept only T::Struct and T::ImmutableStruct descendants, while using StructT in deserialize/serialize preserves the concrete type at every deserialize entry point.
Next, the deserialize and serialize methods must be implemented. Notice that both of these return Results.
For deserialization, the JSON is parsed (and a parse error is handled). Then we build up a creation params hash from the parsed json to pass to the deserialize_from_creation_params helper, defined on Serializer.
For serialization, the passed struct is checked to make sure it matches the Schema. Then it uses the serialize_from_struct helper and passes the resulting Hash to generate JSON.
Inspirations
This project is heavily inspired by serde from the Rust community and the dry-rb family of gems.
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake to run Standard and the tests. bin/console for an interactive prompt that aids with experimentation.
To install this gem onto a local machine, run bundle exec rake install.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/maxveldink/sorbet-schema. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in this project's codebase, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.
Sponsorships
I love creating in the open. If you find this or any other maxveld.ink content useful, please consider sponsoring me on GitHub.