Sometimes we need sum types. Two approaches both have gaps:
Plain inheritance: type checkers can’t verify exhaustiveness or flag unreachable branches, because anyone can define a new subclass later.
@sealed: it gives the checker a closed hierarchy, but the variants stay as flat top-level classes. No namespacing.
Before
# assume from typing import sealed
from dataclasses import dataclass
@sealed
class Result[T, E]:
pass
@dataclass
class Ok(Result[T, E]):
value: T
@dataclass
class Err(Result[T, E]):
error: E
Ok and Err just top-level names. Nothing ties them to Result syntactically (This is the standard @sealed layout from the draft PEP)
After
@sumtype
class Result[T, E]:
@dataclass
class Ok:
value: T
@dataclass
class Err:
error: E
@sumtype does three things:
Namespace:Result.Ok / Result.Err become the intended construction path, and the checker treats them as variants of Result.
Implicit sealing: the checker assumes Result.Ok and Result.Err are the only variants. That’s what makes exhaustiveness and unreachable-pattern checking possible. Plain inheritance can’t do this.
r: Result[int, str] = Result.Ok(42)
match r:
case Result.Ok(a): ...
case Result.Err(e): ...
# checker reports missing case if one variant is omit ted
Relationship to @sealed
Not a competitor. It’s a syntactic front-end for a common layout: a sealed hierarchy with nested dataclass variants. If @sealed lands, @sumtype can just be sugar that produces a @sealed hierarchy. No new sealing semantics.
Worth noting: the Jul 2025 thread already showed that nested variants can be expressed manually with ClassVar + __qualname__, but type checkers still can’t infer exhaustiveness from that pattern—someone can always add another subclass later.@sumtype is essentially automating that manual pattern and attaching the missing closure directive.
Open questions
Should @sumtype be purely a checker directive (zero runtime behavior), or also register variants for runtime introspection?
How should generic parameters propagate to nested variants?
Should sealing be optional (@sumtype(sealed=False))?
Or If you have other suggestions, we can discuss together
FYI, I “solved” this issue in my library for Result and Option types by mixing type Result = Ok | Err, a base class Protocol ResultType, and the two concrete subclasses Ok and Err.
This make pattern matching work as expected.
But it requires being explicit for the __new__ return type as Result, and is a bit tricky to make work across all type checkers.
See here for the stubs (src code in rust):
And here for various typing tests:
I’m sharing this because I 100% agree that it should be easier to represent this in python, but at the same time since it works fine for me ATM I don’t have any strong view about how the API should look like. Note that I did not even knew about sealed, will look into it
Seems reasonable. sealed=False forces downstream users to be more careful with exhaustiveness so their code doesn’t have to change if more options are added. It’s good for APIs that sometimes get new, purely optional convenience features that existing users won’t need. Basically the same use case as Rust’s non_exhaustive.
I’d very much like to see this, which does seem to basically be the same proposal as Sum Types in the stdlib linked by the OP: be able to place the prong classes inside the union class, and then apply some decorator to the union class to make it a union. I’m a very frequent user of tagged unions in Zig, and I use “dataclasses with inheritance” in Python as the closest equivalent (I prefer this pattern over typing.Union, as it’s awkward to have to list all the prong names twice, especially when adding or removing prongs).
It seems to me that - when ignoring the needs of typecheckers - we should be able to implement this in pure Python in current versions, with no language changes needed.
If so, perhaps the question to ask is: what typing feature enhancements are needed to make this work? (And if not, what am I missing?)
Also, to bikeshed, I’d personally go for @union rather than @sumtype, but either are fine.