Bounded vs Constrained TypeVars

TL;DR

TypeVar("T", bound=Number) sets an upper boundT can be any subtype of Number, and you may use every method Number defines. TypeVar("T", int, str) sets constraintsT must be exactly one of the listed types at each call site, and the body may only use operations common to all of them. Under PEP 695 these become [T: Number] and [T: (int, str)]. Misuse surfaces as [type-var] in mypy.

Both forms restrict what a TypeVar may bind to, but they restrict in opposite ways and produce different inference. A bound is a ceiling that still admits the whole subtype tree below it; constraints are an enumeration that admits only the exact listed types. Choosing the wrong one either over-restricts your API or loses precise return types. This guide, part of Advanced Typing Patterns & Generics, covers the semantics, inference differences, and the modern PEP 695 spelling for both.

Bound vs constrained TypeVar A bounded TypeVar accepts any subtype of the bound; a constrained TypeVar accepts only one of the explicitly listed types. bound=Number accepts Number and ALL subtypes int, float, Fraction, ... full Number protocol usable int, str (constraints) exactly int OR exactly str no other type, no subtypes bool binds to int only shared operations
A bound is a ceiling over a subtype tree; constraints are a fixed list of exact types.

Context: two PEP 484 mechanisms

Both forms come from PEP 484. A bounded TypeVar declares an upper bound with bound=; the variable can bind to that type or any subtype, and inside a generic function you may call any method the bound type guarantees. A constrained TypeVar lists two or more types positionally; the variable must resolve to exactly one of them.

# Python 3.8+, mypy 1.x / pyright 1.1.x — legacy spelling
from typing import TypeVar
from numbers import Number

TBound = TypeVar("TBound", bound=Number)      # any subtype of Number
TConstr = TypeVar("TConstr", int, str)         # exactly int OR exactly str

Step 1: a bounded TypeVar preserves the concrete subtype

With bound=, the checker infers the most precise type at the call site and lets you use the bound’s interface in the body.

# Python 3.11+, mypy 1.x / pyright 1.1.x
from typing import TypeVar

class Money: ...
class USD(Money): ...

TMoney = TypeVar("TMoney", bound=Money)

def tag(value: TMoney) -> TMoney:
    return value

reveal_type(tag(USD()))   # USD — the concrete subtype is preserved, not Money

Because TMoney is bounded, tag(USD()) returns USD, not Money. The body may use any attribute declared on Money.

Step 2: a constrained TypeVar collapses to a listed type

Constraints behave differently: the inferred type is always one of the listed types, never a subtype and never a union.

# Python 3.11+, mypy 1.x / pyright 1.1.x
from typing import TypeVar

TConstr = TypeVar("TConstr", int, str)

def doubled(value: TConstr) -> TConstr:
    return value + value   # only operations valid for BOTH int and str

reveal_type(doubled(3))      # int
reveal_type(doubled("ab"))   # str
reveal_type(doubled(True))   # int — bool is narrowed UP to the listed int

doubled(True) returns int, not bool, because bool is not in the list and the checker selects the listed type bool is compatible with. The body may use only operations valid for every listed type — here + works for both int and str.

Step 3: mixing types is what each rejects

A bound rejects anything outside the subtype tree. Constraints reject anything that is not exactly one of the listed types, and reject mixing them in one call.

# Python 3.11+, mypy 1.x
from typing import TypeVar

TConstr = TypeVar("TConstr", int, str)

def first(a: TConstr, b: TConstr) -> TConstr:
    return a

first(1, 2)        # ok -> int
first("a", "b")    # ok -> str
first(1, "b")      # mypy error: [type-var] — int and str can't satisfy one TypeVar together

Step 4: PEP 695 equivalents

Python 3.12’s PEP 695 type parameter syntax expresses both inline. A bound is [T: Bound]; constraints are [T: (A, B)].

# Python 3.12+, mypy 1.x / pyright 1.1.x — PEP 695 inline form
def tag[TMoney: Money](value: TMoney) -> TMoney:   # bound
    return value

def doubled[TConstr: (int, str)](value: TConstr) -> TConstr:   # constraints
    return value + value

The parenthesised tuple (int, str) is what marks constraints; a bare [T: Money] is a bound. This is the recommended modern spelling on 3.12+; the TypeVar(...) form remains for older runtimes.

Runtime vs static analysis Neither `bound=` nor constraints are enforced at runtime — passing a disallowed type runs normally until something actually breaks. The restriction exists only for the static checker. A `TypeVar` with *both* `bound=` and constraints, however, raises `TypeError` at runtime, because the two mechanisms are mutually exclusive.

Edge cases

  • bool under constraints: With TypeVar("T", int, str), a bool argument binds to int (its nearest listed supertype), so the return type is int, surprising callers who expected bool.
  • A single constraint is illegal: TypeVar("T", int) with one type is rejected — constraints need at least two. Use bound=int if you meant “int or a subtype”.
  • Constraints don’t union: doubled never returns int | str; it returns whichever single listed type matched. If you want a union return, use an explicit int | str annotation, not a constrained TypeVar.

Common mistakes

  • Using constraints when you meant a bound: TypeVar("T", int, float) forbids subtypes and forbids mixing; if you wanted “any number”, use bound=. Wrong choice often shows as [type-var] at call sites.
  • Expecting the body to use subtype-specific methods under constraints: The body may use only operations common to all listed types; calling a str-only method triggers [union-attr] / [attr-defined].
  • Combining bound= and constraints: Raises TypeError at import time. Choose exactly one.

FAQ

When should I prefer a bound over constraints? Use a bound when you want “this type or anything below it” and need the bound’s methods — the common case for numeric or protocol-based generics. Use constraints only for a closed set of unrelated exact types, such as str vs bytes.

Why does my constrained TypeVar return int for a bool input? bool is a subclass of int but is not itself a listed constraint, so the checker resolves the variable to the listed supertype int. Constraints never preserve subtypes.

Back to Generics and TypeVar