Notes on splitting unions in subtype checks

I’ve been looking at how type checkers solve subtype constraints lately. This post describes an interesting case that does not seem to be widely discussed, and may be useful to other type checker authors.

Throughout the post I’ll write X <: Y to denote “X is a subtype of Y”, and I’ll only be talking about fully static types.

The problem

The typing spec currently takes a set-theoretic interpretation of types: it treats a type as a set of values, and subtyping as the subset relation (concepts). A union T1 | T2 is the union of the two sets (union types).

With those definitions, a constraint of the form A | B <: X is equivalent to the conjunction (A <: X) && (B <: X).

It is tempting to think the symmetric rule holds when the union is on the other side: surely X <: A | B should be equivalent to (X <: A) || (X <: B)? But on closer look, this doesn’t hold in general set theory: it fails when X has one part inside A and another part inside B. Here, X fits inside A | B as a whole, but inside neither member alone, as depicted below:

┌──────────── A ────────────┐┌──────────── B ────────────┐
│                           ││                           │
│        ┌──────────────────┼┼───────────────┐           │
│        │   part of X      ││   part of X   │           │
│        │   inside A       ││   inside B    │           │
│        └──────────────────┼┼───────────────┘           │
│                           ││                           │
└───────────────────────────┘└───────────────────────────┘

A realistic example exists in today’s type system: bool <: Literal[True] | Literal[False]. This subtyping relation obviously holds, but bool is neither a subtype of Literal[True] nor a subtype of Literal[False].

However, all mainstream checkers I examined (mypy, pyright, pyrefly, and ty) use the rule that X <: A | B implies X <: A || X <: B by default. mypy’s subtypes.py says it directly:

Normally, when ‘left’ is not itself a union, the only way ‘left’ can be a subtype of the union ‘right’ is if it is a subtype of one of the items making up the union.

Why does this simplification remain OK in practice?

Why the split usually works

If we are only talking about ordinary nominal class types, which constitute the vast majority of types in the type system, C <: A | B does imply C <: A or C <: B. This is because subtyping between classes is just subclassing, and subclassing is all-or-nothing on sets of values: if C is a subtype of A, then every C value is an A value, with no exceptions.

So suppose C <: A | B, and suppose C has an instance whose exact runtime class is C. That instance must be either an A or a B. If it is an A, nominal membership means that C subclasses A, so C <: A. Symmetrically, if it is a B, then C <: B. Either way, one of the two holds.

In lattice theory, this property of X <: A | B => (X <: A) || (X <: B) is called being join-prime. In the current Python type system, ordinary classes are join-prime in the common case.

Where it breaks today

This section lists cases where join-primeness can fail, and how type checkers typically account for them.

Union types themselves. When X is itself a union type, join-primeness breaks. For example, A | B <: (A | C) | (B | D) holds, but A | B is not a subtype of either side for unrelated A, B, C, and D.

Type checkers usually address this problem by breaking down the union on the left before splitting the one on the right. In the example above, we would first get two smaller constraints A <: (A | C) | (B | D) and B <: (A | C) | (B | D). Each left-hand side is now an ordinary class, so join-prime reasoning applies safely. Nested unions on the right are harmless: checkers often flatten them (e.g. to A | B | C | D) for convenience, but that isn’t needed for correctness.

Enum and literal types. If a class type can be broken down into finite “smaller” pieces, we’ll lose join-primeness. For example,

  • bool <: Literal[True] | Literal[False]
  • Color <: Literal[Color.RED] | Literal[Color.BLUE] | Literal[Color.GREEN], if we define Color as a three-value enum RED, BLUE, and GREEN.

Type checkers usually handle this via rewrite: a union that contains every literal member of bool or an enum is collapsed back into bool or the enum itself, either when the union is constructed or as a fallback during the subtype check. The check then reduces to bool <: bool or Color <: Color, and the usual branch split never sees the hidden union.

