Mastering typing.TypeVar for Generic Functions

TL;DR

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 scoping rules Left panel shows module-level TypeVar used in two functions, each inferring the correct concrete type. Right panel shows a TypeVar declared inside a function body causing the type checker to fall back to Any. Module-level (correct) T = TypeVar("T") # top of module def identity(x: T) -> T: ... def first(xs: list[T]) -> T: ... identity("hi") → inferred str first([1, 2]) → inferred int Function-level (avoid) def broken(x): T = TypeVar("T") # ← inside return x mypy: falls back to Any ✓ preserves concrete type ✗ loses type information
Always declare TypeVar at module scope — function-body declarations cause analyzers to infer Any.
Runtime vs static analysis At runtime, 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.

Per-call scoping of a module-level TypeVar One module-level TypeVar declaration feeds three function call sites, and each site binds the type variable to its own concrete type without affecting the others. module scope T = TypeVar("T") identity(x: T) -> T call: identity("hi") T ↦ str first(xs: list[T]) -> T call: first([1, 2]) T ↦ int wrap(v: T) -> list[T] call: wrap(3.0) T ↦ float
One declaration, three independent bindings — module scope supplies a stable object each call unifies on its own.

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 tupleTypeVar("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 boundTypeVar("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
Constraint tuple versus bound inference A single TypeVar declaration branches into a constraint path that yields the exact listed type, a bound path that preserves the concrete subtype, and a combined path that raises TypeError. TypeVar("T", ...) constraint: (str, int) exactly one listed type bool ↦ int (not bool) ✓ returns str, never a union bound=Base Base or any subclass Child ↦ Child (preserved) ✓ keeps concrete subtype both together ✗ TypeError at declaration
Pick one mechanism per TypeVar; supplying constraints and bound= together raises TypeError when the module is imported.

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.

Resolving return-type TypeVar errors Two failing signatures on the left map through a fix step to a passing signature on the right where the type variable appears in both a parameter and the return type. def make() -> T ✗ T only in return mypy / reportInvalidTypeVarUse return NewThing() ✗ type ≠ parameter T [return-value] / reportReturnType fix: thread T param → return def make_from(p: type[T]) -> T ✓ T inferable from parameter
Both return-type errors share one fix: ensure the return TypeVar also appears in a parameter so the checker can infer it.

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]
Decorator signature preservation with ParamSpec and TypeVar A source function's parameter spec and return type flow through a wrapper annotated with P.args and P.kwargs and emerge unchanged on the decorated function. add(x: int, y: int) Callable[P, R] wrapper *args: P.args **kwargs: P.kwargs -> R @wraps(func) add(x: int, y: int) Callable[P, R] ✓ plain Callable[..., R] would erase P here
P carries the parameter list through the wrapper so the decorated function keeps its exact signature.

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.

TypeVar mistakes checklist Four rows each pair a failing TypeVar practice on the left with the corrected practice on the right. ✗ mistake ✓ fix TypeVar("T") inside function body declare at module level -> Any for generic return thread a TypeVar param → return constraint + bound on one TypeVar pick exactly one mechanism Callable[..., R] on a decorator Callable[P, R] with ParamSpec
Four failure modes and their corrections — most TypeVar problems reduce to one of these.
  • Declaring the TypeVar inside a function body: the name never enters the signature, so the checker infers Any and silently disables generic checking. Keep the TypeVar("T") call at module level; on 3.12+, prefer the auto-scoped def 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 TypeVar to 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 Any instead of TypeVar for generic returns: Any disables static checking entirely and lets mismatched types flow through undetected. A TypeVar threaded 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 one TypeVar raises TypeError at import time, and a single-element constraint tuple is rejected as invalid.
  • Erasing parameters in decorators with Callable[..., R]: the ... drops all argument information. Use Callable[P, R] with a ParamSpec to preserve the full signature, adding Concatenate when 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.

Back to Generics and TypeVar