Skip to main content

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—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:

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: Comparable means "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 | 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):

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:

  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

Loading Python environment...

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)

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."