skip to content

In a Generic[T] class, when should a method declare its own TypeVar?

level: seniorimportance: should knowfreq 40%

answer

  1. Two different lifetimes for a placeholder
  2. Per object or per call?
  3. Does it appear in __init__?
  4. A transform's result is not the payload
  5. Reusing T pins the return type

basics

~20 s

Declare a second TypeVar on the method whenever the method introduces a type the instance does not already carry — a transform's result type, or a caller-supplied default. The class parameter is fixed once at instantiation; a method-scoped parameter is solved fresh at every call.

solid answer

~40 s

A parameter listed in `class Leg(Generic[T])` is **class-scoped**: it is solved once when the instance is created and every method sees the same `T` for that instance's lifetime. A TypeVar that appears in one method's signature but nowhere in the class's parameter list is **method-scoped**: the checker solves it independently at each call, so `def map(self, fn: Callable[[T], R]) -> Leg[R]` can turn a `Leg[int]` into a `Leg[str]` while `T` stays pinned to `int` for the receiver. The mistake to avoid is reusing the class parameter for a value that has nothing to do with the instance's contents — that forces callers into an unsatisfiable constraint, since `T` is already solved. Rule of thumb: if the type varies per call rather than per object, it belongs on the method.

code

python · 15 lines
python
from collections.abc import Callable
from typing import Generic, TypeVar

T = TypeVar("T")
R = TypeVar("R")

class Leg(Generic[T]):
    def __init__(self, payload: T) -> None:
        self.payload = payload

    def map(self, fn: Callable[[T], R]) -> "Leg[R]":
        return Leg(fn(self.payload))

leg = Leg(3)                 # Leg[int]
print(leg.map(str).payload)  # Leg[str]

go deeper

for a junior

Recall that a class listed as Generic[T] fixes T when you create the object, while a placeholder appearing only inside one method is decided each time you call it. Recognising a mapping method's second parameter is enough here.

for a middle

Explain the decision rule — per object versus per call — and why a transform method needs a second parameter. Be able to write a mapping method's signature and say why it returns a new instance rather than mutating.

for a senior

Diagnose the real-world symptom: callers casting or dropping to Any because a method reused the class parameter and over-constrained itself. Show you can fix the signature rather than the call sites, and explain what the fix restores for downstream code.

for a principal

Own the API-shape tradeoff: how far to push generic parameters through a shared container before the signatures cost more than they return, and when a concrete type or an overload set is the better public surface for other teams.

## Two scopes, one syntax Both kinds of parameter are ordinary `TypeVar` objects; what distinguishes them is **where they are bound**. * A parameter named in the class's generic base — `class Leg(Generic[T])` — is **class-scoped**. It is solved when an instance is created (or when an annotation writes `Leg[int]` explicitly) and stays fixed for that object. Every method body may rely on it, and every method signature that mentions `T` means *this instance's* `T`. * A parameter that appears only inside one method's signature is **method-scoped**. It is solved at each call of that method, independently of the instance and independently of previous calls. ```python class Leg(Generic[T]): def __init__(self, payload: T) -> None: self.payload = payload def map(self, fn: Callable[[T], R]) -> "Leg[R]": return Leg(fn(self.payload)) ``` `T` is class-scoped and pinned by construction; `R` is method-scoped and comes out of whatever callable this particular call was handed. `Leg(3).map(str)` is a `Leg[str]` built from a `Leg[int]`, and the checker tracks that without any casting. ## The decision rule Ask: **does this type vary per object, or per call?** * *Per object* — the element type of a container, the payload a wrapper holds, the row type a repository returns. Class-scoped. * *Per call* — the result type of a transform, the type of a caller-supplied fallback, the type a converter produces. Method-scoped. A related test is whether the type appears in `__init__` or in any attribute annotation. If it does, it describes the instance's state and belongs on the class. If it appears only in one method's parameters and return, it belongs on that method. ## The classic failure The mistake is to reuse the class parameter for a per-call type, because it is already in scope and looks convenient: ```python class Leg(Generic[T]): def map(self, fn: Callable[[T], T]) -> "Leg[T]": ... # too tight ``` Now `map` can only apply transformations that return the *same* type it started with. A route-optimisation job that stores `Leg[int]` distances and wants formatted labels out of them has no way to say so; the checker will reject `leg.map(str)`, and the usual reaction is to reach for a cast or `Any`, which throws away exactly the information the class was written to preserve. The same trap appears on lookup helpers: `def get(self, key: str, default: T) -> T` forces the caller's fallback to match the stored type, whereas a method-scoped second parameter lets `get(k, default=None)` widen the result honestly. ## The other direction is also a bug The mirror-image mistake is declaring a method-scoped parameter for a type that *is* the instance's own. If `def replace(self, item: R) -> "Leg[R]"` is meant to swap the payload in place, the checker now believes an arbitrary unrelated type can be stored in a container whose class parameter says otherwise. A parameter that describes stored state and is not in the class's parameter list is a hole in the class's own invariant. ## What a class-scoped parameter buys the body Inside a generic class, `T` behaves in method bodies exactly as it does in generic functions: unless it carries a bound, the body may only do object-level things with it. Storing it, returning it and passing it on are fine; calling methods on it is not. Attributes annotated with `T` are the normal way to carry it, and constructing a *new* instance of the class parameterised differently — `return Leg(fn(self.payload))` — is how transforms are written, because the class parameter of the new object is solved from its own constructor argument. ## Runtime notes None of this scoping exists at runtime. `Leg[int]` produces a subscripted alias object rather than a new class; calling it constructs an ordinary `Leg`, and the instance's `type()` is `Leg` with no record of the argument. `isinstance` against a subscripted generic raises `TypeError` outright, precisely because the information needed to check it was never kept. So the scoping discipline above is entirely for the checker, the editor and the reader — which is also why getting it wrong is cheap to fix and easy to leave broken: nothing crashes, callers just quietly lose type information or reach for escape hatches.

  • How would you type a lookup method whose default may differ from the stored value type?
    Give the method its own parameter and return the union of the two: the class parameter stays on the stored payload, while a method-scoped parameter covers the caller's fallback and appears in the return alongside it. That lets `get(key, None)` widen the result honestly instead of forcing the fallback to match the stored type or falling back to Any.
  • What happens if a method mentions a TypeVar that the class also lists in its generic base?
    It is the class-scoped one — a parameter name inside a generic class always refers to the class's parameter, already solved for that instance. There is no shadowing that would make it fresh per call, so a method needing a per-call type must introduce a differently named parameter.
  • Why does a transform method construct a new instance rather than mutating in place?
    Because the instance's class parameter is fixed at construction. A `Leg[int]` cannot become a `Leg[str]`; the only way to express the result is a new object whose parameter is solved from the transformed value. That is why such methods return `Leg[R]` rather than None, and it is the same reason the mapping style is standard for generic containers.

The class parameter is the gauge of a railway line — chosen once when the line is laid, and every train on it must match. A method-scoped parameter is the cargo of one particular train: decided per journey, and irrelevant to the next one.

saying these in an interview costs you the question

  • Reuses the class parameter for a transform's result type
  • Thinks a method can rebind the class-scoped parameter
  • Declares a fresh parameter for the stored payload type
  • Says the class parameter is solved per method call
  • Expects the instance to remember its type argument at runtime
  • Reaches for a cast instead of a second parameter

context