Mastering typing.TypeVar for Generic Functions
Declare TypeVar at module level with a unique name, map it to at least one parameter and the return type, and choose bound= for a class hierarchy or a constraint tuple for a disjoint set. Mixing both raises TypeError; scoping a TypeVar inside a function body causes mypy and pyright to fall back to Any.
This guide delivers exact syntax patterns and static analyzer fixes for implementing Generics and TypeVar correctly in Python functions. It targets precise type inference, constraint resolution, and error elimination for developers maintaining type-safe codebases.
Correct declaration scope prevents cross-function type leakage. Constraint tuples and bound= parameters dictate inference strictness. Static analyzers require explicit TypeVar mapping for return types. These practices align with broader strategies in Advanced Typing Patterns & Generics.
TypeVar objects are plain Python values — no dispatch or specialisation occurs. The T = TypeVar("T") call just creates a marker object. Static checkers use these markers to thread type information through signatures; Python's interpreter ignores them completely.
Declaring and Scoping TypeVar Correctly in Function Signatures
A TypeVar created with the classic PEP 484 syntax, T = TypeVar("T"), is an ordinary module-level name. Static type checkers scope it implicitly: the moment T appears in a function signature, that function acquires its own fresh copy of T for the duration of a single call. This is why the same module-level T can be reused in identity, first, and dozens of other functions without any of them interfering — each call site unifies T independently. Declaring the TypeVar at module level is therefore not a style preference; it is the mechanism that gives the checker a stable object to bind against.
Instantiating a TypeVar inside a function body breaks this. The name never participates in the signature, so mypy and pyright have nothing to unify. The parameter effectively annotates to an unbound variable and the return type collapses to Any, silently disabling every downstream check. Keep the TypeVar("T") call at the top of the module, next to your imports, and reference the name — never the constructor — inside signatures.
from typing import TypeVar
# Module-level declaration ensures proper scope isolation
T = TypeVar("T")
def process_item(item: T) -> T:
# mypy and pyright correctly infer return type matches input
return item
def first(items: list[T]) -> T:
# Reusing the module-level T is safe: each call unifies T on its own
return items[0]
reveal_type(process_item("hi")) # Revealed type is "builtins.str"
reveal_type(first([1, 2, 3])) # Revealed type is "builtins.int"
The variable name you pass to TypeVar must match the name you bind it to (X = TypeVar("Y") is an error mypy flags as [misc]), and the string should be unique per logical concept. Reusing one TypeVar across unrelated functions is harmless because of per-call scoping, but reusing it for two independent parameters of the same function forces both arguments to unify to one type — usually not what you want. When two parameters should vary independently, declare two names, T and S.
On Python 3.12+, PEP 695 adds native syntax that removes the declaration step entirely: def process_item[T](item: T) -> T: .... The [T] clause introduces a type parameter whose scope is exactly the function, auto-scoped by the language rather than by the checker’s inference rules. This makes accidental leakage impossible — there is no module-level name to misuse — and it is the recommended form when you can target 3.12 or newer. The explicit TypeVar("T") form remains necessary for libraries supporting 3.8 through 3.11, and the two styles interoperate freely within a project.
Applying Constraints vs Bounds for Strict Type Inference
Constraints and bounds answer two different questions, and choosing the wrong one silently degrades inference. A constraint tuple — TypeVar("T", str, int) — declares that T is exactly one of the listed types at each call site. The checker will not accept a subclass as a distinct type: passing a bool (a subclass of int) binds T to int, not bool, and passing anything outside the list is rejected outright. Inside the body you may only rely on operations common to every listed type. A bound — TypeVar("T", bound=Base) — declares an upper limit: T may be Base or any subclass, and the body may use the full interface of Base.
The inference consequence is the headline difference. With a constraint tuple the result is the listed type, never a union — format_val("hello") is typed str, not str | int. With a bound, the checker preserves the concrete argument type — upgrade(Child()) is typed Child, not Base — so a caller keeps access to everything Child adds on top of Base. Reach for a bound when a subclass should flow through unchanged; reach for a constraint tuple when the API is genuinely defined over a small, closed set of unrelated types (the classic example is AnyStr = TypeVar("AnyStr", str, bytes) in the standard library).
from typing import TypeVar
# Constraint tuple: T_Disjoint is exactly str or exactly int at each call site
T_Disjoint = TypeVar("T_Disjoint", str, int)
def format_val(val: T_Disjoint) -> T_Disjoint:
return val
# Bound parameter: restricts to the class hierarchy rooted at Base
class Base:
def base_op(self) -> None: ...
class Child(Base):
def child_op(self) -> None: ...
T_Bound = TypeVar("T_Bound", bound=Base)
def upgrade(obj: T_Bound) -> T_Bound:
obj.base_op() # OK: the bound guarantees Base's interface
return obj
reveal_type(format_val("hello")) # Revealed type is "builtins.str"
upgrade(Child()).child_op() # OK: return type is Child, not Base
Passing both a constraint tuple and bound= to one TypeVar is not a type error caught later — it raises TypeError immediately when Python evaluates the TypeVar(...) call at import time. Two further rules matter for constraints: you must supply at least two constraint types (a single constraint is rejected as meaningless), and a constrained TypeVar cannot also be marked covariant or contravariant. For a fuller treatment of when each shape pays off, see Bounded vs Constrained TypeVars.
Resolving Static Analyzer Errors with TypeVar in Return Types
The most common analyzer failure is a TypeVar that appears only in the return annotation. A generic function must be able to infer its type variable from the arguments; if T is not present in any parameter, the checker has nothing to bind it to and the return type is unresolvable. Mypy reports this as A function returning TypeVar should receive at least two arguments containing TypeVar for the two-argument case, or more generally flags the return as invalid; pyright surfaces reportInvalidTypeVarUse. The fix is always the same: make sure the return-type TypeVar also appears in at least one parameter annotation.
from typing import TypeVar
T = TypeVar("T")
# ✗ T appears only in the return type — nothing to infer from
def make() -> T: # mypy: needs T in a parameter
...
# ✓ T is threaded from a parameter into the return type
def make_from(proto: type[T]) -> T:
return proto()
reveal_type(make_from(int)) # Revealed type is "builtins.int"
The second failure class is a genuine mismatch: returning a value whose type does not match the parameter-derived T. That triggers [return-value] in mypy and reportReturnType in pyright. It usually means the body constructed a new object of a fixed type instead of returning something derived from the input — replacing the concrete return with the threaded value resolves it. A related subtlety is invariance: at a function call site the checker resolves T to a single exact type, so def echo(x: T) -> T called on a list[int] yields list[int], never the wider list[object]. Do not reach for covariant=True/contravariant=True to loosen this — variance markers apply to generic classes (see Variance and Type Parameters); a function-level TypeVar is always effectively invariant at the call site, and marking one variant is rejected as [misc] when it appears in a plain function.
Run both analyzers, ideally in parallel, because they phrase these diagnostics differently and one occasionally catches what the other misses. Enabling strict mode makes the return-only-TypeVar case a hard failure instead of a warning, and warn_return_any catches the silent Any fallback that a leaked or unbound TypeVar produces.
# pyproject.toml CI configuration
[tool.mypy]
strict = true
warn_return_any = true
[tool.pyright]
typeCheckingMode = "strict"
Advanced: TypeVar with Callable and Higher-Order Functions
Decorators are where a TypeVar alone is not enough. Annotating a decorator as Callable[..., R] -> Callable[..., R] preserves only the return type — the ... erases every parameter, so callers lose argument checking, positional/keyword distinctions, and IDE completion on the decorated function. To preserve the whole signature you need to capture the parameters too, and that is exactly what ParamSpec provides. Pair a ParamSpec P (the full parameter list) with a TypeVar R (the return type): Callable[P, R] -> Callable[P, R] tells the checker the wrapper accepts and returns precisely what the wrapped function does.
Inside the wrapper, forward arguments with *args: P.args and **kwargs: P.kwargs. These two attributes are special: P.args and P.kwargs may only be used together on the *args/**kwargs of a function typed with P, and the checker enforces that pairing. functools.wraps copies runtime attributes such as __name__ and __doc__; the ParamSpec annotation is what makes the static signature survive the decoration.
# ParamSpec lives in typing on 3.10+ (PEP 612); before that import it
# from typing_extensions to support 3.8 and 3.9.
from typing import TypeVar, Callable, ParamSpec
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
def log_call(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@log_call
def add(x: int, y: int) -> int:
return x + y
reveal_type(add) # (x: int, y: int) -> int — signature preserved
add("a", "b") # mypy: [arg-type] — caught, unlike Callable[..., R]
When the wrapper adds or removes leading arguments, Concatenate extends P: a decorator that injects a first argument is typed Callable[Concatenate[Connection, P], R] -> Callable[P, R], stripping the injected parameter from the caller-facing signature. Concatenate and ParamSpec were both introduced by PEP 612 and landed in the standard typing module in Python 3.10; on 3.8 and 3.9 import both from typing_extensions. TypeVar itself is far older — PEP 484, available since 3.5 — so only the ParamSpec/Concatenate half of this pattern carries a version caveat. See ParamSpec and Concatenate for the full decorator toolkit.
Common Mistakes
Most TypeVar bugs trace back to a handful of recurring errors: leaking the variable’s scope, defeating inference with Any, and picking the wrong restriction mechanism. Run through this checklist whenever a generic signature behaves unexpectedly or a checker emits a diagnostic you did not anticipate.
- Declaring the
TypeVarinside a function body: the name never enters the signature, so the checker infersAnyand silently disables generic checking. Keep theTypeVar("T")call at module level; on 3.12+, prefer the auto-scopeddef f[T]()syntax so the mistake is impossible. - Reusing the same TypeVar across unrelated functions: static analyzers scope module-level TypeVars per call, so reuse is generally safe — the real hazard is binding one
TypeVarto two parameters of the same function when you meant two independent variables, which forces both arguments to unify. Use separate names (T,S) per distinct concept. - Using
Anyinstead ofTypeVarfor generic returns:Anydisables static checking entirely and lets mismatched types flow through undetected. ATypeVarthreaded from a parameter into the return type preserves the exact input type for every downstream caller. - Confusing constraint tuples with
bound=: constraint tuples restrict to exact, disjoint types and return the listed type; bounds restrict to a class hierarchy and preserve the concrete subtype. Passing both to oneTypeVarraisesTypeErrorat import time, and a single-element constraint tuple is rejected as invalid. - Erasing parameters in decorators with
Callable[..., R]: the...drops all argument information. UseCallable[P, R]with aParamSpecto preserve the full signature, addingConcatenatewhen the wrapper injects or removes leading arguments.
Frequently Asked Questions
Why does mypy report “TypeVar is not valid as a return type”? The TypeVar was not bound to any parameter, was declared locally inside a function, or only appears in the return annotation without appearing in any argument annotation. Ensure it appears in at least one argument annotation.
How do I constrain a TypeVar to multiple unrelated types?
Use a constraint tuple: TypeVar('T', str, bytes, int). This restricts the function to exactly those types at each call site and preserves return type inference.
When should I use typing.TypeVar vs typing.Generic for functions?
Use TypeVar directly in function signatures for generic functions. typing.Generic is for class definitions that carry type parameters. Generic functions rely on standalone TypeVar instances without class inheritance.