Metatypes. The spec says type[A | B] <: type[A] | type[B] (for unrelated A and B). But type[A | B] is a subtype of neither on the right-hand side. As with the previous example, type checkers can typically avoid this case via normalization.

Fixed-length tuple types. Under set-theoretical interpretation, tuple types exhibit some interesting behaviors:

  • tuple[A | B] <: tuple[A] | tuple[B]
  • tuple[A | B, C | D] <: tuple[A, C] | tuple[A, D] | tuple[B, C] | tuple[B, D]
  • etc.

Again, we can observe that the left-hand side “tuple of union” type need not be a subtype of any union branch in the “union of tuple” types on the right-hand side.

These equivalences are included in the typing spec, but the conformance suite does not directly test them as assignment or subtyping relations. Support for these rules is not universally implemented. In theory, this case can be handled in the same way as before via aggressive normalization: we could break the tuple of union apart (or, when the right-hand side covers every combination, combine it back into a tuple of union). But doing this for tuples would cause a combinatorial explosion as the number of union-bearing positions and alternatives grows, which may explain why type checkers are generally reluctant to do it.

Beyond today’s type system

Python doesn’t have sealed classes or first-class intersection and negation types at the moment. This section looks at what adding them would change.

Sealed classes

A sealed class declares that all of its subclasses are known, which lets a checker reason about that one hierarchy under a closed-world assumption. Consider the following example, using a hypothetical @sealed decorator:

class A: ...
class B: ...

@sealed
class C(ABC):
    @abstractmethod
    def f(self): ...

class D(C, A):   # a C that is also an A
    def f(self): ...

class E(C, B):   # a C that is also a B
    def f(self): ...

Because C is sealed and abstract, every C instance is either a D or an E, so a checker could treat C as D | E. That gives C <: A | B. But C <: A fails because E instances are not A instances; symmetrically, D shows that C <: B fails. As with enums, a checker would need to expand C into its subclasses before applying the usual branch split.

Intersections

An intersection type would bring the mirror image of the union problem:

  • An intersection on the right is easy, since X <: A & B holds exactly when X <: A and X <: B.
  • An intersection on the left is hard, because A & B <: X can hold when neither A <: X nor B <: X does. Each part can supply something X needs, without supplying everything. For example, take protocols RequiresF (requiring a method f), RequiresG (requiring g), and RequiresFG (requiring both). Then RequiresF & RequiresG <: RequiresFG, yet neither RequiresF <: RequiresFG nor RequiresG <: RequiresFG.

Intersections would also give X more ways to hide a union, because intersection distributes over union. For example, (A | B) & C is equivalent to (A & C) | (B & C), so it can fit inside that union even when it fits inside neither branch alone. A checker cannot apply the usual branch split here without losing completeness unless it first accounts for this hidden union structure. It could expand the left-hand side into a union of intersections, factor the right-hand side in the opposite direction, or use a solver that reasons about the Boolean structure directly. The first two approaches amount to converting toward DNF or CNF, both of which risk exponential blowup in the worst case.

Negations

A negation type would break join-primeness further, because it would make the “part of A, part of B” situation easier to construct.

For example, take A and C to be two unrelated nominal classes that could share a common subclass. The subtyping relation C <: A | ~A always holds, because the right-hand side is equivalent to object. But obviously C is a subtype of neither A (they don’t declare an inheritance relation) nor ~A (one could always define another class that subclasses both C and A, so some C objects can be A objects).

One may think that we only need another form of normalization to handle this: e.g. to combine A | ~A into object or A & ~A to Never. But this is incomplete: Take C <: (A & B) | ~A | ~B with unrelated nominal non-final classes A, B, and C. The right-hand side is already in DNF, and in fact it’s equivalent to object, but that can’t be derived without further boolean operations beyond local complement merge. In general, deciding such hidden tautologies is at least as hard as propositional tautology, which is co-NP-hard, so it’s unlikely that one would find a complete decision procedure with worst-case polynomial complexity.

Wrapping up

