Skip to main content

Generic Functions and Classes: Type Variables Explained

Generics allow you to write functions and classes that work with any type, while preserving type information. Instead of writing separate max_int(), max_str(), and max_float() functions, you write a single generic max() that accepts any comparable type. This article teaches you to use TypeVar to create flexible, reusable, type-safe code.

What Are Generics and TypeVar?

A TypeVar (type variable) is a placeholder for any type. It lets you write code that works with multiple types while maintaining type safety:

from typing import TypeVar

T = TypeVar("T") # T can be any type

def identity(value: T) -> T:
"""Return the value unchanged."""
return value

# mypy infers the type from the argument
result_int: int = identity(42) # T is int
result_str: str = identity("hello") # T is str
result_list: list = identity([1, 2, 3]) # T is list

The function identity() has a single type variable T. When you call it with an int, mypy infers T = int. When you call it with a str, mypy infers T = str. The return type matches the input type.

Constrained Type Variables

Sometimes you want a type variable that can be any of a set of types, not just any type. Use constraints:

from typing import TypeVar

# T can be int, str, or float—nothing else
Numeric = TypeVar("Numeric", int, float)
Stringlike = TypeVar("Stringlike", str, bytes)

def triple(value: Numeric) -> Numeric:
"""Triple a number (int or float)."""
return value * 3

result_int: int = triple(5) # OK: T is int
result_float: float = triple(2.5) # OK: T is float
result_str: str = triple("a") # mypy: error: str is not a valid constraint

Constraints are useful when you need to call methods or operators that only work on specific types.

Bounded Type Variables

Use bounds to constrain a type variable to types that inherit from a base class:

from typing import TypeVar

class Animal:
def speak(self) -> None:
raise NotImplementedError

class Dog(Animal):
def speak(self) -> None:
print("Woof!")

class Cat(Animal):
def speak(self) -> None:
print("Meow!")

# T must be Animal or a subclass of Animal
AnimalType = TypeVar("AnimalType", bound=Animal)

def make_speak(animal: AnimalType) -> AnimalType:
"""Make an animal speak and return it."""
animal.speak()
return animal

dog = Dog()
result_dog: Dog = make_speak(dog) # OK: Dog is a subclass of Animal

bird = "tweet"
make_speak(bird) # mypy: error: str is not a subclass of Animal

With a bound, the type variable can be any subclass of the bound. This preserves the specific subclass type through the function.

Generic Classes

Classes can also be generic. The Generic base class enables this:

from typing import TypeVar, Generic, List

T = TypeVar("T")

class Stack(Generic[T]):
"""A stack that holds items of type T."""

def __init__(self) -> None:
self.items: List[T] = []

def push(self, item: T) -> None:
"""Push an item onto the stack."""
self.items.append(item)

def pop(self) -> T:
"""Pop the top item from the stack."""
if not self.items:
raise IndexError("pop from empty stack")
return self.items.pop()

def is_empty(self) -> bool:
"""Check if the stack is empty."""
return len(self.items) == 0

# Create a stack of integers
int_stack: Stack[int] = Stack()
int_stack.push(42)
int_stack.push(100)
top: int = int_stack.pop() # mypy knows top is int

# Create a stack of strings
str_stack: Stack[str] = Stack()
str_stack.push("hello")
str_stack.push("world")
message: str = str_stack.pop() # mypy knows message is str

# Type mismatch: caught by mypy
int_stack.push("wrong") # mypy: error: Argument 1 has incompatible type "str"; expected "int"

When you instantiate Stack[int], mypy sets T = int throughout the class, catching type errors in push() and pop().

Multiple Type Variables

Classes and functions can have multiple type variables:

from typing import TypeVar, Generic, Tuple

K = TypeVar("K") # Key type
V = TypeVar("V") # Value type

class Pair(Generic[K, V]):
"""A pair of values with different types."""

def __init__(self, key: K, value: V) -> None:
self.key = key
self.value = value

def swap(self) -> "Pair[V, K]":
"""Swap key and value, reversing their types."""
return Pair(self.value, self.key)

# Create a pair of str and int
pair: Pair[str, int] = Pair("count", 42)
print(pair.key) # mypy: str
print(pair.value) # mypy: int

# Swap returns Pair[int, str]
swapped: Pair[int, str] = pair.swap()
print(swapped.key) # mypy: int
print(swapped.value) # mypy: str

