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, which introduced TypeVar in 2015 and defined exactly two ways to narrow what a type variable may bind to. A bounded TypeVar declares an upper bound with the bound= keyword; 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 at each call site. These are the only two mechanisms — there is no “lower bound” and no way to combine them (see the runtime note in Step 4).

PEP 484 fork: bound versus constraints A single TypeVar declaration splits into two mutually exclusive PEP 484 mechanisms. TypeVar("T", ...) bound= positional list bounded one upper bound (a ceiling) binds the bound or any subtype body may use the bound's API constrained two or more exact types binds exactly one listed type body uses only shared API
One declaration, two mutually exclusive PEP 484 mechanisms.

The keyword argument versus the positional arguments is the entire syntactic difference at the TypeVar call. Everything downstream — inference, accepted arguments, usable methods — follows from that one choice.

# 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

A subtle but important detail: bound= takes a single type expression, so it also accepts a union (bound=int | str) or a Protocol. The positional constraint form takes a list of independent types and treats them as alternatives that are never merged. That distinction — one ceiling versus an enumeration — drives every behavioural difference in the steps below.

The core semantics have been stable from Python 3.8 through 3.13: the runtime behaviour of TypeVar(...) has not changed, and the two mechanisms mean exactly what PEP 484 said in 2015. What has changed is spelling and ergonomics. On 3.8–3.9 you would write bound="Money" as a string forward reference or add from __future__ import annotations; on 3.10+ the X | Y union operator is available inside bound=; and 3.12 adds the PEP 695 inline syntax covered in Step 4. If you need the newest TypeVar features (such as infer_variance=True) on an older interpreter, import TypeVar from typing_extensions rather than typing.

Step 1: a bounded TypeVar preserves the concrete subtype

With bound=, the checker solves the type variable to the most precise type available at the call site and then lets you use the bound’s interface in the body. The bound acts as a ceiling on what may be passed, but the inferred value floats down to whatever concrete subtype the caller actually supplied.

Bounded TypeVar preserves the concrete subtype The bound is a ceiling above the subtype tree, and the concrete subtype passes through the function unchanged. ceiling: bound=Money Money USD EUR any node at or below the ceiling is accepted tag(USD()) -> TMoney in: USD out: USD ✓ subtype survives, not widened to Money
The ceiling gates what is accepted; the concrete subtype survives inference.
# 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. mypy’s reveal_type prints Revealed type is "__main__.USD" and pyright prints Type of "tag(USD())" is "USD" — both keep the subtype. Inside the body you may read any attribute declared on Money, because every valid binding is guaranteed to be at least a Money; reaching for an attribute that only USD defines is rejected with mypy [attr-defined] and pyright reportAttributeAccessIssue, since the checker only knows the value is some Money. This is the crucial contrast with constraints: a bound both widens what you may call (to the bound’s full interface) and narrows the return type down to the caller’s exact class. When the bound is a Protocol rather than a concrete class, the same rule applies structurally — any object whose shape matches the Protocol is accepted, and its concrete class is preserved on the way out.

Step 2: a constrained TypeVar collapses to a listed type

Constraints behave differently: the inferred type is always exactly one of the listed types, never a subtype and never a union. Whatever the caller passes is snapped up to the nearest listed type before the function even returns — the concrete class is discarded, unlike the bounded case.

Constrained TypeVar collapses to a listed type Concrete argument types funnel through a constraint and collapse onto exactly one listed type. True (bool) 3 (int) "ab" (str) funnel int str exactly one listed type — never a union
bool and int both collapse to the listed int; the concrete subtype is lost.
# 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 single listed type bool is compatible with. The body may use only operations valid for every listed type — the checker type-checks the body once per constraint, unifying value to each listed type in turn, so value + value must be legal for int and for str. Here + satisfies both, so the body passes; had you written value.upper(), mypy would report [union-attr] (Item "int" of "int | str" has no attribute "upper") because the int branch fails, and pyright would report reportAttributeAccessIssue. This “check the body against each constraint independently” rule is why constraints feel stricter than a bound=int | str union would: a union lets you narrow with isinstance inside the body, whereas a constrained TypeVar demands code that is valid for all listed types simultaneously without narrowing.

Step 3: mixing types is what each rejects

A bound rejects anything outside the subtype tree — pass something that is not a subtype of the bound and you get an error. Constraints are stricter in a second dimension: when the same TypeVar appears in more than one parameter, every occurrence must resolve to the same listed type, so mixing int and str in one call is rejected even though each is individually allowed.

Rejection matrix for a shared constrained TypeVar A two-by-two grid of argument type pairs marks the matching pairs as accepted and the mixed pairs as rejected. first(a: T, b: T) with T = TypeVar("T", int, str) b = int b = str a = int a = str ✓ int both int ✗ [type-var] int + str mixed ✗ [type-var] str + int mixed ✓ str both str
Only the diagonal — matching listed types — is accepted; off-diagonal mixes fail.
# 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

mypy reports Value of type variable "TConstr" of "first" cannot be "int | str" [type-var], and pyright reports the same failure under reportGeneralTypeIssues (roughly “Argument of type ‘str’ cannot be assigned… type ‘int’ is incompatible with constraint”). The fix depends on intent: if the two arguments really may differ, they need two independent type variables (def first(a: T1, b: T2) -> T1), and if you actually want to accept either type in either slot, drop the shared TypeVar entirely and annotate with a plain int | str union. A bound behaves more permissively here — with T = TypeVar("T", bound=object), first(1, "b") would infer T as the join int | str, because a bound is satisfied by any common supertype rather than demanding a single exact match. That difference — join versus exact-match — is the core of why constraints reject mixing.

