Right, so you’ve mastered the basic annotations: list[int], dict[str, float], all that good stuff. You feel pretty good about yourself. And then you try to write a function that should work on any sequence, or a class that should hold any type of value, and you hit a wall. Your brilliant, generic code is suddenly shackled to, say, int. This is where TypeVar comes in—it’s your key to unlocking actual, honest-to-goodness generics in Python.

Think of a TypeVar (Type Variable) as a placeholder. It’s a stand-in for a type that you promise will be consistent within a single function call or class instance. You’re telling the type checker, “I don’t know what this is yet, but whatever it is, it’s the same here as it is over there.”

Let’s start with the classic, utterly necessary example: a function that returns the first element of a sequence.

from typing import TypeVar, Sequence

T = TypeVar('T')  # Declare a type variable 'T'

def first(seq: Sequence[T]) -> T:
    return seq[0]

Here’s the magic: we’ve created a type variable T. When you call first([1, 2, 3]), the type checker sees the input is a Sequence[int]. It says, “Aha! For this call, T is int.” Therefore, the return type must also be int. Call it with first(["a", "b", "c"])? Now T is str, and the return type is str. You’ve written one function that is correctly typed for an infinite number of types. This is profoundly powerful.

Constraining and Binding Your TypeVar

Sometimes, “any type” is too broad. What if you’re writing a function that uses the + operator? You can’t just add two None objects together. This is where you constrain the TypeVar.

from typing import TypeVar

AddableType = TypeVar('AddableType', int, float, str)  # Can only be one of these

def add_things(a: AddableType, b: AddableType) -> AddableType:
    return a + b

# These are all valid:
result_int: int = add_things(1, 2)
result_str: str = add_things("hello", " world")

# This will (rightfully) make a type checker throw a fit:
result_bad = add_things(1, "oops")  # Inconsistent types for 'AddableType'

You can also use bound for a more flexible, inheritance-based approach. A bound says “T must be a subclass of this type.”

from typing import TypeVar, Protocol

class SupportsClose(Protocol):
    def close(self) -> None: ...

C = TypeVar('C', bound=SupportsClose)  # Any type that has a .close() method

def close_thing(thing: C) -> C:
    thing.close()
    return thing  # We can return it because we know it's of type C

# This works with files, sockets, your own context managers—anything with .close().

The Covariant and Contravariant Rabbit Hole

Brace yourself. This is where it gets academic, but I’ll be direct: you probably don’t need this day-to-day, but when you do, it’s the only tool that fixes the problem.

Variance answers the question: “If B is a subclass of A, is Container[B] a subclass of Container[A]?” Python’s type system needs your guidance here.

  • Invariant (default): Container[B] is NOT a subclass of Container[A]. This is the safest bet. It’s what a list is. A list of cats is not a list of animals if you might .append() a dog to it.
  • Covariant (covariant=True): Container[B] IS a subclass of Container[A]. This makes sense for read-only containers, like Sequence. A sequence of cats is a sequence of animals. You can’t add to it, so it’s safe.
  • Contravariant (contravariant=True): The relationship is reversed. Container[A] is a subclass of Container[B]. This is rare and mostly for argument types. Think of a function that compares two cats—it can also compare two animals.

Here’s how you’d define a covariant type variable for a custom collection:

from typing import TypeVar, Iterable, Iterator

T_co = TypeVar('T_co', covariant=True)  # The '_co' suffix is a common convention

class ImmutableBag(Iterable[T_co]):
    def __init__(self, items: Iterable[T_co]) -> None:
        self._items = tuple(items)

    def __iter__(self) -> Iterator[T_co]:
        return iter(self._items)

# Because it's covariant and read-only, this is valid:
bag_of_cats: ImmutableBag[Cat] = ImmutableBag([Cat(), Cat()])
bag_of_animals: ImmutableBag[Animal] = bag_of_cats  # Type checker says: "Okay!"

The object Fallacy and Best Practices

A common mistake is to reach for object when you want “any type.” Don’t.

def first_bad(seq: Sequence[object]) -> object:
    return seq[0]

value = first_bad([1, 2, 3])  # Type of 'value' is now just 'object'
# You've lost all type information!

The whole point of TypeVar is to preserve that type relationship. Use object only when you truly do not care about the type coming out.

My advice? Keep it simple until you have a specific reason to complicate it.

  1. Start with a plain, invariant TypeVar('T').
  2. Only add constraints (TypeVar('T', int, str)) if your function logic actually requires it.
  3. Only delve into bound and covariant when you’re designing sophisticated class hierarchies and generic collections. Most application code never needs it.

TypeVar is what separates typing novices from pros. It moves you from describing what your code is to describing how your code behaves. And that’s the whole point.