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:
Loading Python environment...
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:
Loading Python environment...
🎓 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:
Loading Python environment...
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?
Loading Python environment...
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:
Loading Python environment...
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:
Loading Python environment...
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):
Loading Python environment...
Protocols say: "B acts-like A" (loose coupling):
Loading Python environment...
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:
Loading Python environment...
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
Loading Python environment...
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)
Loading Python environment...
Mistake 2: Not Constraining When You Need Specific Operations
Loading Python environment...
Mistake 3: Using Generics When a Simple Type Would Do
Loading Python environment...
Mistake 4: Overthinking Variance
Loading Python environment...
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."