Skip to content

Latest commit

 

History

History
219 lines (171 loc) · 6.07 KB

sealed.md

File metadata and controls

219 lines (171 loc) · 6.07 KB
id title sidebar_label
sealed
Sealed Classes and Modules
Sealed Classes

Sorbet supports sealed classes. Sealed classes restrict who's allowed to subclass a class. This is a considerably advanced feature, but it's also considerably powerful. That being said, if you're already familiar with the concept, here's what the syntax looks like in Sorbet:

# -- foo.rb --
module Parent
  extend T::Helpers
  # (1) Mark module as sealed
  sealed!
end

# (2) CAN be included/extended/inherited in `foo.rb`
class Child1; include Parent; end
class Child2; include Parent; end

sig {params(x: Parent).void}
def foo(x)
  case x
  when Child1 then # ...
  when Child2 then # ...
  # (3) Sealed classes support exhaustiveness
  else T.absurd(x)
  end
end


# -- bar.rb --
# (4) CANNOT be included/extended/inherited in other files
class BadChild; include Parent; end # error!

What's the point of sealed classes?

Sealed classes (and modules) let us declare "there are only these subclasses of this class." Consider code that looks like this:

class NotNeeded; ...; end
class Paid;      ...; end
class Failed;    ...; end

ChargeAttemptResult = T.type_alias {T.any(NotNeeded, Paid, Failed)}

We might be annoyed that the definitions for NotNeeded, Paid, and Failed are not intrinsically connected to each other... It's only after defining them that we've linked them with an ad-hoc type alias, declaring a ChargeAttemptResult as the union of those three classes.

We could use a parent class or module (or even an interface) to relate these classes to each other:

module ChargeAttemptResult; end

class NotNeeded; include ChargeAttemptResult; end
class Paid;      include ChargeAttemptResult; end
class Failed;    include ChargeAttemptResult; end

But this would mean we lose out on exhaustiveness: there's no way for Sorbet to know that ChargeAttemptResult is only included in these classes.

But that's precisely what sealed classes let us declare to Sorbet:

module ChargeAttemptResult
  extend T::Helpers
  sealed!     # ← Adding `sealed!` here
end

# ... same classes from before ...

Now Sorbet will do two things:

  • It will only allow ChargeAttemptResult to be included into classes defined in this file.
  • It will will feed information into Sorbet's control flow-sensitive typing to check that all subclasses are handled exhaustively.

Specifically, here's what it would look like to case over this sealed class:

T.reveal_type(x)                     # type:  ChargeAttemptResult
case x
when NotNeeded then T.reveal_type(x) # type:  NotNeeded
when Paid      then T.reveal_type(x) # type:  Paid
else T.absurd(x)                     # error: Missing case for `Failed`
end

Sealed classes are a powerful feature. They let us declare intentional, definition-side interfaces, without sacrificing flow-sensitive typing or exhaustiveness.

Sealed classes vs sealed modules

Throughout this doc we've been talking about sealed classes and sealed modules as if they were interchangeable. But they're actually different in one key way:

  • Sealed modules only require being declared sealed!
  • Sealed classes require being declared both sealed! and abstract!

Modules can't be instantiated, but classes can. So in order to ensure that something exhaustively covers all the subclasses and can ignore handling the parent class, it must be marked abstract so that Sorbet knows a value of that type could never need to be created in the first place.

To re-iterate:

module SealedModule
  extend T::Helpers
  sealed!
end

class SealedClass
  extend T::Helpers
  abstract!
  sealed!
end

Approximating algebraic data types

Given sealed classes, Sorbet is powerful enough to model algebraic data types the same way as other object-oriented languages model them, because it also has record types (T::Struct). An "algebraic data type" is basically a way to say that a type is composed of a fixed set of cases, and in each case, a fixed number of fields are available.

For example, here's an example that combines T::Struct with sealed! modules to create an algebraic data type representing an invoice, which could be in a number of different states:

module InvoiceState
  extend T::Helpers
  sealed!

  class Drafted < T::Struct
    include InvoiceState
  end

  class Opened < T::Struct
    include InvoiceState
    prop :due_at, Time
  end

  class Paid < T::Struct
    include InvoiceState
    prop :charge, Charge
    prop :paid_at, Time
  end

  class Voided < T::Struct
    include InvoiceState
    prop :voided_at, Time
  end
end

sig {params(invoice_state: InvoiceState).void}
def process_invoice(invoice_state)
  case invoice_state
  when InvoiceState::Drafted
    # ... no special props available!
  when InvoiceState::Opened
    puts invoice_state.due_at
  when InvoiceState::Paid
    puts invoice_state.charge
    puts invoice_state.paid_at
  when InvoiceState::Voided
    puts invoice_state.voided_at
  else
    T.absurd(invoice_state)
  end
end

Listing sealed subclasses at runtime

As an extra convenience, a sealed class or module will gain a class method called sealed_subclasses that returns the complete set of sealed subclasses. This can be used to dynamically iterate over all the subclasses:

ChargeAttemptResult.sealed_subclasses.each do |klass|
  puts "Charge attempt can be #{klass.name}"
end

sealed_subclasses returns a Set. Sorbet makes no guarantee about the iteration order of elements in this set. When writing code that assumes a stable iteration order, take care to sort the elements first.

What's next?

  • Abstract Classes and Interfaces

    Sealed classes are often combined with some sort of parent interface which has common methods that all child classes implement. This can be either an abstract parent class or an interface that's included.

  • Final Methods, Classes, and Modules

    Final classes and methods are similar to sealed, in that they're both ways to limit the extent and effects of inheritance. And in fact, they're often combined.