Python Generics: Type Safety Without Compromise
Dec 24, 2025 • 7 min read
Python Generics: Type Safety Without Compromise
The Problem with Any
Without generics, Python code defaults to the worst kind of typing: everything is Any.
def get_first(items: list) -> Any:
return items[0] if items else None
This compiles. MyPy doesn't complain. But you've lost all type information.
result = get_first([1, 2, 3])
# What type is result? int? str? None? Who knows.
Generics solve this.
What Are Generics?
Generics are parameterized types—containers that preserve type information about their contents.
from typing import TypeVar, List
T = TypeVar('T')
def get_first(items: List[T]) -> T | None:
return items[0] if items else None
Now MyPy knows:
numbers = [1, 2, 3]
result = get_first(numbers) # Type: int | None
names = ["Alice", "Bob"]
first_name = get_first(names) # Type: str | None
The type flows through. No casts. No guessing.
Generic Classes: Building Reusable Containers
Generics shine when building data structures.
A Simple Generic Stack
from typing import Generic, TypeVar
T = TypeVar('T')
class Stack(Generic[T]):
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T | None:
return self._items.pop() if self._items else None
Usage:
int_stack = Stack[int]()
int_stack.push(42)
int_stack.push(100)
value = int_stack.pop() # Type: int | None
# MyPy prevents: int_stack.push("string")
The generic parameter ensures type safety across all operations.
Bounded Type Variables: Constraining Generics
Sometimes you need flexibility, but not total freedom.
from typing import TypeVar, Protocol
class Comparable(Protocol):
def __lt__(self, other) -> bool: ...
T = TypeVar('T', bound=Comparable)
def find_min(items: list[T]) -> T | None:
if not items:
return None
return min(items)
Now find_min works with any comparable type:
find_min([3, 1, 4, 1, 5]) # ✓ int is comparable
find_min(["zebra", "apple"]) # ✓ str is comparable
find_min([lambda: None]) # ✗ functions aren't comparable
Bounds give you type safety with semantic guarantees.
Generic Protocols: Duck Typing with Types
Protocols let you define behavior without inheritance.
from typing import Protocol, TypeVar
T = TypeVar('T')
class Serializable(Protocol):
def to_dict(self) -> dict: ...
def serialize_all(items: list[Serializable]) -> list[dict]:
return [item.to_dict() for item in items]
Any class with a to_dict method works:
class User:
def to_dict(self) -> dict:
return {"type": "user"}
class Product:
def to_dict(self) -> dict:
return {"type": "product"}
# Both work without inheritance
serialize_all([User(), Product()])
This is structural typing—the Pythonic way.
Real-World Pattern: Generic Repository
A common backend pattern:
from typing import Generic, TypeVar, Protocol
from abc import abstractmethod
class Entity(Protocol):
id: int
T = TypeVar('T', bound=Entity)
class Repository(Generic[T]):
def __init__(self, entity_type: type[T]):
self._entity_type = entity_type
self._store: dict[int, T] = {}
def save(self, entity: T) -> None:
self._store[entity.id] = entity
def find_by_id(self, id: int) -> T | None:
return self._store.get(id)
def all(self) -> list[T]:
return list(self._store.values())
Usage:
class User:
def __init__(self, id: int, name: str):
self.id = id
self.name = name
user_repo = Repository[User](User)
user_repo.save(User(1, "Alice"))
user = user_repo.find_by_id(1) # Type: User | None
# MyPy prevents: user_repo.save(Product(...))
One implementation. Full type safety across all entity types.
Advanced: Multiple Type Parameters
Generics support multiple parameters:
from typing import Generic, TypeVar
K = TypeVar('K')
V = TypeVar('V')
class Cache(Generic[K, V]):
def __init__(self) -> None:
self._data: dict[K, V] = {}
def get(self, key: K) -> V | None:
return self._data.get(key)
def set(self, key: K, value: V) -> None:
self._data[key] = value
Usage:
cache = Cache[str, int]()
cache.set("count", 42)
value = cache.get("count") # Type: int | None
Both key and value types are tracked independently.
Generic Functions with Overloads
Sometimes you need different return types based on input:
from typing import overload, TypeVar
T = TypeVar('T')
@overload
def parse_value(value: str, type_: type[int]) -> int: ...
@overload
def parse_value(value: str, type_: type[float]) -> float: ...
@overload
def parse_value(value: str, type_: type[bool]) -> bool: ...
def parse_value(value: str, type_: type[T]) -> T:
if type_ is int:
return type_(value)
elif type_ is float:
return type_(value)
elif type_ is bool:
return type_(value.lower() == "true")
raise ValueError(f"Unsupported type: {type_}")
MyPy now knows exact return types:
x = parse_value("42", int) # Type: int
y = parse_value("3.14", float) # Type: float
z = parse_value("true", bool) # Type: bool
Why Generics Matter in Production
Without generics:
- Every container loses type information
- Refactoring requires manual type tracking
- Tests catch what the type system should
With generics:
- Types flow through your entire system
- Refactoring is safe and mechanical
- Bugs are caught at design time
Generics turn collections from black holes into transparent pipes.
Best Practices
- Use built-in generics: Prefer
list[T]overList[T](Python 3.9+) - Keep bounds simple: Complex bounds reduce reusability
- Prefer protocols over inheritance: Structural typing is more flexible
- Don't over-parameterize: One or two type parameters is usually enough
- Use
TypeVarconsistently: Reuse the same variable for related types
Conclusion
Generics are the difference between type hints that document and types that enforce.
They let you write reusable code without sacrificing precision. They make refactoring safe. They catch bugs before runtime.
In modern Python engineering, generics aren't optional—they're essential.
Write once. Type-check everywhere.
That's the power of generics.