Thanks for writing up the PEP. However, like Eric, I'm strongly negative on this proposal. I find it unergonomic and not worth it for the benefits it claims to add.
## Favor Composition over Inheritance
On a high level, we want to favor composition over inheritance. With `sealed`, we would be restricted to classes being defined in the same module and all part of a fixed class hierarchy. With union types, we can freely mix and match library classes and user-defined classes. And we can use a class as part of any number of unions, in any module.
A *very* common pattern I see with type aliases is to have a large union of types imported from various modules, including third-party libraries. (For open-source examples, see [1].) This flexible mixing would not be possible with sealed classes:
```
from foo import Foo
from bar import Bar
MyUnion = Foo | Bar | ...
def foo(x: MyUnion) -> int:
if isinstance(x, Foo):
return x.foo_method()
if isinstance(x, Bar):
return x.bar_method()
```
## Scala and Kotlin: Sealed Classes for the Lack of Union Types
You mention Scala as a language that has sealed types. But Scala only got union types in Scala 3 [2]. They were restricted to sealed classes before that. Their own docs point out the weaknesses of sealed classes/traits compared to union types [3]:
> Alternative to Union Types
>
> As shown, union types can be used to represent alternatives of several different types, without requiring those types to be part of a custom-crafted class hierarchy, or requiring explicit wrapping.
>
> Pre-planning the Class Hierarchy
> Other languages would require pre-planning of the class hierarchy, like the following example illustrates:
>
> trait UsernameOrPassword
> case class Username(name: String) extends UsernameOrPassword
> case class Password(hash: Hash) extends UsernameOrPassword
> def help(id: UsernameOrPassword) = ...
>
> Pre-planning does not scale very well since, for example, requirements of API users might not be foreseeable. Additionally, cluttering the type hierarchy with marker traits like UsernameOrPassword also makes the code more difficult to read.
The other reason Scala needed explicit case classes was to enable pattern-matching, since case classes automatically derive the necessary `unapply` method [6]. Python's structural pattern-matching doesn't have that limitation.
Kotlin also lacks union types, and the language designers seem to frequently mention the same downsides as above [4]:
> Union types would make a big difference in Kotlin. At the moment, a sealed class is the closest thing to a union type, but it has several limitations, the biggest being that you can only have subtypes defined by you as part of a sealed class.
> [PEP] A design pattern where a group of record-like classes is combined into a union is popular in other languages that support pattern matching
TypeScript's type system is closer to Python's than Scala or Java. And TypeScript heavily uses union types for exhaustive pattern-matching.
## Motivating Example
I found your motivating code snippet pretty hard to understand when it was written using `sealed`. We have to make each of the classes inherit from the sealed superclass. This is cumbersome for users and ties them down to a class hierarchy.
This goes against the spirit of static duck typing, where we shouldn't have to reach into a class definition and add a parent just to make it acceptable to the type system. With `sealed`, we are limited to classes defined by the user in the same module. In real-world code, I find a lot of examples of unions of types imported from different modules, but very few of the same-module classes that you have. How often do you think this will be used?
The PEP's approach also requires us to define empty shell classes for `Node`, `Expression`, or `Statement`.
Finally, to understand the intended meaning of the `Expression` class, I have to go over the rest of the file and hunt for its subclasses (which could be anywhere in the module). That kind of non-local reasoning is highly unintuitive. With a union, `Expression = Name | Operation`, I can see the components of `Expression` right where it is defined.
Contrast the motivating example to the more idiomatic `Union` version, where we have independent classes:
```
from __future__ import annotations
from typing import *
from dataclasses import dataclass
@dataclass
class Name:
name: str
@dataclass
class Operation:
left: Expression
op: str
right: Expression
@dataclass
class Assignment:
target: str
value: Expression
@dataclass
class Print:
value: Expression
Expression = Name | Operation
Statement = Assignment | Print
Node = Expression | Statement
def dump(node: Node) -> None:
match node:
case Assignment(target, value):
reveal_type(target)
reveal_type(value)
case Print(value):
reveal_type(value)
case Operation(left, op, right):
reveal_type(left)
reveal_type(op)
reveal_type(right)
case _:
assert False
```
The above works just fine in Pyright. I also tested something similar in Pyre [5].
## Specification
> Once disadvantage [of explicitly listing subclasses, as in Java] is that requires that all subclasses to be written twice: once when defined and once in the enumerated list on the base class.
Doesn't your proposal also require the base class to be written an extra time for each subclass? For example, in your motivating example, Node, Expression, and Statement are repeated for each subclass.
## Rejected Ideas
> Having to remember whether to use the base class or the union type in each situation is particularly unfriendly to the user of a sealed class.
Agreed that this is a drawback of the union approach. But this seems minor given that the example was somewhat contrived: the user matched on `Node | None` and did nothing with the `Node` branch. The most common use case is where we are matching on the individual variants of `Node`: `Expression`, `Statement`, etc.
As Thomas (tmkehrenberg) mentioned:
> Unions do support isinstance since python 3.10 but apparently they do not support pattern matching. This feels like an oversight to me.
That should be a reasonable solution to this problem. Even without that, I don't think this is a significant drawback.
## Conclusion
It looks like the main benefits proposed by this PEP are:
1. Not having to import base classes in the cases where we want to match on the whole union instead of matching the individual variants. This seems like a minor benefit and can be addressed by allowing Unions in match.
2. Automatic exhaustiveness-checking: That is, if the match statement for `Node` doesn't check for `Name`, the type checker should warn about it.
This is currently already expressible in 3.11 using Jelle's `case _: assert_never()`. Yes, this is cumbersome to add, but it's not always illegal to leave out a case branch. In cases where it is illegal - say, if each case branch defines a variable, but there is no case branch for `Name` - then the type checker already complains about it. For example:
```
match node:
case Assignment(target, value):
x = 1
case Print(value):
x = 2
case Operation(left, op, right):
x = 3
print(x) # Pyright: "x" is possibly unbound (since there was no branch for Name)
```
A better solution for your use case might be to have type checkers complain about missing case branches in their "strict" mode. That should satisfy users who want `match` to check for exhaustiveness without `assert_never`, while avoiding unnecessary noise for most users.
Other than these two minor benefits, I don't see any compelling advantages. There are many downsides, such as:
1. Not being able to create an ADT using existing types.
2. Tying the user to a class hierarchy.
3. Causing unidiomatic code, with empty base classes that need to be repeated for each subclass and non-local reasoning needed to understand the meaning of the sealed base class.
If we want "versatile ADTs", unions are much more flexible and versatile than the `@sealed` alternative, while providing similar exhaustiveness-checking. So, I'm strongly negative on this PEP.
[1]:
https://grep.app/search?q=%3D%20Union%5B&case=true&filter[lang][0]=Python[2]:
http://dotty.epfl.ch/docs/reference/new-types/union-types-spec.html[3]:
https://docs.scala-lang.org/scala3/book/types-union.html#alternative-to-union-types[4]:
https://discuss.kotlinlang.org/t/union-types/77/103[5]:
shorturl.at/acF26[6]:
https://docs.scala-lang.org/overviews/scala-book/case-classes.html#an-unapply-method