Bounded vs Constrained TypeVars
TypeVar("T", bound=Number) sets an upper bound — T can be any subtype of Number, and you
may use every method Number defines. TypeVar("T", int, str) sets constraints — T 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.
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).
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.
# 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.
# 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.
# 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.
# 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.
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 bool→int collapse, the illegal
single-constraint declaration, and the fact that constraints never produce a union.
boolunder constraints: WithTypeVar("T", int, str), aboolargument binds toint(its nearest listed supertype), so the return type isint, surprising callers who expectedbool. This is the same widening that makesdoubled(True)in Step 2 returnint, and it is unavoidable becauseboolis not itself a listed constraint.- A single constraint is illegal:
TypeVar("T", int)with one type raisesTypeError: A single constraint is not allowedat construction — constraints need at least two. If you meant “intor a subtype”, usebound=int; a lone constraint is almost always a bound in disguise. - Constraints don’t union:
doublednever returnsint | str; it returns whichever single listed type matched. If you genuinely want a union return, annotate with an explicitint | str, not a constrainedTypeVar— 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)
TypeVaris effectively invariant over its listed set, whereas a boundedTypeVarparticipates in ordinary variance rules — relevant when you later mark aTypeVarcovariant or contravariant in a generic class. See Variance and Type Parameters for how the ceiling versus the enumeration changes what subtyping relationships hold. - A
TypeVarbound to anotherTypeVar: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.
- Using constraints when you meant a bound:
TypeVar("T", int, float)forbids subtypes and forbids mixing; if you wanted “any number”, usebound=(ideally on a numeric Protocol, sincenumbers.Numberis not statically useful). The wrong choice typically surfaces as mypy[type-var]and pyrightreportGeneralTypeIssuesat 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 pyrightreportAttributeAccessIssue, 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: RaisesTypeErrorat construction time (import time for a module-levelTypeVar). 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
TypeVarto get a union return: If callers should get backint | str, annotate the return asint | strdirectly; a constrainedTypeVarcollapses 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.