Step 4: PEP 695 equivalents

Python 3.12’s PEP 695 type parameter syntax expresses both mechanisms inline, with no TypeVar import and no module-level variable. A bound is [T: Bound]; constraints are [T: (A, B)]. The colon reads naturally for a bound (“T is a Money”) but the parentheses are what silently flip the meaning to constraints.

Legacy TypeVar to PEP 695 mapping Each legacy TypeVar spelling maps by an arrow to its equivalent PEP 695 inline form. PEP 484 (3.8+) PEP 695 (3.12+) TypeVar("T", bound=Money) TypeVar("T", int, str) [T: Money] [T: (int, str)] bare = bound parentheses = constraints
Same two mechanisms, no import — parentheses distinguish constraints from a bound.
# 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. The semantics are identical to the legacy forms — PEP 695 is pure syntax sugar over the same solver — so everything in Steps 1–3 carries over unchanged. This is the recommended modern spelling on 3.12+; the TypeVar(...) form remains necessary for older runtimes, and you can keep using it via typing_extensions if you must support pre-3.12 interpreters while still writing TypeVar calls. Note two runtime wrinkles: PEP 695 type parameters are lazily evaluated, so a forward reference in a bound works without from __future__ import annotations, and the implicit TypeVar objects PEP 695 creates are not importable by name the way a module-level TypeVar is.

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 construction time, because the two mechanisms are mutually exclusive: `TypeVar("T", int, str, bound=object)` fails immediately with `TypeError: Constraints cannot be combined with bound=...`.

Edge cases

Several corners trip people up because they follow from the “exact listed type” rule rather than ordinary subtyping intuition. The most common are the boolint collapse, the illegal single-constraint declaration, and the fact that constraints never produce a union.

Edge-case ladder for constrained TypeVars Four rungs each describe an edge case input and its outcome under a constrained TypeVar. bool argument → collapses to listed int, not bool TypeVar("T", int) → TypeError: one constraint is illegal return of doubled() → int or str, never int | str union bound=SomeProtocol → structural ceiling, subtype preserved
Each rung is a distinct surprise that follows from exact-listed-type resolution.
  • 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. This is the same widening that makes doubled(True) in Step 2 return int, and it is unavoidable because bool is not itself a listed constraint.
  • A single constraint is illegal: TypeVar("T", int) with one type raises TypeError: A single constraint is not allowed at construction — constraints need at least two. If you meant “int or a subtype”, use bound=int; a lone constraint is almost always a bound in disguise.
  • Constraints don’t union: doubled never returns int | str; it returns whichever single listed type matched. If you genuinely want a union return, annotate with an explicit int | str, not a constrained TypeVar — the two are frequently confused but produce different signatures.
  • bound= on a Protocol: A bound may be a Protocol, giving a structural ceiling: any object matching the Protocol’s shape is accepted and its concrete class is preserved on return. Constraints cannot express this, since they demand membership of an exact listed set rather than structural conformance.
  • Bounds interact with variance; constraints are value-restricted: A value-restricted (constrained) TypeVar is effectively invariant over its listed set, whereas a bounded TypeVar participates in ordinary variance rules — relevant when you later mark a TypeVar covariant or contravariant in a generic class. See Variance and Type Parameters for how the ceiling versus the enumeration changes what subtyping relationships hold.
  • A TypeVar bound to another TypeVar: bound= accepts an in-scope type variable (TypeVar("S", bound=T)), chaining ceilings; constraints have no analogue, because a constraint list must be concrete types, not type variables.

Common mistakes

Most real-world bugs here come from reaching for constraints when a bound was wanted, or expecting a constrained variable to behave like a union. The checklist below captures the recurring errors and the exact diagnostic each checker emits.

Common bounded vs constrained TypeVar mistakes A checklist marking four frequent mistakes and the error code each one produces. constraints used where a bound was meant mypy [type-var] at the call site subtype-only method under constraints mypy [union-attr] / [attr-defined], pyright reportAttributeAccessIssue bound= and constraints together TypeError at construction — runtime, not the checker pick one mechanism deliberately: ceiling vs enumeration
Three recurring failures and the single rule that avoids them all.
  • Using constraints when you meant a bound: TypeVar("T", int, float) forbids subtypes and forbids mixing; if you wanted “any number”, use bound= (ideally on a numeric Protocol, since numbers.Number is not statically useful). The wrong choice typically surfaces as mypy [type-var] and pyright reportGeneralTypeIssues at call sites where a subtype or a mixed pair is passed.
  • 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 such as .upper() triggers mypy [union-attr] (or [attr-defined] depending on context) and pyright reportAttributeAccessIssue, because the checker validates the body against every constraint in turn. A bound avoids this by guaranteeing the whole bound interface.
  • Combining bound= and constraints: Raises TypeError at construction time (import time for a module-level TypeVar). This is a runtime failure, not a checker diagnostic, so it will crash even code you never type-check. Choose exactly one mechanism — a ceiling or an enumeration, never both.
  • Reaching for a constrained TypeVar to get a union return: If callers should get back int | str, annotate the return as int | str directly; a constrained TypeVar collapses to one listed type and can never yield the union, as shown in the Edge cases above.

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