Generic Classes and Protocols
You've seen how generic functions like get_first_item[T] work with any type while preserving type information. Now comes the next level: generic classes. Imagine building a Stack that works with integers, strings, User objects—any type—with full type safety. Or a Cache that maps any key type to any value type. This is where Generics transform from convenient functions into powerful design patterns.
The challenge is real: without Generics, you'd write separate Stack classes for every data type you use. With Generics, you write ONE generic Stack class that adapts to any type. But there's a catch—sometimes you need to constrain what types are allowed. You can't sort items if they don't support comparison. Enter Protocols: a way to define structural contracts that Generics can enforce.
In this lesson, you'll learn to build type-safe, reusable generic classes that scale from simple containers to sophisticated data structures. You'll also discover Protocols—an elegant way to specify "what an object can do" without forcing inheritance hierarchies.
Section 1: Creating a Generic Stack Class
Let's start with the most fundamental generic class pattern: a Stack—a container that stores items in Last-In-First-Out order (like a stack of plates).
Building Your First Generic Class
Here's a Stack[T] that works with any type:
class Stack[T]:
"""A generic stack that works with any type.
Type parameter T ensures all items in the stack are the same type,
and that push/pop operations preserve that type.
"""
def __init__(self) -> None:
"""Initialize an empty stack."""
self._items: list[T] = []
def push(self, item: T) -> None:
"""Add an item to the top of the stack."""
self._items.append(item)
def pop(self) -> T | None:
"""Remove and return the top item, or None if stack is empty."""
return self._items.pop() if self._items else None
def peek(self) -> T | None:
"""View the top item without removing it."""
return self._items[-1] if self._items else None
def is_empty(self) -> bool:
"""Check if the stack is empty."""
return len(self._items) == 0
def size(self) -> int:
"""Return the number of items in the stack."""
return len(self._items)
# Type-safe usage with integers
int_stack: Stack[int] = Stack[int]()
int_stack.push(1)
int_stack.push(2)
int_stack.push(3)
top: int | None = int_stack.pop() # Type: int | None
print(top) # Output: 3
# Type-safe usage with strings
str_stack: Stack[str] = Stack[str]()
str_stack.push("hello")
str_stack.push("world")
greeting: str | None = str_stack.pop() # Type: str | None
print(greeting) # Output: world
# Custom types work too
class Book:
def __init__(self, title: str):
self.title = title
book_stack: Stack[Book] = Stack[Book]()
book_stack.push(Book("Python Guide"))
book_stack.push(Book("Web Dev"))
recent_book: Book | None = book_stack.pop() # Type: Book | None
if recent_book:
print(recent_book.title) # Output: Web Dev
What's happening here?
Stack[T]defines a class with a type parameter T- Each instance specifies what T is:
Stack[int],Stack[str],Stack[Book] - All methods respect that type choice—
pushonly accepts T,popreturns T | None - Your IDE knows the exact type at every step. It autocompletes correctly and catches type mismatches
This is fundamentally different from a non-generic Stack:
# Without generics—loses type information
class UntypedStack:
def __init__(self) -> None:
self._items: list[Any] = [] # Could be anything
def push(self, item: Any) -> None:
self._items.append(item)
def pop(self) -> Any: # Return type unknown to IDE
return self._items.pop() if self._items else None
# Using it—no type safety
stack: UntypedStack = UntypedStack()
stack.push(1)
stack.push("string") # ✅ IDE allows this (but shouldn't!)
mixed: Any = stack.pop() # ❌ IDE has no idea what type this is
# You could accidentally call wrong methods
if mixed:
print(mixed.upper()) # Type checking can't catch this—might be int!
🎓 Expert Insight
In AI-native development, generic classes are specifications that scale. When you write
Stack[T], you're not writing three separate classes—you're writing one specification that AI can instantiate for any type. This is the future: write the intent once, let AI handle the variations.
Section 2: Multiple Type Parameters
Real-world containers often need multiple type parameters. A Cache needs a key type AND a value type. A mapping needs source type and target type. Let's see how this works.
Creating Cache[K, V]
Here's a generic Cache that maps keys to values:
class Cache[K, V]:
"""A generic cache that maps keys of type K to values of type V.
Type parameters:
- K: Key type (typically str, int, or hashable type)
- V: Value type (can be any type)
"""
def __init__(self) -> None:
"""Initialize an empty cache."""
self._store: dict[K, V] = {}
def set(self, key: K, value: V) -> None:
"""Store a value associated with a key."""
self._store[key] = value
def get(self, key: K) -> V | None:
"""Retrieve a value by key, or None if not found."""
return self._store.get(key)
def has(self, key: K) -> bool:
"""Check if a key exists in the cache."""
return key in self._store
def clear(self) -> None:
"""Remove all entries from the cache."""
self._store.clear()
def size(self) -> int:
"""Return the number of cached items."""
return len(self._store)
# Cache mapping strings to integers (user IDs)
user_cache: Cache[str, int] = Cache[str, int]()
user_cache.set("alice", 1001)
user_cache.set("bob", 1002)
alice_id: int | None = user_cache.get("alice") # Type: int | None
print(alice_id) # Output: 1001
# Cache mapping integers to objects
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
user_object_cache: Cache[int, User] = Cache[int, User]()
user_object_cache.set(1001, User("Alice", "alice@example.com"))
user_object_cache.set(1002, User("Bob", "bob@example.com"))
user: User | None = user_object_cache.get(1001) # Type: User | None
if user:
print(f"{user.name}: {user.email}")
# IDE knows user has .name and .email attributes
# Cache mapping tuples to strings
location_cache: Cache[tuple[float, float], str] = Cache[tuple[float, float], str]()
location_cache.set((37.7749, -122.4194), "San Francisco")
location_cache.set((40.7128, -74.0060), "New York")
city: str | None = location_cache.get((37.7749, -122.4194)) # Type: str | None
print(city) # Output: San Francisco
Key points:
- K and V are independent type parameters
- Each instance fully specifies both:
Cache[str, int],Cache[int, User], etc. - Methods respect both types:
set(key: K, value: V),get(key: K) -> V | None - Your IDE knows exactly what types to expect at every step
Section 3: Bounded Type Variables
Sometimes you need to guarantee that your generic type can do certain things. For example, a function that finds the maximum item needs to compare items. Not all types support comparison. This is where bounded type variables come in.
The Problem: Comparing Unknown Types
What if you write a function to find the maximum item in a list?
# ❌ This doesn't work—you can't compare arbitrary items
def find_max[T](items: list[T]) -> T | None:
if not items:
return None
max_item = items[0]
for item in items[1:]:
if item > max_item: # ❌ Error: can't compare T with T
max_item = item
return max_item
The problem: Python doesn't know if T supports the > operator. Maybe T is a type that can't be compared. To solve this, you need a bound—a way to say "T must support comparison."
Creating a Comparable Protocol
First, define what "comparable" means using a Protocol:
from typing import Protocol
class Comparable(Protocol):
"""Protocol for types that support comparison operations."""
def __lt__(self, other: object) -> bool:
"""Less than operator."""
...
def __le__(self, other: object) -> bool:
"""Less than or equal operator."""
...
def __gt__(self, other: object) -> bool:
"""Greater than operator."""
...
def __ge__(self, other: object) -> bool:
"""Greater than or equal operator."""
...
What's this? A Protocol doesn't inherit from anything. It just says: "Any type that implements these methods is considered Comparable." It's a structural contract: "acts like a Comparable" rather than "is-a Comparable."
💬 AI Colearning Prompt
"What's the difference between a Protocol and an abstract base class (ABC)? When would you use each? Give examples where Protocols are better than inheritance."
Using Bounded Generics
Now you can constrain T:
def find_max[T: Comparable](items: list[T]) -> T | None:
"""Find the maximum item in a list of comparable items.
Type parameter T is bounded by Comparable, meaning any type passed
to this function must support comparison operators.
"""
if not items:
return None
max_item = items[0]
for item in items[1:]:
if item > max_item: # ✅ Now this is legal—T is guaranteed Comparable
max_item = item
return max_item
# Works with int (supports comparison)
numbers = [3, 1, 4, 1, 5, 9, 2, 6]
largest_num: int | None = find_max(numbers) # Type: int | None
print(largest_num) # Output: 9
# Works with str (supports comparison)
names = ["Charlie", "Alice", "Bob"]
last_name: str | None = find_max(names) # Type: str | None
print(last_name) # Output: Charlie
# Works with float
values = [3.14, 2.71, 1.41]
largest_value: float | None = find_max(values) # Type: float | None
print(largest_value) # Output: 3.14
# Custom types must implement the protocol
class Product:
def __init__(self, price: float):
self.price = price
def __lt__(self, other: object) -> bool:
if not isinstance(other, Product):
return NotImplemented
return self.price < other.price
def __le__(self, other: object) -> bool:
if not isinstance(other, Product):
return NotImplemented
return self.price <= other.price
def __gt__(self, other: object) -> bool:
if not isinstance(other, Product):
return NotImplemented
return self.price > other.price
def __ge__(self, other: object) -> bool:
if not isinstance(other, Product):
return NotImplemented
return self.price >= other.price
# Now find_max works with Product (implements Comparable)
products = [
Product(29.99),
Product(9.99),
Product(49.99)
]
most_expensive: Product | None = find_max(products) # Type: Product | None
if most_expensive:
print(f"Most expensive: ${most_expensive.price}") # Output: Most expensive: $49.99
What bounded generics do:
T: Comparablemeans "T can be any type that implements Comparable"- The function can now safely call
>,<,==on items of type T - Your IDE validates that the bound is satisfied before running code
- Custom types automatically work if they implement the methods the Protocol requires
🤝 Practice Exercise
Ask your AI: "Create a generic
find_min[T: Comparable](items: list[T]) -> T | Nonefunction. Show usage with int, str, and a custom Product class. Explain why the Comparable bound is necessary and what would break without it."
Expected Outcome: You'll understand why bounded type variables are necessary when your function performs operations (like comparison) that not all types support, seeing how constraints enable type-safe operations.
Section 4: Protocols for Structural Typing
Protocols are a powerful feature on their own. Let's understand why they're better than inheritance for Generics.
Protocols vs Inheritance
Traditional inheritance says: "B is-a A" (tight coupling):
from abc import ABC, abstractmethod
# Inheritance approach
class Drawable(ABC):
@abstractmethod
def draw(self) -> str:
"""Draw the shape."""
...
class Circle(Drawable):
def draw(self) -> str:
return "Drawing a circle"
class Square(Drawable):
def draw(self) -> str:
return "Drawing a square"
# Works, but requires explicit inheritance
def render_shape(shape: Drawable) -> None:
print(shape.draw())
render_shape(Circle()) # ✅ Works (inherits from Drawable)
Protocols say: "B acts-like A" (loose coupling):
from typing import Protocol
# Protocol approach
class Drawable(Protocol):
def draw(self) -> str:
"""Draw the shape."""
...
class Circle:
def draw(self) -> str:
return "Drawing a circle"
class Square:
def draw(self) -> str:
return "Drawing a square"
class Triangle:
def draw(self) -> str:
return "Drawing a triangle"
# Works with any type that has draw() method—no inheritance required!
def render_shape(shape: Drawable) -> None:
print(shape.draw())
render_shape(Circle()) # ✅ Works (has draw method)
render_shape(Square()) # ✅ Works (has draw method)
render_shape(Triangle()) # ✅ Works (has draw method)
# Even works with types defined elsewhere that you don't control
class LegacyShape:
def draw(self) -> str:
return "Drawing legacy shape"
render_shape(LegacyShape()) # ✅ Works! (has draw method, no inheritance needed)
Why Protocols are better for Generics:
- No inheritance required: Types automatically satisfy a Protocol if they implement the methods
- Less coupling: You're not locked into a class hierarchy
- Works with external types: Even if a type wasn't designed as Drawable, it works if it has the right methods
- Clearer intent: "acts-like" is more flexible than "is-a"
Creating a Custom Protocol
Let's define a Serializable Protocol:
class Serializable(Protocol):
"""Protocol for types that can be converted to JSON."""
def to_json(self) -> str:
"""Convert to JSON string."""
...
class User:
def __init__(self, name: str, email: str):
self.name = name
self.email = email
def to_json(self) -> str:
import json
return json.dumps({"name": self.name, "email": self.email})
class Config:
def __init__(self, setting: str, value: str):
self.setting = setting
self.value = value
def to_json(self) -> str:
import json
return json.dumps({"setting": self.setting, "value": self.value})
# Works with any Serializable type
def save_to_file[T: Serializable](obj: T, filename: str) -> None:
"""Save any serializable object to a JSON file."""
with open(filename, "w") as f:
f.write(obj.to_json())
# Both work—both implement Serializable without explicit inheritance
save_to_file(User("Alice", "alice@example.com"), "user.json")
save_to_file(Config("theme", "dark"), "config.json")
Specification Reference
Specification: Create generic class with bounded type parameters Prompt Used: "Design a Stack[T] class with push/pop/peek operations. Then show how type information flows through all methods." Generated Code: Stack[T] implementation above Validation Steps:
- Verify push accepts T, pop returns T | None
- Test with multiple types (int, str, custom class)
- Confirm IDE provides correct autocomplete for each type
- Ensure type errors are caught at design time, not runtime
Section 5: When NOT to Use Generics
Here's the paradox: just because you CAN make something generic doesn't mean you SHOULD. Over-engineering is real.
The Overengineering Trap
# ❌ Unnecessary generic—you'll only ever use this with strings
class StringProcessor[T]:
"""Overly generic StringProcessor that only processes strings."""
def uppercase(self, value: T) -> T:
return value.upper() # This only works if T is str!
def lowercase(self, value: T) -> T:
return value.lower() # This only works if T is str!
# Much simpler
class StringProcessor:
"""Just process strings—no unnecessary generics."""
def uppercase(self, value: str) -> str:
return value.upper()
def lowercase(self, value: str) -> str:
return value.lower()
When to Genericize
Ask these questions:
- Will this code actually work with multiple types? If no, don't make it generic.
- Is the implementation identical for different types? If the logic changes per type, maybe inheritance or separate classes are better.
- Will users of this code appreciate type safety? If it's internal utility code, simple might beat generic.
- Is the added complexity worth the flexibility? Usually yes for data structures (Stack, Queue, Cache), usually no for business logic.
Good candidates for Generics:
- Container classes (Stack, Queue, Cache, LinkedList)
- Repository patterns (Repository[T] for any entity type)
- Generic functions (filter, map, reduce patterns)
Bad candidates for Generics:
- Business logic that only uses one type
- Simple utilities (string upper/lower, number formatting)
- Classes with many type-specific methods
Common Mistakes
Mistake 1: Confusing Generic[T] (Defining) with T (Using)
# ❌ Wrong—mixing definition and usage
from typing import Generic, TypeVar
T = TypeVar('T')
class Stack(Generic[T]): # Old-style, don't use this
def push(self, item: T) -> None:
...
# ✅ Correct—PEP 695 modern syntax
class Stack[T]: # Clean, modern, preferred
def push(self, item: T) -> None:
...
Mistake 2: Not Constraining When You Need Specific Operations
# ❌ This will error—can't call .upper() without knowing T has that method
def process[T](items: list[T]) -> list[str]:
return [item.upper() for item in items] # Error: T might not have .upper()
# ✅ Bound T to ensure it supports the operation
class HasUpper(Protocol):
def upper(self) -> str: ...
def process[T: HasUpper](items: list[T]) -> list[str]:
return [item.upper() for item in items] # Now safe—T is guaranteed HasUpper
Mistake 3: Using Generics When a Simple Type Would Do
# ❌ Overengineering
class IntegerCalculator[T: int]:
def add(self, a: T, b: T) -> T:
return a + b
# ✅ Just use int
class IntegerCalculator:
def add(self, a: int, b: int) -> int:
return a + b
Mistake 4: Overthinking Variance
# This is advanced—don't worry about it yet
# Variance (covariance/contravariance) is about whether
# Stack[Dog] is compatible with Stack[Animal]
# Save this for advanced study—it rarely matters in practice
Try With AI
Apply generic classes and Protocols through AI collaboration that builds flexible, type-safe APIs.
🔍 Explore Protocol Types:
"Compare isinstance() checking versus Protocol-based duck typing. Show how Protocol defines interface contracts without inheritance and why this matters for generic programming."
🎯 Practice Generic Containers:
"Build generic Repository[T] class with CRUD operations maintaining type safety. Create Cache[K, V] with generic key-value pairs. Show how type parameters flow through method signatures."
🧪 Test Protocol Constraints:
"Define Comparable Protocol with lt method. Create generic sort function constrained to Comparable types. Test with classes implementing Protocol showing structural subtyping."
🚀 Apply Type-Safe APIs:
"Design generic API client using Protocol for serialization and Generic[T] for responses. Build DataLoader[T: Serializable] showing constrained generics with Protocol bounds. Explain composition patterns."