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
Suit.values.sample
end
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.
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 to convert an enum to a String. Because this is so common, this conversion has been built in (it still must be explicitly called):
Suit::Spades.serialize # => 'spades'
Suit::Hearts.serialize # => 'hearts'
# ...
Again: this conversion must 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.
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](#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.
One thing that comes up from time to time is having one large enum, but knowing
that in certain places only a subset of those enums are valid. With T::Enum
,
there are a number of ways to encode this:
- 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.