Typed Enums via T::Enum
Enumerations allow for type-safe declarations of a fixed set of values. “Type safe” means that the values in this set are the only values that belong to this type. Here’s an example of how to define a typed enum with Sorbet:
# (1) New enumerations are defined by creating a subclass of T::Enum
class Suit < T::Enum
# (2) Enum values are declared within an `enums do` block
enums do
Spades = new
Hearts = new
Clubs = new
Diamonds = new
end
end
Note how each enum value is created by calling new
: each enum value is an
instance of the enumeration class itself. This means that
Suit::Spades.is_a?(Suit)
, and the same for all the other enum values. This
guarantees that one enum value cannot be used where some other type is expected,
and vice versa.
This also means that once an enum has been defined as a subclass of T::Enum
,
it behaves like any other Class Type and can be used in method
signatures, type aliases, T.let
annotations, and any other place a class type
can be used:
sig {returns(Suit)}
def random_suit
T.cast(Suit.values.sample, Suit)
end
The
T.cast
is necessary because of how Sorbet behaves withArray#sample
.
Exhaustiveness
Sorbet knows about the values in an enumeration statically, and so it can use
exhaustiveness checking to check whether all enum values
have been considered. The easiest way is to use a case
statement:
sig {params(suit: Suit).void}
def describe_suit_color(suit)
case suit
when Suit::Spades then puts "Spades are black!"
when Suit::Hearts then puts "Hearts are red!"
when Suit::Clubs then puts "Clubs are black!"
when Suit::Diamonds then puts "Diamonds are red!"
else T.absurd(suit)
end
end
Because of the call to T.absurd
, if any of the individual suits had not been
handled, Sorbet would report an error statically that one of the cases was
missing. For more information on how exhaustiveness checking works, see
Exhaustiveness Checking.
Enum values in types
Sorbet allows using individual enum values in types, especially in union types and type aliases. For example:
RedSuit = T.type_alias { T.any(Suit::Hearts, Suit::Diamonds) }
This defines a type alias RedSuit
composed of only hearts
and diamonds. (Contrast this with the type Suit
, composed of all four enum
values.)
Defining a subset of an enum
We can combine the techniques from the two previous sections to define subsets of enum values within an enum, as well as checked cast functions that convert into those subsets safely.
For example:
class Suit < T::Enum
enums do
Spades = new
Hearts = new
Clubs = new
Diamonds = new
end
sig { returns(T.nilable(RedSuit)) }
def to_red_suit
case self
when Spades, Clubs then nil
else
self
end
end
end
RedSuit = T.type_alias { T.any(Suit::Hearts, Suit::Diamonds) }
There are two components to this example:
We’ve defined the
RedSuit
enum subset with a type alias. Due to limitations inT::Enum
, this type alias must be declared outside of the enum itself (we expect to lift this restriction in the future).We’ve added a
to_red_suit
instance method onSuit
which converts that enum value to eithernil
if called on a black suit, or itself otherwise.
This to_red_suit
conversion function could be called like this:
sig { params(red_suit: Suit).void }
def takes_red_suit(red_suit)
# ...
end
sig { params(suit: Suit).void }
def example(suit)
red_suit = suit.to_red_suit
if red_suit
takes_red_suit(red_suit)
else
puts("#{suit} was not a red suit")
end
end
→ View full example on sorbet.run
Sorbet knows that to_red_suit
returns nil
if the suit was not red, so by
assigning to a local variable and using if
, Sorbet’s
flow-sensitive type checking kicks in and allows using
red_suit
with the non-nil type RedSuit
inside the if
condition.
Note that the structure of the to_red_suit
method implementation is
intentional: it specifies the examples of what is not a red suit, instead of
specifying all the cases of what is a red suit in order to be safe in the
presence of refactors.
To outline the refactor-safety of this method, here’s an extended explanation.
For our Suit
example it gets a little contrived, because there are always only
four suits and that’s very unlikely to change. But we can pretend anyways:
If a new enum value is added, maybe called
Stars
, Sorbet will catch that theelse
branch has typeT.any(Hearts, Diamonds, Stars)
, which is not a subtype ofRedSuit
.To treat
Stars
as a red suit, we’d want to update the definition ofRedSuit
to mention it. To treat it as not a red suit, we’d want to add it to thewhen ... nil
statement.If an enum value is removed from
RedSuit
, then Sorbet will report that as a type error similar to the above. After removing a suit from the type alias, the fix would be to explicitly list that removed suit in thewhen ... nil
statement.If an enum value is removed from
Suit
entirely, Sorbet reports this as an “Unable to resolve constant” error. The fix will be to either remove the reference from theRedSuit
type alias, or from thewhen ... nil
statement (depending on what was removed fromSuit
).
(It would be possible to achieve the same effect with exhaustiveness on enums, but in this particular case that ends up being overkill—we can get the same safety guarantees with less redundancy.)
Converting enums to other types
Enumerations do not implicitly convert to any other type. Instead, all conversion must be done explicitly. One particularly convenient way to implement these conversion functions is to define instance methods on the enum class itself:
class Suit < T::Enum
enums do
# ...
end
sig {returns(Integer)}
def rank
# (1) Case on self (because this is an instance method)
case self
when Spades then 1
when Hearts then 2
when Clubs then 3
when Diamonds then 4
else
# (2) Exhaustiveness still works when casing on `self`
T.absurd(self)
end
end
end
A particularly common case is to convert an enum to a String. Because this is so common, this conversion method is built in:
Suit::Spades.serialize # => 'spades'
Suit::Hearts.serialize # => 'hearts'
# ...
Again: this conversion to a string must still be done explicitly. When attempting to implicitly convert an enum value to a string, you’ll get a non-human-friendly representation of the enum:
suit = Suit::Spades
puts "Got suit: #{suit}"
# => Got suit: #<Suit::Spades>
The default value used for serializing an enum is the name of the enum, all
lowercase. To specify an alternative serialized value, pass an argument to
new
:
class Suit < T::Enum
enums do
Spades = new('SPADES')
Hearts = new('HEARTS')
Clubs = new('CLUBS')
Diamonds = new('DIAMONDS')
end
end
Suit::Diamonds.serialize # => 'DIAMONDS'
Each serialized value must be unique compared to all other serialized values for
this enum. The argument to new
currently accepts T.untyped
, meaning you can
pass any value to new
(including things like Symbol
s or Integer
s). A
future change to Sorbet may restrict this; we strongly recommend that you pass
String
values as the explicit serialization values.
Converting from other types to enums
Another common conversion is to take the serialized value and deserialize it
back to the original enum value. This is also built into T::Enum
:
serialized = Suit::Spades.serialize
suit = Suit.deserialize(serialized)
puts suit
# => #<Suit::Spades>
When the value being deserialized doesn’t exist, a KeyError
exception is
raised:
Suit.deserialize('bad value')
# => KeyError: Enum Suit key not found: "bad value"
If this is not the behavior you want, you can use try_deserialize
which
returns nil
when the value doesn’t deserialize to anything:
Suit.try_deserialize('bad value')
# => nil
You can also ask whether a specific serialized value exists for an enum:
Suit.has_serialized?(Suit::Spades.serialize)
# => true
Suit.has_serialized?('bad value')
# => false
Listing the values of an enum
Sometimes it is useful to enumerate all the values of an enum:
Suit.values
# => [#<Suit::Spades>, #<Suit::Heart>, #<Suit::Clubs>, #<Suit::Diamonds>]
Attaching metadata to an enum
It can be tempting to “attach metadata” to each enum value by overriding the
constructor for a T::Enum
subclass such that it accepts more information and
stores it on an instance variable.
This is strongly discouraged. It’s likely that Sorbet will enforce this discouragement with a future change.
Concretely, consider some code like this that is discouraged:
This code is discouraged because it…
- overrides the
T::Enum
constructor, making it brittle to potential future changes in theT::Enum
API. - stores state on each enum value. Enum values are singleton instances, meaning that if someone accidentally mutates this state, it’s observed globally throughout an entire program.
Rather than thinking of enums as data containers, instead think of them as dumb immutable values. A more idiomatic way to express the code above looks similar to the example given in the section Converting enums to other types above:
# typed: strict
class Suit < T::Enum
extend T::Sig
enums do
Spades = new
Hearts = new
Clubs = new
Diamonds = new
end
sig {returns(Integer)}
def rank
case self
when Spades then 1
when Hearts then 2
when Clubs then 3
when Diamonds then 4
else T.absurd(self)
end
end
end
This example uses exhaustiveness on the enum to associate a
rank with each suit. It does this without needing to override anything built
into T::Enum
, and without mutating state.
If you need exhaustiveness over a set of cases which do carry data, see Approximating algebraic data types.
Defining one enum as a subset of another enum
This section has been superseded by the Defining a subset of an enum section above. This section is older, and describes workarounds relevant before that section above existed. We include this section here mostly for inspiration (the ideas in this section are not discouraged, just verbose).
In addition to defining a subset of an enum with type aliases and conversion methods, there are two other ways to define one enum as a subset of another:
- By using a sealed module
- By explicitly converting between multiple enums
Let’s elaborate on those two one at a time.
All the examples below will be for days of the week. There are 7 days total, but there are two clear groups: weekdays and weekends, and sometimes it makes sense to have the type system enforce that a value can only be a weekday enum value or only a weekend enum value.
By using a sealed module
Sealed modules are a way to limit where a module is allowed to be
included. See the docs if you’d like to learn more, but here’s how
they can be used together with T::Enum
:
# (1) Define an interface / module
module DayOfWeek
extend T::Helpers
sealed!
end
class Weekday < T::Enum
# (2) include DayOfWeek when defining the Weekday enum
include DayOfWeek
enums do
Monday = new
Tuesday = new
Wednesday = new
Thursday = new
Friday = new
end
end
class Weekend < T::Enum
# (3) ditto
include DayOfWeek
enums do
Saturday = new
Sunday = new
end
end
→ view full example on sorbet.run
Now we can use the type DayOfWeek
for “any day of the week” or the types
Weekday
& Weekend
in places where only one specific enum is allowed.
There are a couple limitations with this approach:
Sorbet doesn’t allow calling methods on
T::Enum
when we have a value of typeDayOfWeek
. Since it’s an interface, only the methods defined that interface can be called (so for exampleday_of_week.serialize
doesn’t type check).One way to get around this is to declare abstract methods for all of the
T::Enum
methods that we’d like to be able to call (serialize
, for example).It’s not the case that
T.class_of(DayOfWeek)
is a validT.class_of(T::Enum)
. This means that we can’t passDayOfWeek
(the class object) to a method that callsenum_class.values
on whatever enum class it was given to list the valid values of an enum.
The second approach addresses these two issues, at the cost of some verbosity.
By explicitly converting between multiple enums
The second approach is to define multiple enums, each of which overlap values with the other enums, and to define explicit conversion functions between the enums:
→ View full example on sorbet.run
As you can see, this example is significantly more verbose, but it is an alternative when the type safety is worth the tradeoff.
What’s next?
-
Enums are great for defining simple sets of related constants. When the values are not simple constants (for example, “any instance of these two classes”), union types provide a more powerful mechanism for organizing code.
-
While union types provide an ad hoc mechanism to group related types, sealed classes and modules provide a way to establish this grouping at these types’ definitions.
-
For union types, sealed classes, and enums, Sorbet has powerful exhaustiveness checking that can statically catch when certain cases have not been handled.