The split works well today because relatively few supported type forms can hide a union. Checkers normalize or special-case important cases such as enums and metatypes, while accepting some incompleteness elsewhere, such as fixed-length tuple expansion. Sealed classes would add another hidden union to expand, much like enums. Intersection types would create the corresponding problem for A & B <: X, while negation types would make even ordinary nominal classes non-join-prime.

I used to think that these normalization rewrites within type checkers were purely for simplification and performance, but from this investigation I now realize that these rewrites are actually load-bearing for completeness as well.

Once X <: A | B gets converted into X <: A || X <: B, checkers then have to deal with the resulting disjunctive constraints. This is another fascinating topic, and major type checkers are extremely creative in how they avoid solving it in full generality for better performance. But that’s a separate rabbit hole, so I’ll leave it for another day.

8 Likes

This could be a coincidence, but did I cause this rabbit hole? :wink:

Ha, partly! Issues like yours were part of what got me looking at this. Thanks for filing it!

1 Like

This reminds me very much of `AlwaysTruthy | bool` is equivalent to `AlwaysTruthy | Literal[False]` · Issue #216 · astral-sh/ty · GitHub, an issue I tried several times to fix (you can see the linked PRs), but found very hard to in practice. (AlwaysTruthy in ty is a structural type that represents “the set of all objects that are statically known to always evaluate to True in a boolean context”.)

2 Likes

The approach ty takes is to always maintain DNF structure in our types: unions can contain intersections, but intersections can never contain unions at the top level in our representation. And yes, we’ve certainly encountered a fair few cases of exponential blowup! Though nothing so far that we’ve found impossible to mitigate.

2 Likes

Thanks for the responses, Alex!

I hadn’t seen #216 before, and it’s a great example of the problem: bool <: AlwaysTruthy | Literal[False] holds even though bool fits in neither branch on its own. The discussion on the linked PR was eye-opening too. I’d been thinking of these normalizations as sufficient guards, but it looks like there are some wrinkles I hadn’t appreciated earlier. This “decompose” operation mentioned there seems to overlap in concept with what I referred to as “non-join-primeness class type” in this post.

And it’s good to hear DNF is holding up in practice. Out of curiosity, where has the blowup actually shown up for you so far, and what do the mitigations look like? The example I could construct off the top of my head is

def f(x: A1 | A2 | A3) -> None:
    if isinstance(x, (B1, B2, B3)):
        if isinstance(x, (C1, C2, C3)):
            reveal_type(x)  # (A1 & B1 & C1) | (A1 & B1 & C2) | ... (27 terms)

but I’m not sure it’s something people would realistically write.

2 Likes

This came up during intersection discussions

Quoting only a few choice points from it, but it’s worth reading in entirety.

the formulas that define set-theoretic unions and intersections are as follows:
C⊆A∩B⟺(C⊆A∧C⊆B)
C⊇A∪B⟺(C⊇A∧C⊇B)

The duality between intersection and union lies in reversing the subset relation, not in replacing an “and” with an “or”. In fact it is a well understood idea that transitive relations play extremely poorly with “or”, and extremely well with “and”. Hence the above definitions both using “and”.

I’ll also cut to the chase here: you want to reduce the left hand side to a disjunctive normal form, and the right hand side to a conjunctive normal form. The | on the left and the & on the right can then be taken apart. But the & on the left and the | on the right cannot.

Thanks for the pointer! I hadn’t seen that thread, and it’s a great read. The same core observation there, much earlier and more rigorously than I did, and “reverse the subset relation, not swap and with or” is a clean way to put the duality.

I think my notes end up looking at the complementary question: given that C <: A | B <=> C <: A ∨ C <: B isn’t true in general, why do checkers get away with using it? For ordinary nominal classes, it turns out to be fine: once the left-hand side is in DNF and the right-hand side is in CNF, the remaining & on the left and | on the right can be taken apart as well. The cases in the post and the followup discussions (bool and enums, structural types like AlwaysTruthy, negations, etc.) are where that stops being sufficient.