id | title | sidebar_label |
---|---|---|
tenum |
Typed Enums via T::Enum |
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
.
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.
We might want a type for "only red suits" or "only black suits." T::Enum
allows using individual enum values like Suit::Hearts
or Suit::Diamonds
as
types (in addition to the name of the enum itself, like Suit
):
# (1) Hearts and Diamonds are enum values and also types
sig {params(red_suit: T.any(Suit::Hearts, Suit::Diamonds)).void}
def handle_red_suit(red_suit)
case red_suit
when Suit::Hearts then "..."
when Suit::Diamonds then "..."
# (2) exhaustive, even with only two cases handled
else T.absurd(red_suit)
end
end
handle_red_suit(Suit::Hearts) # ok
handle_red_suit(Suit::Spades) # type error
We can also define type aliases of various groups of enum values:
# Two type aliases for two different enum subsets
RedSuit = T.type_alias {T.any(Suit::Hearts, Suit::Diamonds)}
BlackSuit = T.type_alias {T.any(Suit::Spades, Suit::Clubs)}
sig {params(black_suit: BlackSuit).void}
def handle_black_suit(black_suit)
# ...
end
Using enum values in types is useful for modeling enums where there is a frequently-used subset of enum values.
→ View full example on sorbet.run
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.
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
Sometimes it is useful to enumerate all the values of an enum:
Suit.values
# => [#<Suit::Spades>, #<Suit::Heart>, #<Suit::Clubs>, #<Suit::Diamonds>]
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.
This section has been superseded by the Enum values in types 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 using enum values in types and type aliases of enum values, 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.
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.
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.
-
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.