Variance: Covariance and Contravariance

Type variables can be covariant (preserve type hierarchy) or contravariant (reverse it). By default, they are invariant (no variance):

from typing import TypeVar, Generic

T = TypeVar("T") # Invariant

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

class Animal:
pass

class Dog(Animal):
pass

# With invariance, Container[Dog] is not a Container[Animal]
dog_container: Container[Dog] = Container(Dog())
animal_container: Container[Animal] = dog_container # mypy: error: Incompatible types

# To allow this, use covariant TypeVar
T_co = TypeVar("T_co", covariant=True)

class Producer(Generic[T_co]):
"""A producer can be covariant: Dog producer is a Animal producer."""
def produce(self) -> T_co: ...

dog_producer: Producer[Dog] = Producer()
animal_producer: Producer[Animal] = dog_producer # OK: covariance

# Contravariance goes the opposite way
T_contra = TypeVar("T_contra", contravariant=True)

class Consumer(Generic[T_contra]):
"""A consumer can be contravariant: Animal consumer can consume Dog."""
def consume(self, item: T_contra) -> None: ...

animal_consumer: Consumer[Animal] = Consumer()
dog_consumer: Consumer[Dog] = animal_consumer # OK: contravariance

Covariance and contravariance are advanced; most code uses invariant TypeVars. Use them when modeling producers/consumers or when you need to assign a specialized generic to a generalized one.

Practical Example: Generic Repository Pattern

Here's a real-world example—a generic data repository:

from typing import TypeVar, Generic, List, Optional

T = TypeVar("T")

class Repository(Generic[T]):
"""A generic repository for any entity type."""

def __init__(self) -> None:
self.items: List[T] = []

def add(self, item: T) -> None:
"""Add an item to the repository."""
self.items.append(item)

def get_all(self) -> List[T]:
"""Get all items."""
return self.items

def get_by_index(self, index: int) -> Optional[T]:
"""Get an item by index, or None if not found."""
if 0 <= index < len(self.items):
return self.items[index]
return None

class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email

class Product:
def __init__(self, name: str, price: float):
self.name = name
self.price = price

# Use the same Repository class for different entity types
user_repo: Repository[User] = Repository()
user_repo.add(User("Alice", "[email protected]"))
user_repo.add(User("Bob", "[email protected]"))

all_users: List[User] = user_repo.get_all()
first_user: Optional[User] = user_repo.get_by_index(0)

product_repo: Repository[Product] = Repository()
product_repo.add(Product("Laptop", 999.99))
product_repo.add(Product("Mouse", 29.99))

all_products: List[Product] = product_repo.get_all()
first_product: Optional[Product] = product_repo.get_by_index(0)

One generic Repository class works for User, Product, or any type, with full type safety.

Key Takeaways

  • TypeVar defines a type variable that can be any type (or constrained to specific types)
  • Use unconstrained TypeVars for maximum flexibility: T = TypeVar("T")
  • Use constraints for type variables that must be one of a few types: T = TypeVar("T", int, str, float)
  • Use bounds for type variables that must inherit from a base class: T = TypeVar("T", bound=BaseClass)
  • Generic classes inherit from Generic[T] and can have one or more type variables
  • Multiple type variables let you track relationships between different types (e.g., key and value types)
  • Covariance and contravariance are advanced; most code uses invariant TypeVars

Frequently Asked Questions

What's the difference between a constrained TypeVar and a bounded TypeVar?

Constraints list exact types: TypeVar("T", int, str) means T is int or str, nothing else. Bounds allow inheritance: TypeVar("T", bound=BaseClass) means T is BaseClass or any subclass. Bounds are more flexible.

Can I use a generic class without specifying the type parameter?

Partially. If you write Stack() without [int], mypy treats the contents as Unknown. You should always specify the type parameter for clarity: Stack[int]().

How do I return a generic type that's different from the input?

Use multiple TypeVars: def convert(input: T) -> U: ... where T and U are different TypeVars. mypy will track both.

Are generics checked at runtime?

No. Generics are compile-time only. At runtime, Stack[int] and Stack[str] are the same class. Type erasure means runtime checks cannot distinguish them.

Can I use a TypeVar in a regular function (not a class)?

Yes! Any function can use TypeVars: def get_first(items: List[T]) -> T: return items[0]. mypy will infer T from the argument.

Further Reading