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`
T.nilable
, default:
, and factory:
Optional properties: 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 theprop
orconst
.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 aprop
orconst
.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: nil
if 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
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::Enum
values are either value objects (not reference objects) or are known to be immutable, and so are not cloned when being used as a default.String
instances that are frozen (according tofrozen?
) are not cloned, for performance. All otherString
s have.clone
called on them before being used as a default value.Array
andHash
default values are deeply cloned (i.e., Sorbet recursively calls.clone
not only on theArray
orHash
itself, but also on all their elements).- All other default values are simply cloned by calling
.clone
on 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.)
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
prop :bar, String
end
class ChildTwo < T::Struct
include Common
prop :foo, Integer
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_reader
and an explicitinitialize
method.Change the superclass from
T::Struct
toT::InexactStruct
. This causes Sorbet to no longer statically check the types of any arguments passed to theinitialize
method on the subclass, but does allow definingT::Struct
hierarchies. 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)
name:
Custom serializations with 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
Hash
hasString
-valued keys, unlike Ruby’sStruct#to_h
method, which producesSymbol
-valued keys. Even custom names provided withname:
must beString
s.nil
properties are omitted from the resultingHash
.Nested
T::Struct
andT::Enum
values 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::Struct
instances 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::Struct
instances: 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
Hash
given tofrom_hash
must haveString
-valued keys, like the result of callingserialize
.The
from_hash
method does not do the same static nor runtime type checking that theT::Struct
'snew
method would do:- There are no static type checks.
- Required properties missing in the
Hash
do raise exceptions at runtime. - Extra or unknown properties present in the
Hash
do not raise exceptions at runtime unless the optionalstrict
argument tofrom_hash
is passed (or the method is called via thefrom_hash!
wrapper). - The types provided via the
Hash
are not checked at runtime.
Because union-typed properties containing
T::Struct
instances 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_bar
field.
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.
with
Immutable property updates using 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.