Skip to main content
Updated Feb 26, 2026

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—push only accepts T, pop returns 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: Comparable means "T can be any type that implements Comparable"
  • The function can now safely call >, &lt;, == 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 | None function. 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:

  1. Verify push accepts T, pop returns T | None
  2. Test with multiple types (int, str, custom class)
  3. Confirm IDE provides correct autocomplete for each type
  4. 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:

  1. Will this code actually work with multiple types? If no, don't make it generic.
  2. Is the implementation identical for different types? If the logic changes per type, maybe inheritance or separate classes are better.
  3. Will users of this code appreciate type safety? If it's internal utility code, simple might beat generic.
  4. 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."