Typed Structs via T::Struct
Sorbet includes a way to define typed structs. They behave similarly to the Struct class built into Ruby, but work better with static and runtime type checking.
Here’s a quick example:
class MonetaryAmount < T::Struct
# (1) Define mutable struct properties with the `prop` DSL
# (like a typed version of `attr_accessor`)
prop :amount, Integer
# (2) Define constant struct properties with the `const` DSL
# (like a typed version of `attr_reader`)
const :currency, String
end
# (3) T::Struct constructors always take arguments via keywords
monetary = MonetaryAmount.new(amount: 1000, currency: 'USD')
# (4) Access the values using getters and setters
p(monetary.amount) # => 1000
monetary.amount = 2100
# (5) `const` properties cannot be updated
monetary.currency = 'GBP'
# ^^^^^^^^^^^ undefined method `currency=`
# (6) Everything is type checked, unlike Ruby's `Struct` class
MonetaryAmount.new(amount: 1000)
# ^ error: Missing required keyword argument `currency`
MonetaryAmount.new(amount: 'not an int', currency: 'USD')
# ^ error: Expected `Integer` but found `String`
monetary.amount + 'not an int'
# ^ error: Expects an `Integer`, not `String`
monetary.amount = 'not an int'
# ^ error: Expected `Integer` but found `String`
Optional properties: T.nilable, default:, and factory:
By default, all T::Struct properties are required on initialization. There are three ways to mark a property as optional:
Provide a
default: ...keyword argument to theproporconst.The provided value will be used if that property is omitted at initialization time.
Provide a proc or lambda via the
factory: ...keyword argument on aproporconst.This is similar to
default:, but the argument will be called (with no arguments) to produce a default value when needed.Declare the prop’s type as a
T.nilable(...)type.Not only will this allow the prop’s value to include
nil, but it also impliesdefault: nilif no explicitdefault:orfactory:value is provided.
class OptionalExample < T::Struct
# All these props are optional
prop :uses_default, String, default: ''
prop :created, Float, factory: ->() { Time.now.to_f }
prop :nilable, T.nilable(Integer)
end
x = OptionalExample.new
x.uses_default # => ''
x.created # => 1666483572.897899
x.nilable # => nil
y = OptionalExample.new
x.uses_default # => ''
x.created # => 1666483576.475571
x.nilable # => nil
T.nilable without implying default: nil
Due to an accident of history (see Legacy code and historical context), props marked T.nilable always have an implied default: nil associated with them.
There is no way to opt out of this behavior at the time being.
One way to work around it is to hide the T.nilable(...) type from Sorbet statically:
class MustConstructWithFoo < T::Struct
NilableInteger = T.type_alias { T.nilable(Integer) }
prop :foo, NilableInteger
end
MustConstructWithFoo.new # error: Missing required keyword arg `foo`
This is a partial, static-only solution: at runtime, the foo keyword is still optional with an implied default of nil. But Statically, Sorbet will require typed call sites to provide a value.
(This workaround only because of a technical limitation in Sorbet: the phase which handles the prop DSL is syntax-directed—it has no semantic information available, which means it does not resolve through type aliases).
Default values and references
To avoid having a default value be shared and mutated by all instances of a T::Struct, certain built-in types are deeply cloned at initialization time. Other types that are not built into Ruby have their .clone method called.
Before we get ahead of ourselves, consider this code:
class Example < T::Struct
# The `[]` default is cloned on initialization,
# so it is not shared by multiple instances.
prop :vals, T::Array[Integer], default: []
end
ex1 = Example.new
ex2 = Example.new
ex1.vals << 'elem'
p(ex2.vals)
It would be surprising if p(ex2.vals) printed ['elem'] in this example—it would mean that the default of [] was shared by reference across all Example instances, so that updating one instance’s vals property simultaneously affected all of them.
To fix this, T::Struct takes measures to clone objects, so that they are not shared:
true,false,nil, anySymbol, anyNumeric, andT::Enumvalues are either value objects (not reference objects) or are known to be immutable, and so are not cloned when being used as a default.Stringinstances that are frozen (according tofrozen?) are not cloned, for performance. All otherStrings have.clonecalled on them before being used as a default value.ArrayandHashdefault values are deeply cloned (i.e., Sorbet recursively calls.clonenot only on theArrayorHashitself, but also on all their elements).- All other default values are simply cloned by calling
.cloneon the provided default.
These rules prevent the most common misuses of accidentally mutating default values via references, but it is still possible to construct cases where the above rules are not strong enough. In such cases, use factory: to compute the default value in whatever way necessary. The value produced by factory: is used verbatim. (This means that factory: can be used when reference sharing across default values is actually the desired outcome.)
Fine-grained inheritance control with override:
Methods defined with prop or const must also annotate the methods they override (just like if the reader and writer methods had been defined as normal def methods). Use the override keyword argument on a prop or const to declare the override. In the simplest cases, this will be one of:
..., override: :reader(only overridesfoo, the reader method)..., override: :writer(only overridesfoo=, the writer method)..., override: true(overrides bothfooandfoo=).
The override keyword also accepts a Hash to declare more fine-grained overrides (for example, if an override incompatibly overrides another method). Use the reader and writer keys:
module Interface
extend T::Sig
extend T::Helpers
abstract!
sig { abstract.returns(T.nilable(String)) }
def foo; end
sig { abstract.params(foo: String).returns(String) }
def foo=(foo); end
end
class Concrete < T::Struct
include Interface
prop :foo, String, override: {reader: {allow_incompatible: true}, writer: true}
end
See Overrides and the prop DSL for a full specification of what override accepts.
Structs and inheritance
Sorbet does not allow inheriting from a class which inherits from T::Struct.
class S < T::Struct
prop :foo, Integer
end
class Bad < S; end # error
Sorbet imposes this limitation somewhat artificially, for performance. Sorbet generates a static signature for the initialize method of a T::Struct subclass. In order to do so, it needs to know all prop's defined on the class. For performance in large codebases, Sorbet requires that it is possible to know which methods are defined on a T::Struct class purely based on syntax—Sorbet does not allow discovering a T::Struct's properties via ancestor information, like the class’s superclass or mixins.
One common situation where inheritance may be desired is when a parent struct declares some common props, and child structs declare their own props:
class Parent < T::Struct
prop :foo, Integer
end
class ChildOne < Parent # error
prop :bar, String
end
class ChildTwo < Parent # error
prop :quz, Symbol
end
This code can be restructured to use composition instead of inheritance:
class Common < T::Struct
prop :foo, Integer
end
class ChildOne < T::Struct
prop :common, Common
prop :bar, String
end
class ChildTwo < T::Struct
prop :common, Common
prop :quz, Symbol
end
Another option is to define a common interface, and repeat the props in each child class:
module Common
extend T::Helpers
extend T::Sig
interface!
sig { abstract.returns(Integer) }
def foo; end
sig { abstract.params(foo: Integer).returns(Integer) }
def foo=(foo); end
end
class ChildOne < T::Struct
include Common
prop :foo, Integer, override: true
prop :bar, String
end
class ChildTwo < T::Struct
include Common
prop :foo, Integer, override: true
prop :quz, Symbol
end
If the code absolutely must use inheritance and cannot use composition, either:
Avoid using
T::Struct, and instead define a normal class, with things likeattr_readerand an explicitinitializemethod.Change the superclass from
T::StructtoT::InexactStruct. This causes Sorbet to no longer statically check the types of any arguments passed to theinitializemethod on the subclass, but does allow definingT::Structhierarchies. This should only be used as a last resort.
Legacy code and historical context
The prop DSL used by T::Struct predates Sorbet by about 5 years. It was originally conceived at Stripe in early 2013 to form the basis for Stripe’s internal object-document mapper (ODM). By the time Stripe began internal development on Sorbet in late 2017, Stripe’s ODM was by far the most commonly used internal abstraction for associating types with methods. At a time when it was not clear that the as-yet-unnamed Ruby type checker project would succeed or not, we were eager to build on existing abstractions to bootstrap early type coverage.
A decision was made to factor the code for the prop DSL into a standalone library, to allow using it independently of the database-specific code in Stripe’s ODM library. From this effort, T::Struct was born. A T::Struct is essentially a Stripe database model class without the database.
Unfortunately, this process left warts in the publicly-accessible T::Struct APIs that persist today. Certain parts of the prop DSL only make sense when used alongside Stripe-internal abstractions. The DSL also contains things that are technically publicly accessible that were never meant to be. This legacy makes it hard to evolve and improve the T::Struct APIs without breaking existing code.
The remainder of this documentation is presented for completeness. Use the APIs below at your own discretion. Our goal here is simply to outline the potential pitfalls that arise when using them.
serialize and from_hash: Converting T::Struct to and from Hash
It’s possible to convert a T::Struct instance to and from Hash instances:
class A < T::Struct
prop :foo, Integer
end
# (1) `serialize` converts from `T::Struct` to `Hash`
serialized = A.new(foo: 42).serialize
p(serialized) # => {"foo"=>42}
# (1) `from_hash` converts from `Hash` to `T::Struct`
deserialized = A.from_hash(serialized)
p(deserialized) # => <A foo=42>
However, serialize and especially from_hash are particularly fraught (see the “gotchas” sections below). It’s likely better to do manual conversion to and from Hash values:
# (1) Convert to hashes directly
class A < T::Struct
prop :foo, Integer
end
a = A.new(foo: 12)
as_hash = {
foo: a.foo
}
# (2) Use keyword splat arguments with `new` to convert from a `Hash`
A.new(**as_hash)
Custom serializations with name:
The name: option on the prop DSL controls the field name that will be used when converting to and from Hash values:
class A < T::Struct
# (1) The name `fooBar` will be used when converting to/from `Hash` values
prop :foo_bar, Integer, name: "fooBar"
end
serialized = A.new(foo_bar: 42).serialize
p(serialized) # => {"fooBar"=>42}
deserialized = A.from_hash(serialized)
p(deserialized) # <A foo_bar=42>
serialize gotchas
As mentioned in the previous section, the serialize behavior was inherited from Stripe’s internal ODM library, and thus has some warts to be aware of:
The
HashhasString-valued keys, unlike Ruby’sStruct#to_hmethod, which producesSymbol-valued keys. Even custom names provided withname:must beStrings.nilproperties are omitted from the resultingHash.Nested
T::StructandT::Enumvalues are also serialized:class Nested < T::Struct prop :bar, Integer end class XorY < T::Enum enums do X = new Y = new end end class Top < T::Struct prop :nested, Nested prop :x_or_y, XorY end p(Top.new(nested: Nested.new(bar: 42), x_or_y: XorY::X).serialize) # => {"nested"=>{"bar"=>42}, "x_or_y"=>"x"}However, union-typed properties containing
T::Structinstances are not serialized:class Foo < T::Struct prop :foo, Integer end class Bar < T::Struct prop :bar, String end class Top < T::Struct prop :foo_or_bar, T.any(Foo, Bar) end foo_top = Top.new(foo_or_bar: Foo.new(foo: 12)) foo_serialized = foo_top.serialize p(foo_serialized) # => {"foo_or_bar"=><Foo foo=12>}Same with generic-typed properties containing
T::Structinstances: these are also not serialized.
from_hash gotchas
As mentioned in the previous section, the deserialize behavior was inherited from Stripe’s internal ODM library, and thus has some warts to be aware of.
The
Hashgiven tofrom_hashmust haveString-valued keys, like the result of callingserialize.The
from_hashmethod does not do the same static nor runtime type checking that theT::Struct'snewmethod would do:- There are no static type checks.
- Required properties missing in the
Hashdo raise exceptions at runtime. - Extra or unknown properties present in the
Hashdo not raise exceptions at runtime unless the optionalstrictargument tofrom_hashis passed (or the method is called via thefrom_hash!wrapper). - The types provided via the
Hashare not checked at runtime.
Because union-typed properties containing
T::Structinstances are not serialized, they must also not be still serialized when given tofrom_hash:class Foo < T::Struct prop :foo, Integer end class Bar < T::Struct prop :bar, String end class Top < T::Struct prop :foo_or_bar, T.any(Foo, Bar) end foo = Foo.new(foo: 12) p(Top.from_hash({"foo_or_bar" => foo})) # => <Top foo_or_bar=<Foo foo=12>> p(Top.from_hash({"foo_or_bar" => foo.serialize})) # => <Top foo_or_bar={"foo"=>12}>And since there are no runtime type checks, the serialized hash value is directly set to the
foo_or_barfield.
Structural vs reference equality
By default, T::Struct values compare using reference equality (“Are these two instances literally the same object in memory?"”), while classes created with Ruby’s Struct class compare using structural equality (“Are these two possibly-different objects both instances of the same class, containing pairwise-equal fields?”).
While it would be nice if T::Struct had been built from the beginning with structural equality, it wasn’t, and now quite a lot of code in the wild depends on this.
For those cases where structural equality is preferred, we recommend defining a custom module that can be included into a T::Struct to override the equality methods, providing structural equality.
Immutable property updates using with
Properties defined with const do not have setter methods, making it impossible to update these properties after construction. A common pattern when working with such classes is to “immutably update” the instance by creating a copy of an object with identical fields except with a different value for the one const property.
This is built into T::Struct, but has some limitations:
class A < T::Struct
const :foo, Integer
const :another_required, Integer
end
a1 = A.new(foo: 1, another_required: 42)
p(a1) # => <A foo=1 another_required=42>
# The `with` method
a2 = a1.with(foo: 2)
p(a2) # => <A foo=2 another_required=42>
Added in haste, the implementation of with uses from_hash to merge the new and old properties and create the new instance. This means it suffers from exactly the same gotchas mentioned in the from_hash gotchas section above.
Legacy and Stripe-specific options
There are a number of other legacy or Stripe-internal options in the prop DSL. Those include dont_store, enum, foreign, ifunset, immutable, raise_on_nil_write, redaction, and sensitivity. Stripe employees can reference these docs to learn more.
Other users of sorbet-runtime are not encouraged to use these options.
Runtime type checking gotchas
In addition to those mentioned in the from_hash gotchas section, there are gaps in how runtime checking for T::Struct props works versus normal methods with sig annotations. These differences are due to T::Struct's historical context and are hard to evolve short of making breaking changes.
For performance,
propandconstgetter methods do not do runtime checking. Specifically, getter methods forpropandconstare defined withattr_reader, which creates methods whose performance is optimized by the Ruby VM.The assumption is that types are validated on initialization and via calls to setter methods. Attempts to write directly into the instance variables backing a prop are not validated:
class A < T::Struct; prop :foo, Integer; end a = A.new(foo: 0) a.instance_variable_set(:@foo, 'not an int') p(a.foo) # no runtime exception, evaluates to a String(Do not directly set instance variables
T::Struct. Always go through the corresponding setter method.)Methods defined via
propandconstdo not have runtime signature objects. This means that methods likecall_validation_error_handlerwill not yield asignaturefor prop calls that fail to type check because there isn’t one.Because
prop- andconst-defined methods do not have signature objects, changing the defaultcheckedlevel does not affect runtime checking for these methods. Constructors and setter methods are always runtime checked.Constructors and setter methods check standard library generic container types recursively:
class A < T::Struct; prop :foo, T::Array[Integer]; end A.new(foo: [0, 'not int']) # runtime exception: all elements must be IntegerThis contrasts with other standard library generic classes, where the generic types are erased. (For user-defined generic classes, the generic types are always erased, even for
propmethods.) This is not configurable.The reasoning: partly historical (too many existing usages depended on being able to validate types of third-party APIs by coercing it into
T::Structand assuming it would raise aTypeError), partly design (it’s worth being really sure that data structures hold what they say they hold, especially if we want to skip runtime checks on getter methods).