Skip to content

Latest commit

 

History

History
473 lines (367 loc) · 19.9 KB

tenum.md

File metadata and controls

473 lines (367 loc) · 19.9 KB
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 with Array#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:

  1. We've defined the RedSuit enum subset with a type alias. Due to limitations in T::Enum, this type alias must be declared outside of the enum itself (we expect to lift this restriction in the future).

  2. We've added a to_red_suit instance method on Suit which converts that enum value to either nil 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 the else branch has type T.any(Hearts, Diamonds, Stars), which is not a subtype of RedSuit.

    To treat Stars as a red suit, we'd want to update the definition of RedSuit to mention it. To treat it as not a red suit, we'd want to add it to the when ... 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 the when ... 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 the RedSuit type alias, or from the when ... nil statement (depending on what was removed from Suit).

(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 Symbols or Integers). 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:

→ View on sorbet.run

This code is discouraged because it...

  • overrides the T::Enum constructor, making it brittle to potential future changes in the T::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

→ View on sorbet.run

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:

  1. By using a sealed module
  2. 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:

  1. Sorbet doesn't allow calling methods on T::Enum when we have a value of type DayOfWeek. Since it's an interface, only the methods defined that interface can be called (so for example day_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).

  2. It's not the case that T.class_of(DayOfWeek) is a valid T.class_of(T::Enum). This means that we can't pass DayOfWeek (the class object) to a method that calls enum_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?

  • Union types

    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.

  • Sealed Classes and Modules

    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.

  • Exhaustiveness Checking

    For union types, sealed classes, and enums, Sorbet has powerful exhaustiveness checking that can statically catch when certain cases have not been handled.