Build a Type-Safe Data Store in Python
Use Python generics to build a type-safe repository and pipeline library.
Introduction
30 Second Summary
You write a helper function that works perfectly for strings. A teammate passes in an integer, and the app crashes at runtime. Type-safe generics solve this by letting you write reusable code that catches those mismatches before the code ever runs.
In this project, you will build a type-safe data store library using Python generics. You will progress from a simple container to a full repository and transformation pipeline, then verify everything with a strict type checker.
What You'll Build
A Python library of generic classes that store, retrieve, and transform data while preserving full type safety, verified by mypy's strict mode.
By the end of this project, you'll have:
- A generic Box[T] container class built with both modern PEP 695 syntax and legacy TypeVar syntax.
- A bounded SortedContainer and a multi-parameter Repository[K, V] with full CRUD operations.
- A Pipeline[T, R] class that chains typed transformations with guaranteed type safety at each step.
- Secret Mission: Build a generic EventBus[E] with typed publish/subscribe callbacks.
Are there any prerequisites?
You should be comfortable with basic Python classes, including __init__, methods, and inheritance. Familiarity with Python's type hints syntax (like def greet(name: str) -> str) is also helpful.
Before We Start
Before We Start...
Set Up Your Python Environment
Before you can build type-safe generic classes, you need a Python environment that supports the modern type parameter syntax. Python 3.12 introduced a cleaner way to write generics, so that's the minimum version you need.
In this step, you'll verify your Python installation, set up mypy (a static type checker that catches type errors without running your code), and create a project directory.
In this step, get ready to:
- Install and verify Python 3.12+.
- Install mypy for static type checking.
- Create your project directory and open it in VS Code.
Verify your Python version
Python 3.12 introduced PEP 695, a new syntax for writing generic classes that requires no extra imports. You need version 3.12 or higher for this project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Check your Python version by running this command:
python3 --version
You should see output like Python 3.12.x or higher (for example, Python 3.14.5).
✔️ I see version 3.12 or higher
Your Python version is ready. Move on to the next substep.
ⓧ I see an older version
Your system has an older Python that does not support the PEP 695 type parameter syntax. You need to install Python 3.12 or higher alongside it.
- Download the latest Python installer from python.org/downloads.
- Run the installer and follow the prompts.
- On macOS, the system python3 may still point to the old version after installing. Run python3.12 --version or python3.14 --version to confirm the new install. Use that versioned command for the rest of this project.
- On Windows, the installer adds the new version to your PATH automatically. Close and reopen your terminal, then run python3 --version again.
Use a version manager
Tools like pyenv (macOS/Linux) or the Python Launcher for Windows let you switch between Python versions easily. They are optional for this project, but helpful if you work with multiple Python versions regularly.
ⓧ Command not found
Python is not installed on your machine yet. Install it from the official website.
- Go to python.org/downloads and download the latest Python installer for your operating system.
- Run the installer.
- On Windows, check the box labeled Add python.exe to PATH before clicking Install Now.
- Close and reopen your terminal after the install finishes.
- Run python3 --version again to confirm it prints 3.12 or higher.
Install mypy
mypy is a static type checker for Python. It reads your code and checks that your type annotations are consistent without actually running anything. This is how you'll prove your generics are correctly implemented.
- Install mypy by running this command:
python3 -m pip install mypy
What does this command do?
python3 -m pip install mypy tells Python to use its built-in package manager (pip) to download and install mypy from the Python Package Index.
- Verify the installation succeeded by running:
mypy --version
You should see a version number printed (for example, mypy 2.1.0).
Seeing 'command not found' after installing?
If mypy --version is not recognized, your terminal might not see the location where pip installed it. Try running mypy through Python directly:
python3 -m mypy --version
If that works, use python3 -m mypy in place of mypy throughout this project.
help me fix mypy not being found after pip install
Create your project directory
You need a dedicated folder to hold all the Python files you'll build in this project. Creating it on your Desktop makes it easy to find.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir python-generics
cd python-generics
- Confirm the folder exists by running:
ls
You should see an empty output since the folder is new. That's expected.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the python-generics folder, then click Open.
Why open a folder, not a file?
Opening a folder sets that folder as your workspace root in VS Code. This means the integrated terminal opens there by default, and later when you run mypy --strict ., the . refers to this folder.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
Your terminal should show python-generics in the prompt, confirming you're in the right directory.
Terminal not showing the right folder?
If your terminal prompt does not show python-generics, you may have opened a file instead of the folder. Close VS Code, then re-open it using File then Open Folder and select the python-generics folder on your Desktop.
help me open my project folder correctly in VS Code
Your environment is ready. Next up, you'll build your first generic class and see how Python's type parameter syntax works in practice.
Build Your First Generic Class
Your environment is ready. Now it's time to write your first generic class.
Generics solve a common problem: you want to write a class that works with any type, but you also want your type checker to catch mistakes. Without generics, you'd either copy-paste the same class for every type or lose type safety by using Any. In this step, you'll build a Box[T] container that holds a single value of any type while preserving full type information.
In this step, get ready to:
- Create a generic Box[T] class using the modern Python 3.12+ type parameter syntax.
- Implement the same class using legacy TypeVar syntax for backwards compatibility.
- Run the file and see type-safe generic instances in action.
Create the modern Box[T] class
PEP 695 introduced a clean syntax for declaring type parameters directly on the class definition. Instead of importing TypeVar and Generic, you write class Box[T]: and Python handles the rest.
- Create a new file called box.py in your project folder by clicking File > New File in VS Code.
- Name the file box.py and paste the following code:
"""Single-parameter generic class demonstrating both modern and legacy syntax."""
from typing import TypeVar, Generic
# === Modern Python 3.12+ syntax (PEP 695) ===
class Box[T]:
"""A container that holds a single value of type T."""
def __init__(self, value: T | None = None) -> None:
self._value: T | None = value
def get(self) -> T:
"""Return the stored value. Raises ValueError if empty."""
if self._value is None:
raise ValueError("Box is empty")
return self._value
def set(self, value: T) -> None:
"""Store a value in the box."""
self._value = value
def is_empty(self) -> bool:
"""Check if the box is empty."""
return self._value is None
def __repr__(self) -> str:
if self.is_empty():
return "Box(empty)"
return f"Box({self._value!r})"
What does this code do?
class Box[T]: declares a generic class with one type parameter T. When you later write Box[str], Python's type checker knows every method on that instance uses str wherever T appears.
The T | None pattern lets the box start empty. The get() method raises a ValueError if the box is empty, giving you a clear signal instead of silently returning None.
The imports at the top (TypeVar and Generic) are not used by Box[T] itself. They are for the legacy class you'll add next.
- Save box.py.
- Confirm the file exists by running:
ls
- You should see box.py in the output.
Don't see box.py?
Make sure you saved the file with Cmd+S (macOS) or Ctrl+S (Windows). Also confirm your terminal is in the same folder as the file. Run pwd to check your current directory.
Help me figure out why box.py isn't showing up in my project directory.
Add the legacy LegacyBox class
Most existing Python codebases still use the legacy generics syntax because it works on Python 3.11 and earlier. Understanding both approaches lets you read any generic code you encounter.
- In box.py, scroll to the end of the Box class (after the __repr__ method).
- Add the following code below the Box class:
# === Legacy syntax (Python 3.11 and earlier) ===
LT = TypeVar("LT")
class LegacyBox(Generic[LT]):
"""Same Box implemented with the legacy Generic[T] syntax."""
def __init__(self, value: LT | None = None) -> None:
self._value: LT | None = value
def get(self) -> LT:
if self._value is None:
raise ValueError("Box is empty")
return self._value
def set(self, value: LT) -> None:
self._value = value
def is_empty(self) -> bool:
return self._value is None
def __repr__(self) -> str:
if self.is_empty():
return "LegacyBox(empty)"
return f"LegacyBox({self._value!r})"
What's the difference between the two syntaxes?
In the legacy approach, you first create a type variable with LT = TypeVar("LT"), then pass it to Generic[LT] as a base class. The modern syntax (class Box[T]:) handles both in a single line.
Both versions produce the same runtime behavior. The difference is purely ergonomic. The modern syntax is less boilerplate and keeps the type parameter visible right on the class name.
- Save box.py.
You won't see any output yet. The next section adds a demo block that exercises both classes.
Seeing a red underline on Generic[LT]?
Make sure the import line at the top of the file reads from typing import TypeVar, Generic. If you accidentally deleted or changed that import, VS Code will flag Generic as undefined.
Help me fix a red underline on my LegacyBox class.
Add the demo block and run the file
The if __name__ == "__main__" block runs only when you execute the file directly. It lets you test your classes without affecting other files that import them.
- At the bottom of box.py, add this demo block after the LegacyBox class:
if __name__ == "__main__":
# Modern syntax usage
string_box: Box[str] = Box("hello")
print(string_box.get()) # "hello"
int_box: Box[int] = Box(42)
print(int_box.get()) # 42
empty_box: Box[float] = Box()
print(empty_box.is_empty()) # True
# Legacy syntax usage
legacy_box: LegacyBox[str] = LegacyBox("world")
print(legacy_box.get()) # "world"
What does this code do?
Each variable declaration includes an explicit type annotation like Box[str] or Box[int]. This tells mypy exactly what type each box holds, so it can flag any misuse at development time.
At runtime, Box[str] and Box[int] are the same class. Python erases the type parameter. The safety comes entirely from static analysis, not runtime enforcement.
- Save box.py.
- Run the file by executing this command in your terminal:
python3 box.py
- You should see four lines of output:
hello
42
True
world
Seeing a SyntaxError?
The class Box[T]: syntax requires Python 3.12 or higher. Run python3 --version to confirm your version. If you see a version lower than 3.12, go back to Step 1 and upgrade.
Also check that your indentation uses consistent spaces (not a mix of tabs and spaces). Python is strict about indentation.
Help me fix a SyntaxError when running box.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here's the complete box.py file for comparison:
"""Single-parameter generic class demonstrating both modern and legacy syntax."""
from typing import TypeVar, Generic
# === Modern Python 3.12+ syntax (PEP 695) ===
class Box[T]:
"""A container that holds a single value of type T."""
def __init__(self, value: T | None = None) -> None:
self._value: T | None = value
def get(self) -> T:
"""Return the stored value. Raises ValueError if empty."""
if self._value is None:
raise ValueError("Box is empty")
return self._value
def set(self, value: T) -> None:
"""Store a value in the box."""
self._value = value
def is_empty(self) -> bool:
"""Check if the box is empty."""
return self._value is None
def __repr__(self) -> str:
if self.is_empty():
return "Box(empty)"
return f"Box({self._value!r})"
# === Legacy syntax (Python 3.11 and earlier) ===
LT = TypeVar("LT")
class LegacyBox(Generic[LT]):
"""Same Box implemented with the legacy Generic[T] syntax."""
def __init__(self, value: LT | None = None) -> None:
self._value: LT | None = value
def get(self) -> LT:
if self._value is None:
raise ValueError("Box is empty")
return self._value
def set(self, value: LT) -> None:
self._value = value
def is_empty(self) -> bool:
return self._value is None
def __repr__(self) -> str:
if self.is_empty():
return "LegacyBox(empty)"
return f"LegacyBox({self._value!r})"
if __name__ == "__main__":
# Modern syntax usage
string_box: Box[str] = Box("hello")
print(string_box.get()) # "hello"
int_box: Box[int] = Box(42)
print(int_box.get()) # 42
empty_box: Box[float] = Box()
print(empty_box.is_empty()) # True
# Legacy syntax usage
legacy_box: LegacyBox[str] = LegacyBox("world")
print(legacy_box.get()) # "world"
Why learn both syntaxes?
The modern class Box[T]: syntax is cleaner, but most open-source libraries and production codebases still use TypeVar + Generic because they need to support Python 3.11 and earlier.
Knowing both means you can read any generic code you encounter and choose the right approach for your project's minimum Python version.
Your first generic class is working. You've seen how a single class definition can safely handle strings, integers, and floats without any code duplication. Next up, you'll add constraints to your type parameters so they only accept types that meet specific requirements.
Add Constraints and Bounds to TypeVars
Your Box[T] from the last step accepts any type at all. Strings, integers, custom objects. But sometimes accepting everything is a problem.
A sorted container needs to compare items with < and ==. If someone passes in a type that has no concept of ordering, the code breaks at runtime. Bounded type parameters let you tell Python's type system exactly which types are allowed, and mypy catches mistakes before the code ever runs.
In this step, get ready to:
- Define a Comparable Protocol using structural typing.
- Build a SortedContainer class that only accepts comparable types.
- Verify the bound with mypy's strict mode.
Create the Comparable Protocol
Python uses Protocol for structural typing. Instead of requiring a type to inherit from a specific base class, a Protocol says "any type that has these methods qualifies." This is perfect for comparison operators because built-in types like int and str already implement __lt__ without inheriting from anything special.
- Create a new file called comparable.py in your project directory.
- Add the following code to define the Comparable Protocol:
"""Bounded generics with Protocol for structural typing."""
from typing import Protocol, runtime_checkable
@runtime_checkable
class Comparable(Protocol):
"""Protocol for types that support comparison."""
def __lt__(self, other: "Comparable") -> bool: ...
def __eq__(self, other: object) -> bool: ...
What does this code do?
The @runtime_checkable decorator lets you use isinstance() checks against this Protocol at runtime, not just at type-check time.
The Protocol itself defines two method signatures: __lt__ (less than) and __eq__ (equality). Any type that implements both of these methods satisfies the Comparable protocol. The ... after each signature means "no implementation here, just a contract."
Why Protocol instead of an abstract base class?
With an abstract base class, types must explicitly inherit from it. That would mean int and str could never satisfy the contract because you cannot change their inheritance chain.
Protocol uses structural typing instead. If a type has the right methods, it qualifies automatically. Python's built-in types already implement comparison operators, so they satisfy Comparable without any changes.
- Save comparable.py.
Build the SortedContainer class
Now you will use the Comparable Protocol as a bound on a type parameter. The syntax T: Comparable means "T can be any type, as long as it satisfies the Comparable protocol."
- Add the following SortedContainer class below the Comparable Protocol in comparable.py:
class SortedContainer[T: Comparable]:
"""A container that keeps items sorted, only accepting Comparable types."""
def __init__(self) -> None:
self._items: list[T] = []
def insert(self, item: T) -> None:
"""Insert an item in sorted position."""
pos = 0
for existing in self._items:
if item < existing:
break
pos += 1
self._items.insert(pos, item)
def get_min(self) -> T:
"""Return the smallest item. Raises ValueError if empty."""
if not self._items:
raise ValueError("Container is empty")
return self._items[0]
def get_max(self) -> T:
"""Return the largest item. Raises ValueError if empty."""
if not self._items:
raise ValueError("Container is empty")
return self._items[-1]
def get_sorted(self) -> list[T]:
"""Return all items in sorted order."""
return list(self._items)
def __len__(self) -> int:
return len(self._items)
def __repr__(self) -> str:
return f"SortedContainer({self._items!r})"
What does this code do?
The class declaration class SortedContainer[T: Comparable] uses PEP 695 syntax with a bound. Only types that satisfy Comparable can be used as T.
The insert() method walks through existing items and finds the correct sorted position using <. This is only safe because the bound guarantees T has a __lt__ method.
The get_min() and get_max() methods return the first and last items respectively, since the list is always maintained in sorted order.
- Now add the demonstration block at the bottom of comparable.py:
if __name__ == "__main__":
# int supports Comparable
numbers: SortedContainer[int] = SortedContainer()
numbers.insert(5)
numbers.insert(2)
numbers.insert(8)
numbers.insert(1)
print(numbers.get_sorted()) # [1, 2, 5, 8]
print(numbers.get_min()) # 1
print(numbers.get_max()) # 8
# str also supports Comparable
words: SortedContainer[str] = SortedContainer()
words.insert("banana")
words.insert("apple")
words.insert("cherry")
print(words.get_sorted()) # ['apple', 'banana', 'cherry']
What does this code do?
This demo creates two SortedContainer instances with different concrete types. SortedContainer[int] works because int implements __lt__ and __eq__. Same for SortedContainer[str].
Items are inserted in arbitrary order, but get_sorted() always returns them in ascending order because insert() places each item at the correct position.
- Save comparable.py.
- Run the script to verify the sorting logic by running this command:
python3 comparable.py
You should see this output:
[1, 2, 5, 8]
1
8
['apple', 'banana', 'cherry']
Not seeing the sorted output?
- Make sure you saved comparable.py before running it.
- Check that the if __name__ == "__main__": block is at the top level (no indentation), not inside the class.
- Verify your terminal is in the project directory where comparable.py lives.
Still stuck? Help me debug why python3 comparable.py is not printing sorted output
Run mypy to verify the bound
The real power of bounded type parameters shows up at type-check time. Running mypy on your file proves that the generics are correctly wired.
- Run mypy in strict mode on your file:
mypy --strict comparable.py
You should see Success: no issues found in 1 source file.
What did mypy just verify?
mypy confirmed that every use of T inside SortedContainer is consistent with the Comparable bound. The insert() method calls item < existing, which is only valid because T: Comparable guarantees a __lt__ method exists.
If someone tried to create a SortedContainer[object], mypy would reject it because object does not satisfy the Comparable protocol. The error is caught at development time, not as a confusing runtime crash.
Seeing mypy errors?
- Check that your Comparable Protocol uses def __eq__(self, other: object) -> bool: (not other: "Comparable"). The __eq__ method must accept object to match Python's built-in signature.
- Make sure you are running Python 3.12+ . The class SortedContainer[T: Comparable] syntax is not available in earlier versions.
- Confirm you have the @runtime_checkable decorator on the line directly above class Comparable(Protocol):.
Need more help? Help me fix mypy errors in my comparable.py file with bounded generics
✔️ Awesome, I've got everything!
Great. Double check you have saved comparable.py and that mypy reports zero issues.
ⓧ I'd like to double check the full code
Here is the complete comparable.py file:
"""Bounded generics with Protocol for structural typing."""
from typing import Protocol, runtime_checkable
@runtime_checkable
class Comparable(Protocol):
"""Protocol for types that support comparison."""
def __lt__(self, other: "Comparable") -> bool: ...
def __eq__(self, other: object) -> bool: ...
class SortedContainer[T: Comparable]:
"""A container that keeps items sorted, only accepting Comparable types."""
def __init__(self) -> None:
self._items: list[T] = []
def insert(self, item: T) -> None:
"""Insert an item in sorted position."""
pos = 0
for existing in self._items:
if item < existing:
break
pos += 1
self._items.insert(pos, item)
def get_min(self) -> T:
"""Return the smallest item. Raises ValueError if empty."""
if not self._items:
raise ValueError("Container is empty")
return self._items[0]
def get_max(self) -> T:
"""Return the largest item. Raises ValueError if empty."""
if not self._items:
raise ValueError("Container is empty")
return self._items[-1]
def get_sorted(self) -> list[T]:
"""Return all items in sorted order."""
return list(self._items)
def __len__(self) -> int:
return len(self._items)
def __repr__(self) -> str:
return f"SortedContainer({self._items!r})"
if __name__ == "__main__":
# int supports Comparable
numbers: SortedContainer[int] = SortedContainer()
numbers.insert(5)
numbers.insert(2)
numbers.insert(8)
numbers.insert(1)
print(numbers.get_sorted()) # [1, 2, 5, 8]
print(numbers.get_min()) # 1
print(numbers.get_max()) # 8
# str also supports Comparable
words: SortedContainer[str] = SortedContainer()
words.insert("banana")
words.insert("apple")
words.insert("cherry")
print(words.get_sorted()) # ['apple', 'banana', 'cherry']
You now have a generic class that restricts which types are allowed at compile time. Next up, you will build a multi-parameter generic Repository that uses two type parameters at once.
Build a Multi-Parameter Generic Repository
Your SortedContainer proved that bounded type parameters can restrict which types a generic class accepts. Now you are ready to tackle the pattern that appears in almost every production Python codebase: a data repository.
A repository needs two separate type parameters. One for the key you look things up with, and one for the value you store. In this step, you will build a Repository[K, V] class that handles any key-value combination, then layer on a bounded EntityRepository[E: Entity] that adds domain-specific methods.
In this step, get ready to:
- Create entity data classes that your repository will store.
- Build a generic Repository[K, V] class with full CRUD operations.
- Add a bounded EntityRepository[E: Entity] for type-safe entity storage.
Create the entity data classes
Before building the repository itself, you need the data types it will store. You will define a base Entity class and two concrete subclasses. The repository will use bounded generics to accept only these entity types.
- Create a new file called repository.py in your project folder.
- Add the imports and entity data classes by pasting the following code:
"""Multi-parameter generics with bounded types for a data repository."""
from dataclasses import dataclass, field
from datetime import datetime
# Base entity class that repository values must extend
@dataclass
class Entity:
"""Base class for all storable entities."""
id: str
created_at: datetime = field(default_factory=datetime.now)
@dataclass
class User(Entity):
"""A user entity."""
name: str = ""
email: str = ""
@dataclass
class Product(Entity):
"""A product entity."""
title: str = ""
price: float = 0.0
What does this code do?
Entity is the base class that all storable items must extend. It has an id field for lookups and a created_at timestamp.
User and Product inherit from Entity and add their own fields. Later, the bounded EntityRepository will accept only types that extend Entity.
- Save repository.py.
- You won't see output yet. The next chunk builds the Repository[K, V] class that uses these entities.
Build the Repository[K, V] class
A single type parameter is fine for a container that holds one thing. But a key-value store has two independent types that can vary. The Repository[K, V] class declares both in its type parameter list, giving you full type safety on both sides of the mapping.
- In repository.py, add the Repository[K, V] class below the Product class:
class Repository[K, V]:
"""A generic key-value repository with two type parameters."""
def __init__(self) -> None:
self._store: dict[K, V] = {}
def save(self, key: K, value: V) -> None:
"""Save a value with the given key."""
self._store[key] = value
def find(self, key: K) -> V | None:
"""Find a value by key. Returns None if not found."""
return self._store.get(key)
def delete(self, key: K) -> bool:
"""Delete a value by key. Returns True if it existed."""
if key in self._store:
del self._store[key]
return True
return False
def find_all(self) -> list[V]:
"""Return all stored values."""
return list(self._store.values())
def count(self) -> int:
"""Return the number of stored items."""
return len(self._store)
def __repr__(self) -> str:
return f"Repository({self.count()} items)"
What does this code do?
- class Repository[K, V]: declares two type parameters. K is the key type and V is the value type. They are completely independent of each other.
- self._store: dict[K, V] uses both type parameters in the internal dictionary. When you create a Repository[str, int], mypy knows the dict maps strings to integers.
- Every method signature uses K and V to enforce that the key and value types stay consistent across all operations.
Why two type parameters instead of one?
A single parameter like Box[T] works when everything in the container is the same type. But a repository maps keys to values, and those are often different types (string keys to User objects, integer IDs to Product objects).
Multiple type parameters let you express these independent relationships. This is the same pattern behind Python's built-in dict[K, V] type annotation.
- Save repository.py.
- You won't see output yet. The demo block at the end of this file exercises both repository classes.
Add a bounded EntityRepository[E: Entity]
The generic Repository[K, V] accepts any key and value types. Sometimes you want to be more specific. An entity repository should only store objects that have an id field and a created_at timestamp. Bounding the type parameter to Entity enforces this at the type level.
- In repository.py, add the EntityRepository class below the Repository class:
class EntityRepository[E: Entity]:
"""A repository specifically for Entity subclasses, keyed by their id."""
def __init__(self) -> None:
self._store: dict[str, E] = {}
def save(self, entity: E) -> None:
"""Save an entity, using its id as the key."""
self._store[entity.id] = entity
def find_by_id(self, entity_id: str) -> E | None:
"""Find an entity by its id."""
return self._store.get(entity_id)
def find_all(self) -> list[E]:
"""Return all stored entities."""
return list(self._store.values())
def delete(self, entity_id: str) -> bool:
"""Delete an entity by id."""
if entity_id in self._store:
del self._store[entity_id]
return True
return False
def find_recent(self, limit: int = 10) -> list[E]:
"""Return the most recently created entities."""
sorted_entities = sorted(
self._store.values(),
key=lambda e: e.created_at,
reverse=True,
)
return sorted_entities[:limit]
def __repr__(self) -> str:
return f"EntityRepository({len(self._store)} entities)"
What does this code do?
- E: Entity is a bounded type parameter. It means E can be any type that extends Entity. Passing a plain str or int would be a type error.
- Because E is bounded to Entity, the class can safely access entity.id and entity.created_at without mypy complaining.
- find_recent() sorts by created_at. This method is only possible because the bound guarantees every E has that field.
- Save repository.py.
Now add a demo block at the bottom of the file so you can test both repository classes.
- Add the following code at the bottom of repository.py:
if __name__ == "__main__":
# Generic Repository with explicit key/value types
config: Repository[str, str] = Repository()
config.save("host", "localhost")
config.save("port", "8080")
print(config.find("host")) # "localhost"
print(config.find_all()) # ["localhost", "8080"]
# Bounded EntityRepository
users: EntityRepository[User] = EntityRepository()
users.save(User(id="u1", name="Alice", email="alice@example.com"))
users.save(User(id="u2", name="Bob", email="bob@example.com"))
found = users.find_by_id("u1")
if found:
print(found.name) # "Alice"
products: EntityRepository[Product] = EntityRepository()
products.save(Product(id="p1", title="Laptop", price=999.99))
print(products.find_all())
What does this demo block do?
- Creates a Repository[str, str] for string key-value pairs (like a config store).
- Creates an EntityRepository[User] and an EntityRepository[Product] to prove that the bound accepts any Entity subclass.
- After find_by_id("u1"), mypy knows the result is a User (not just an Entity), so accessing found.name is safe.
- Save repository.py.
- Run the file to test both repository classes by running this command:
python3 repository.py
You should see output including localhost, a list of values, Alice, and the product list printed to the terminal.
Seeing a NameError or ImportError?
- Make sure from dataclasses import dataclass, field and from datetime import datetime are at the top of your file.
- Check that the Entity class is defined above Repository and EntityRepository. Python reads top-to-bottom, so classes must be defined before they are referenced.
- Confirm you are running Python 3.12 or higher. The class Repository[K, V]: syntax does not work on older versions.
Help me debug my repository.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved repository.py.
ⓧ I'd like to double check the full code
"""Multi-parameter generics with bounded types for a data repository."""
from dataclasses import dataclass, field
from datetime import datetime
# Base entity class that repository values must extend
@dataclass
class Entity:
"""Base class for all storable entities."""
id: str
created_at: datetime = field(default_factory=datetime.now)
@dataclass
class User(Entity):
"""A user entity."""
name: str = ""
email: str = ""
@dataclass
class Product(Entity):
"""A product entity."""
title: str = ""
price: float = 0.0
class Repository[K, V]:
"""A generic key-value repository with two type parameters."""
def __init__(self) -> None:
self._store: dict[K, V] = {}
def save(self, key: K, value: V) -> None:
"""Save a value with the given key."""
self._store[key] = value
def find(self, key: K) -> V | None:
"""Find a value by key. Returns None if not found."""
return self._store.get(key)
def delete(self, key: K) -> bool:
"""Delete a value by key. Returns True if it existed."""
if key in self._store:
del self._store[key]
return True
return False
def find_all(self) -> list[V]:
"""Return all stored values."""
return list(self._store.values())
def count(self) -> int:
"""Return the number of stored items."""
return len(self._store)
def __repr__(self) -> str:
return f"Repository({self.count()} items)"
class EntityRepository[E: Entity]:
"""A repository specifically for Entity subclasses, keyed by their id."""
def __init__(self) -> None:
self._store: dict[str, E] = {}
def save(self, entity: E) -> None:
"""Save an entity, using its id as the key."""
self._store[entity.id] = entity
def find_by_id(self, entity_id: str) -> E | None:
"""Find an entity by its id."""
return self._store.get(entity_id)
def find_all(self) -> list[E]:
"""Return all stored entities."""
return list(self._store.values())
def delete(self, entity_id: str) -> bool:
"""Delete an entity by id."""
if entity_id in self._store:
del self._store[entity_id]
return True
return False
def find_recent(self, limit: int = 10) -> list[E]:
"""Return the most recently created entities."""
sorted_entities = sorted(
self._store.values(),
key=lambda e: e.created_at,
reverse=True,
)
return sorted_entities[:limit]
def __repr__(self) -> str:
return f"EntityRepository({len(self._store)} entities)"
if __name__ == "__main__":
# Generic Repository with explicit key/value types
config: Repository[str, str] = Repository()
config.save("host", "localhost")
config.save("port", "8080")
print(config.find("host")) # "localhost"
print(config.find_all()) # ["localhost", "8080"]
# Bounded EntityRepository
users: EntityRepository[User] = EntityRepository()
users.save(User(id="u1", name="Alice", email="alice@example.com"))
users.save(User(id="u2", name="Bob", email="bob@example.com"))
found = users.find_by_id("u1")
if found:
print(found.name) # "Alice"
products: EntityRepository[Product] = EntityRepository()
products.save(Product(id="p1", title="Laptop", price=999.99))
print(products.find_all())
You now have a multi-parameter generic repository with both an open version and a bounded version. Next up, you will chain typed transformations together with a Pipeline[T, R] class that ensures each step's output type matches the next step's input type.
Chain Transformations with a Pipeline
Your Repository stores and retrieves data. But real-world applications also need to transform data as it flows through a system.
In this step, you'll build a Pipeline[T, R] class that chains typed transformations from an input type T to an output type R. The generics guarantee that each step's output type matches the next step's input type, so mismatches are caught before runtime.
In this step, get ready to:
- Build a Pipeline class that chains transformations using multiple type parameters.
- Create a StreamPipeline that processes lazy iterators with full type safety.
- Test that each pipeline step's output type feeds correctly into the next step's input type.
Create the Pipeline class
A pipeline represents a typed transformation. Pipeline[T, R] takes an input of type T and produces an output of type R. The key feature is the then() method, which introduces a third type parameter U at the method level to chain a new transformation onto the end.
- Create a new file called pipeline.py in your project folder.
- Add the following imports and Pipeline class:
"""Generic pipeline demonstrating chained transformations with multiple type parameters."""
from collections.abc import Callable, Iterator
class Pipeline[T, R]:
"""A typed transformation pipeline from input type T to output type R."""
def __init__(self, transform: Callable[[T], R]) -> None:
self._transform = transform
def apply(self, value: T) -> R:
"""Apply the transformation to a single value."""
return self._transform(value)
def then[U](self, next_transform: Callable[[R], U]) -> "Pipeline[T, U]":
"""Chain another transformation, producing a new Pipeline[T, U]."""
def combined(value: T) -> U:
intermediate = self._transform(value)
return next_transform(intermediate)
return Pipeline(combined)
def apply_many(self, values: list[T]) -> list[R]:
"""Apply the transformation to a list of values."""
return [self._transform(v) for v in values]
What does this code do?
Pipeline[T, R] wraps a callable that transforms a value from type T to type R. The class has two type parameters declared at the class level.
The then[U] method is the important piece. It declares its own type parameter U at the method level, representing the new output type. It returns a Pipeline[T, U] that preserves the original input type T but changes the output to U. This is how the type checker verifies that chained steps connect correctly.
- Save pipeline.py.
Seeing a syntax error on the type parameters?
Make sure you have the square brackets [U] directly after then in the method definition. Method-level type parameters use the same bracket syntax as class-level ones.
Also confirm you are importing from collections.abc, not from typing. The Callable and Iterator imports come from collections.abc in modern Python.
Help me debug a type parameter syntax error in my Pipeline class
Build stream transformation helpers
The Pipeline class processes one value at a time. For large datasets, you want lazy evaluation where items are processed only as they are consumed. StreamPipeline solves this by working with iterators instead of individual values.
- In pipeline.py, add the StreamPipeline class below the Pipeline class:
class StreamPipeline[T, R]:
"""A lazy pipeline that processes iterators."""
def __init__(self, transform: Callable[[Iterator[T]], Iterator[R]]) -> None:
self._transform = transform
def process(self, source: Iterator[T]) -> Iterator[R]:
"""Process a stream of values."""
return self._transform(source)
def then[U](self, next_step: Callable[[Iterator[R]], Iterator[U]]) -> "StreamPipeline[T, U]":
"""Chain another stream transformation."""
def combined(source: Iterator[T]) -> Iterator[U]:
intermediate = self._transform(source)
return next_step(intermediate)
return StreamPipeline(combined)
What does this code do?
StreamPipeline[T, R] follows the same pattern as Pipeline, but its transform operates on Iterator[T] instead of a single T value. Items are only processed when you consume the output iterator.
The then[U] method works identically to the one in Pipeline. It chains stream transformations while tracking how the element type changes at each stage.
StreamPipeline needs reusable building blocks to create its stream transformations. These are generic functions that produce the callables StreamPipeline expects.
- Add the map_step and filter_step helper functions below the StreamPipeline class:
def map_step[A, B](func: Callable[[A], B]) -> Callable[[Iterator[A]], Iterator[B]]:
"""Create a map transformation step for StreamPipeline."""
def step(source: Iterator[A]) -> Iterator[B]:
return (func(item) for item in source)
return step
def filter_step[A](predicate: Callable[[A], bool]) -> Callable[[Iterator[A]], Iterator[A]]:
"""Create a filter transformation step for StreamPipeline."""
def step(source: Iterator[A]) -> Iterator[A]:
return (item for item in source if predicate(item))
return step
What do these helpers do?
map_step[A, B] takes a function from A to B and wraps it into a stream transformer. The return type Callable[[Iterator[A]], Iterator[B]] is exactly what StreamPipeline accepts.
filter_step[A] only has one type parameter because filtering doesn't change the element type. The input and output iterators both carry type A.
- Save pipeline.py.
Seeing a type error on the generator expressions?
Make sure the parentheses around the generator expression are present. Writing return func(item) for item in source without wrapping parentheses is a syntax error. The correct form is (func(item) for item in source).
Help me fix a generator expression error in my pipeline helpers
Test the pipeline
Now add a demonstration block that exercises both Pipeline and StreamPipeline with concrete types.
- Add the following if __name__ == "__main__" block at the bottom of pipeline.py:
if __name__ == "__main__":
# Simple pipeline: str -> int -> float
parse_and_halve: Pipeline[str, float] = (
Pipeline[str, int](int)
.then(lambda n: n / 2)
)
print(parse_and_halve.apply("42")) # 21.0
print(parse_and_halve.apply_many(["10", "20", "30"])) # [5.0, 10.0, 15.0]
# Chaining multiple steps
text_pipeline: Pipeline[str, list[str]] = (
Pipeline[str, str](str.strip)
.then(str.lower)
.then(lambda s: s.split())
)
print(text_pipeline.apply(" Hello World ")) # ['hello', 'world']
# Stream pipeline with lazy evaluation
stream: StreamPipeline[int, int] = StreamPipeline[int, int](
map_step(lambda x: x * 2)
).then(
filter_step(lambda x: x > 5)
)
result = list(stream.process(iter(range(1, 6))))
print(result) # [6, 8, 10]
What does this demonstrate?
- parse_and_halve starts as Pipeline[str, int] (parsing strings to ints), then chains .then(lambda n: n / 2) to become Pipeline[str, float]. The type parameter changes at each step.
- text_pipeline chains three steps: str to str (strip), str to str (lower), str to list[str] (split). Each .then() call introduces a new U that becomes the next R.
- The stream pipeline doubles each number, then filters to keep only values above 5. Only [6, 8, 10] survive because 2 and 4 (doubled from 1 and 2) are too small.
- Save pipeline.py.
- Run the file by entering this command in your terminal:
python3 pipeline.py
You should see output showing 21.0, the list [5.0, 10.0, 15.0], the split words ['hello', 'world'], and the filtered stream result [6, 8, 10].
Seeing unexpected output or a NameError?
If you see a NameError for map_step or filter_step, make sure both helper functions are defined above the if __name__ block in the file.
If the stream result is an empty list, check that your filter_step lambda uses x > 5 (greater than), not x > 10.
Help me debug my pipeline.py output
✔️ Awesome, I've got everything!
Great. Double check you've saved pipeline.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete pipeline.py file for comparison:
"""Generic pipeline demonstrating chained transformations with multiple type parameters."""
from collections.abc import Callable, Iterator
class Pipeline[T, R]:
"""A typed transformation pipeline from input type T to output type R."""
def __init__(self, transform: Callable[[T], R]) -> None:
self._transform = transform
def apply(self, value: T) -> R:
"""Apply the transformation to a single value."""
return self._transform(value)
def then[U](self, next_transform: Callable[[R], U]) -> "Pipeline[T, U]":
"""Chain another transformation, producing a new Pipeline[T, U]."""
def combined(value: T) -> U:
intermediate = self._transform(value)
return next_transform(intermediate)
return Pipeline(combined)
def apply_many(self, values: list[T]) -> list[R]:
"""Apply the transformation to a list of values."""
return [self._transform(v) for v in values]
class StreamPipeline[T, R]:
"""A lazy pipeline that processes iterators."""
def __init__(self, transform: Callable[[Iterator[T]], Iterator[R]]) -> None:
self._transform = transform
def process(self, source: Iterator[T]) -> Iterator[R]:
"""Process a stream of values."""
return self._transform(source)
def then[U](self, next_step: Callable[[Iterator[R]], Iterator[U]]) -> "StreamPipeline[T, U]":
"""Chain another stream transformation."""
def combined(source: Iterator[T]) -> Iterator[U]:
intermediate = self._transform(source)
return next_step(intermediate)
return StreamPipeline(combined)
def map_step[A, B](func: Callable[[A], B]) -> Callable[[Iterator[A]], Iterator[B]]:
"""Create a map transformation step for StreamPipeline."""
def step(source: Iterator[A]) -> Iterator[B]:
return (func(item) for item in source)
return step
def filter_step[A](predicate: Callable[[A], bool]) -> Callable[[Iterator[A]], Iterator[A]]:
"""Create a filter transformation step for StreamPipeline."""
def step(source: Iterator[A]) -> Iterator[A]:
return (item for item in source if predicate(item))
return step
if __name__ == "__main__":
# Simple pipeline: str -> int -> float
parse_and_halve: Pipeline[str, float] = (
Pipeline[str, int](int)
.then(lambda n: n / 2)
)
print(parse_and_halve.apply("42")) # 21.0
print(parse_and_halve.apply_many(["10", "20", "30"])) # [5.0, 10.0, 15.0]
# Chaining multiple steps
text_pipeline: Pipeline[str, list[str]] = (
Pipeline[str, str](str.strip)
.then(str.lower)
.then(lambda s: s.split())
)
print(text_pipeline.apply(" Hello World ")) # ['hello', 'world']
# Stream pipeline with lazy evaluation
stream: StreamPipeline[int, int] = StreamPipeline[int, int](
map_step(lambda x: x * 2)
).then(
filter_step(lambda x: x > 5)
)
result = list(stream.process(iter(range(1, 6))))
print(result) # [6, 8, 10]
You now have a full pipeline system where the type checker guarantees every transformation connects correctly. Next up, you'll wire all your generic classes together in a single entry point and prove everything passes mypy strict mode.
Wire Everything Together and Type-Check
You have four modules covering single-parameter generics, bounded types, multi-parameter repositories, and chained pipelines. Now it's time to prove they all work together under a single type checker.
A type-safe library only counts as type-safe if a static analysis tool confirms it. In this step, you'll create a main module that exercises every generic class with concrete types, then run mypy in strict mode to validate the entire project.
In this step, get ready to:
- Create main.py with demonstration functions for every generic module.
- Run mypy --strict . and verify zero type errors across the project.
Create main.py with demonstration functions
The main.py file imports all four modules and calls their APIs with concrete type arguments. It also includes commented-out lines showing what mypy would reject, so you can see the type checker's power without breaking your code.
- Create a new file called main.py in your project directory.
- Add the imports and the first two demonstration functions by pasting this code:
"""Main module that wires everything together and demonstrates type safety."""
from box import Box, LegacyBox
from comparable import SortedContainer
from repository import Repository, EntityRepository, User, Product, Entity
from pipeline import Pipeline, StreamPipeline, map_step, filter_step
def demonstrate_box() -> None:
"""Demonstrate single-parameter generics."""
print("=== Box[T] ===")
# The type parameter is inferred from the argument
auto_box = Box("inferred as str")
print(auto_box)
# Explicit type annotation
explicit_box: Box[int] = Box(100)
explicit_box.set(200)
print(f"Value: {explicit_box.get()}")
# Empty box with explicit type
empty: Box[list[str]] = Box()
print(f"Is empty: {empty.is_empty()}")
empty.set(["hello", "world"])
print(f"After set: {empty.get()}")
# === What mypy catches (uncomment to see errors) ===
# int_box: Box[int] = Box(42)
# int_box.set("not an int") # error: Argument 1 has incompatible type "str"
def demonstrate_sorted_container() -> None:
"""Demonstrate bounded type parameters."""
print("\n=== SortedContainer[T: Comparable] ===")
numbers: SortedContainer[int] = SortedContainer()
for n in [30, 10, 50, 20, 40]:
numbers.insert(n)
print(f"Sorted: {numbers.get_sorted()}")
print(f"Min: {numbers.get_min()}, Max: {numbers.get_max()}")
# === What mypy catches (uncomment to see errors) ===
# Non-comparable type would fail:
# bad: SortedContainer[object] = SortedContainer() # error: Type argument has incompatible type
What does this code do?
The imports pull in every generic class you've built across all four modules.
- demonstrate_box() shows type inference (Python figures out T from the argument), explicit annotation (Box[int]), and nested generics (Box[list[str]]).
- demonstrate_sorted_container() exercises the bounded type parameter. Only types that implement Comparable are accepted.
- The commented-out lines show what mypy would catch if you uncommented them. They stay commented so the file passes strict type checking.
You won't see output yet. The next chunk adds the remaining functions and the entry point that runs everything.
- Now add the remaining demonstration functions and the main() entry point below the code you just pasted:
def demonstrate_repository() -> None:
"""Demonstrate multi-parameter generics."""
print("\n=== Repository[K, V] ===")
# String keys, integer values
scores: Repository[str, int] = Repository()
scores.save("alice", 95)
scores.save("bob", 87)
print(f"Alice's score: {scores.find('alice')}")
print(f"All scores: {scores.find_all()}")
# Entity repository with bounded type
print("\n=== EntityRepository[E: Entity] ===")
user_repo: EntityRepository[User] = EntityRepository()
user_repo.save(User(id="1", name="Alice", email="alice@test.com"))
user_repo.save(User(id="2", name="Bob", email="bob@test.com"))
user = user_repo.find_by_id("1")
if user is not None:
# mypy knows this is a User, not just Entity
print(f"Found: {user.name} ({user.email})")
# === What mypy catches (uncomment to see errors) ===
# product_repo: EntityRepository[Product] = EntityRepository()
# product_repo.save(User(id="1", name="Wrong")) # error: Argument has incompatible type "User"; expected "Product"
def demonstrate_pipeline() -> None:
"""Demonstrate chained generics with multiple type parameters."""
print("\n=== Pipeline[T, R] ===")
# Build a pipeline: str -> int -> str
pipeline: Pipeline[str, str] = (
Pipeline[str, int](len)
.then(lambda n: f"Length is {n}")
)
print(pipeline.apply("Hello, generics!"))
# Process a batch
words = ["short", "a bit longer", "x"]
results = Pipeline[str, int](len).apply_many(words)
print(f"Lengths: {results}")
# Stream pipeline
print("\n=== StreamPipeline[T, R] ===")
stream: StreamPipeline[str, int] = StreamPipeline[str, str](
map_step(str.strip)
).then(
filter_step(lambda s: len(s) > 3)
).then(
map_step(len)
)
data = iter([" hi ", " hello ", " no ", " generics "])
print(f"Stream result: {list(stream.process(data))}")
def main() -> None:
"""Run all demonstrations."""
demonstrate_box()
demonstrate_sorted_container()
demonstrate_repository()
demonstrate_pipeline()
print("\n" + "=" * 50)
print("All demonstrations complete!")
print("Run 'mypy --strict .' to verify type safety.")
if __name__ == "__main__":
main()
What does this code do?
- demonstrate_repository() exercises both Repository[str, int] (two explicit type parameters) and EntityRepository[User] (bounded to Entity subclasses). The if user is not None narrowing proves mypy tracks the specific type through the generic.
- demonstrate_pipeline() chains transformations across type boundaries (str -> int -> str) and builds a lazy StreamPipeline with map_step and filter_step.
- main() calls all four demonstration functions and prints a reminder to run mypy next.
- Save main.py.
- Run the file to confirm everything executes by running this command:
python3 main.py
You should see output for each section: Box values, sorted numbers, repository lookups, pipeline transformations, and a final "All demonstrations complete!" message.
Seeing a ModuleNotFoundError?
- Make sure you're running the command from inside your project directory where all .py files live.
- Check that box.py, comparable.py, repository.py, and pipeline.py are all saved in the same folder as main.py.
- help me debug a ModuleNotFoundError when running main.py
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py before moving on.
ⓧ I'd like to double check the full code
"""Main module that wires everything together and demonstrates type safety."""
from box import Box, LegacyBox
from comparable import SortedContainer
from repository import Repository, EntityRepository, User, Product, Entity
from pipeline import Pipeline, StreamPipeline, map_step, filter_step
def demonstrate_box() -> None:
"""Demonstrate single-parameter generics."""
print("=== Box[T] ===")
# The type parameter is inferred from the argument
auto_box = Box("inferred as str")
print(auto_box)
# Explicit type annotation
explicit_box: Box[int] = Box(100)
explicit_box.set(200)
print(f"Value: {explicit_box.get()}")
# Empty box with explicit type
empty: Box[list[str]] = Box()
print(f"Is empty: {empty.is_empty()}")
empty.set(["hello", "world"])
print(f"After set: {empty.get()}")
# === What mypy catches (uncomment to see errors) ===
# int_box: Box[int] = Box(42)
# int_box.set("not an int") # error: Argument 1 has incompatible type "str"
def demonstrate_sorted_container() -> None:
"""Demonstrate bounded type parameters."""
print("\n=== SortedContainer[T: Comparable] ===")
numbers: SortedContainer[int] = SortedContainer()
for n in [30, 10, 50, 20, 40]:
numbers.insert(n)
print(f"Sorted: {numbers.get_sorted()}")
print(f"Min: {numbers.get_min()}, Max: {numbers.get_max()}")
# === What mypy catches (uncomment to see errors) ===
# Non-comparable type would fail:
# bad: SortedContainer[object] = SortedContainer() # error: Type argument has incompatible type
def demonstrate_repository() -> None:
"""Demonstrate multi-parameter generics."""
print("\n=== Repository[K, V] ===")
# String keys, integer values
scores: Repository[str, int] = Repository()
scores.save("alice", 95)
scores.save("bob", 87)
print(f"Alice's score: {scores.find('alice')}")
print(f"All scores: {scores.find_all()}")
# Entity repository with bounded type
print("\n=== EntityRepository[E: Entity] ===")
user_repo: EntityRepository[User] = EntityRepository()
user_repo.save(User(id="1", name="Alice", email="alice@test.com"))
user_repo.save(User(id="2", name="Bob", email="bob@test.com"))
user = user_repo.find_by_id("1")
if user is not None:
# mypy knows this is a User, not just Entity
print(f"Found: {user.name} ({user.email})")
# === What mypy catches (uncomment to see errors) ===
# product_repo: EntityRepository[Product] = EntityRepository()
# product_repo.save(User(id="1", name="Wrong")) # error: Argument has incompatible type "User"; expected "Product"
def demonstrate_pipeline() -> None:
"""Demonstrate chained generics with multiple type parameters."""
print("\n=== Pipeline[T, R] ===")
# Build a pipeline: str -> int -> str
pipeline: Pipeline[str, str] = (
Pipeline[str, int](len)
.then(lambda n: f"Length is {n}")
)
print(pipeline.apply("Hello, generics!"))
# Process a batch
words = ["short", "a bit longer", "x"]
results = Pipeline[str, int](len).apply_many(words)
print(f"Lengths: {results}")
# Stream pipeline
print("\n=== StreamPipeline[T, R] ===")
stream: StreamPipeline[str, int] = StreamPipeline[str, str](
map_step(str.strip)
).then(
filter_step(lambda s: len(s) > 3)
).then(
map_step(len)
)
data = iter([" hi ", " hello ", " no ", " generics "])
print(f"Stream result: {list(stream.process(data))}")
def main() -> None:
"""Run all demonstrations."""
demonstrate_box()
demonstrate_sorted_container()
demonstrate_repository()
demonstrate_pipeline()
print("\n" + "=" * 50)
print("All demonstrations complete!")
print("Run 'mypy --strict .' to verify type safety.")
if __name__ == "__main__":
main()
Run mypy in strict mode
Running your code proved the logic works at runtime. Static type checking proves the types are correct at development time, without needing to execute anything.
- Run mypy with the strict flag across your entire project by running this command:
mypy --strict .
What does --strict do?
The --strict flag enables every optional type-checking rule mypy offers. It disallows untyped function definitions, untyped calls, and untyped generics.
If your generics have any holes (a missing return type, a T used inconsistently, an untyped function), strict mode will catch it. Zero errors means your library is fully type-safe.
- You should see a "Success" message with zero errors reported. Each file is listed as checked with no issues.
Seeing type errors from mypy?
- Read the error message carefully. Mypy reports the file name, line number, and exact issue. Compare that line against the Complete Code.
- Make sure you did not accidentally uncomment any of the "What mypy catches" lines. Those are intentionally invalid and must stay commented out.
- Confirm you are running Python 3.12+ by checking python3 --version. The class Box[T]: syntax requires 3.12 or newer.
- help me fix mypy errors in my Python generics project
Secret mission
Build a Generic EventBus[E]
Ready for more? Build a typed publish/subscribe event system where the event type itself is generic. Subscribers are callbacks constrained to accept only the specific event type their bus handles.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files for reference, or delete them entirely.
Resources you used:
- Project directory with Python files (box.py, comparable.py, repository.py, pipeline.py, main.py).
- mypy package installed via pip.
Keep everything running
No action needed. Choose this if you want to keep the project files for future reference or plan to extend the code with your own generic classes.
- Your project directory stays on your Desktop.
- mypy remains installed and available for other Python projects.
Pause - I'll come back to this later
Since everything is local, there is nothing to pause. Your files stay on disk until you decide to delete them.
- No running processes to stop.
- Your project folder and mypy installation take up minimal disk space.
Delete - I don't want to use this again
Remove all project files and uninstall mypy to start fresh.
- Delete the project directory by running this command on macOS/Linux:
rm -rf ~/Desktop/python-generics
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\python-generics
- Uninstall mypy by running:
python3 -m pip uninstall mypy
Keep mypy for other projects
mypy is useful for any Python project that uses type hints. You may want to skip uninstalling it if you plan to continue writing typed Python code.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete type-safe library using every major Python generics pattern, from single-parameter containers to chained multi-parameter pipelines.
You've learned how to:
- Built generic classes using both the modern PEP 695 syntax (Box[T]) and the legacy TypeVar + Generic[T] approach, with bounded type parameters to restrict what types are accepted.
- Designed multi-parameter generics (Repository[K, V] and EntityRepository[E: Entity]) for real-world data store patterns with full CRUD operations.
- Chained typed transformations with Pipeline[T, R] and verified zero errors across the entire project using mypy --strict.
- Secret Mission: Built a typed publish/subscribe EventBus[E] proving generics work for callback patterns too.
Ready to quiz yourself?