Project 6: Generic Model Library
Project 6: Generic Model Library
Build a library of generic Pydantic models–paginated responses, API envelopes, result types–that work with any data type while maintaining full type safety.
Learning Objectives
By completing this project, you will:
- Master Python generics with TypeVar - Understand how type variables enable reusable, type-safe code
- Create generic Pydantic BaseModel subclasses - Build models that work with any data type while maintaining validation
- Understand bounded type variables - Constrain type variables with
boundfor safer generic types - Implement covariance and contravariance - Know when to use
covariant=Trueorcontravariant=True - Use runtime type introspection - Apply
get_originandget_argsto inspect generic types at runtime - Integrate generic models with FastAPI - Create reusable response wrappers that generate correct OpenAPI schemas
Deep Theoretical Foundation
The Problem Generics Solve
Consider building an API that returns paginated responses for different resources:
# Without generics: Massive duplication
class PaginatedUserResponse(BaseModel):
items: list[User]
total: int
page: int
per_page: int
class PaginatedOrderResponse(BaseModel):
items: list[Order]
total: int
page: int
per_page: int
class PaginatedProductResponse(BaseModel):
items: list[Product]
total: int
page: int
per_page: int
# ... repeated for every entity
This duplication violates DRY (Don’t Repeat Yourself) and makes maintenance painful. Generics solve this:
# With generics: Single reusable definition
T = TypeVar('T')
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
per_page: int
# Usage - fully type-safe!
UserPage = PaginatedResponse[User]
OrderPage = PaginatedResponse[Order]
ProductPage = PaginatedResponse[Product]
Python Generics Fundamentals
TypeVar: The Type Variable
A TypeVar is a placeholder for a type that will be specified later. Think of it as a variable that holds a type instead of a value:
Regular Variable: x = 5 # x holds the value 5
Type Variable: T = TypeVar('T') # T holds a type (like int, str, User)
Creating type variables:
from typing import TypeVar
# Basic type variable - can be any type
T = TypeVar('T')
# Type variable with constraints - can only be specific types
Number = TypeVar('Number', int, float) # Only int or float
# Type variable with bound - must be subclass of bound
from pydantic import BaseModel
ModelT = TypeVar('ModelT', bound=BaseModel) # Must be BaseModel or subclass
How TypeVar Works at Runtime vs Static Analysis
Static Analysis (mypy, Pyright) Runtime (Python Interpreter)
================================ ============================
T = TypeVar('T') T = TypeVar('T')
# T is a TypeVar object
class Box(Generic[T]): class Box(Generic[T]):
value: T value: T
# T is still a TypeVar
box: Box[int] = Box(value=5) box = Box(value=5)
# Type info mostly erased!
reveal_type(box.value) # int type(box.value) # int (from value)
Key insight: Python’s type hints are largely erased at runtime. Pydantic is special because it preserves and uses type information at runtime for validation.
Generic Class Inheritance Pattern
Generic[T]
|
| (inherit with Generic[T])
v
+---------+----------+
| |
v v
BaseModel Other Classes
|
| (combine inheritance)
v
MyGenericModel(BaseModel, Generic[T])
|
| (specialize with concrete type)
v
MyGenericModel[User] # Concrete type
Generic BaseModel Inheritance
When creating a generic Pydantic model, you must inherit from both BaseModel and Generic[T]:
from typing import TypeVar, Generic
from pydantic import BaseModel
T = TypeVar('T')
class Wrapper(BaseModel, Generic[T]):
data: T
metadata: dict = {}
How Pydantic handles this:
1. Class Definition
Wrapper(BaseModel, Generic[T])
|
v
2. Pydantic Metaclass Processing
- Detects Generic[T] in bases
- Records type parameters
- Prepares for parameterization
|
v
3. Parameterization: Wrapper[User]
- Creates specialized class
- Substitutes T with User
- Generates schema for User
|
v
4. Validation
- Uses User schema for 'data' field
- Full Pydantic validation applies
Bounded Type Variables
A bound constrains what types can be used with a TypeVar:
from pydantic import BaseModel
from typing import TypeVar, Generic
# Without bound - T can be ANY type
T = TypeVar('T')
class Container(BaseModel, Generic[T]):
item: T
def describe(self) -> str:
# Problem: T could be anything, so we can't call methods on it
# return self.item.some_method() # Type error!
return str(self.item)
# With bound - T must be BaseModel or subclass
ModelT = TypeVar('ModelT', bound=BaseModel)
class ModelContainer(BaseModel, Generic[ModelT]):
item: ModelT
def get_fields(self) -> list[str]:
# Safe: We know item is a BaseModel, so model_fields exists
return list(self.item.model_fields.keys())
Visual comparison:
T = TypeVar('T') ModelT = TypeVar('ModelT', bound=BaseModel)
Can be: Can be:
- int - BaseModel (itself)
- str - User (if User inherits BaseModel)
- list - Order (if Order inherits BaseModel)
- User
- Any type! Cannot be:
- int
- str
- dict
- Classes not inheriting BaseModel
Covariance and Contravariance
These concepts describe how generic types relate to each other when their type parameters are related by inheritance.
Covariance (Output Position)
If Dog is a subtype of Animal, then Container[Dog] is a subtype of Container[Animal]:
T_co = TypeVar('T_co', covariant=True)
class Reader(Generic[T_co]):
"""A type that only PRODUCES values of type T"""
def read(self) -> T_co:
...
# Dog is subtype of Animal
# Therefore Reader[Dog] is subtype of Reader[Animal]
def process_reader(r: Reader[Animal]) -> None:
animal = r.read() # We get an Animal (or subtype)
dog_reader: Reader[Dog] = ...
process_reader(dog_reader) # OK! Reader[Dog] is subtype of Reader[Animal]
Inheritance: Dog --> Animal
| |
Covariance: v v
Reader[Dog] --> Reader[Animal]
(subtype) (supertype)
Contravariance (Input Position)
The subtype relationship is reversed for types that CONSUME values:
T_contra = TypeVar('T_contra', contravariant=True)
class Writer(Generic[T_contra]):
"""A type that only CONSUMES values of type T"""
def write(self, value: T_contra) -> None:
...
# Dog is subtype of Animal
# Therefore Writer[Animal] is subtype of Writer[Dog]
def use_writer(w: Writer[Dog]) -> None:
w.write(Dog())
animal_writer: Writer[Animal] = ...
use_writer(animal_writer) # OK! Writer[Animal] can accept Dog
Inheritance: Dog --> Animal
| |
Contravariance: v v
Writer[Animal] --> Writer[Dog]
(subtype) (supertype)
(reversed!)
Invariance (Default)
By default, TypeVar is invariant–no subtype relationship:
T = TypeVar('T') # Invariant
class Container(Generic[T]):
def get(self) -> T: ...
def set(self, value: T) -> None: ...
# Container[Dog] is NOT a subtype of Container[Animal]
# Container[Animal] is NOT a subtype of Container[Dog]
Practical Pydantic Example
from typing import TypeVar, Generic, Sequence
from pydantic import BaseModel
# For response models (output), use covariance
T_co = TypeVar('T_co', covariant=True, bound=BaseModel)
class ResponseWrapper(BaseModel, Generic[T_co]):
"""Returns data - covariant makes sense for API responses"""
success: bool
data: T_co
# Note: Pydantic models are typically invariant by default
# because they both read and write data. Covariance is
# mainly useful for abstract/read-only interfaces.
Runtime Type Introspection
At runtime, you often need to inspect generic types. Python’s typing module provides get_origin and get_args:
from typing import get_origin, get_args, List, Dict, Optional, Union
# get_origin: Returns the base type
get_origin(List[int]) # list
get_origin(Dict[str, int]) # dict
get_origin(Optional[str]) # typing.Union
get_origin(int) # None (not generic)
# get_args: Returns the type arguments
get_args(List[int]) # (int,)
get_args(Dict[str, int]) # (str, int)
get_args(Optional[str]) # (str, NoneType)
get_args(int) # () (empty tuple)
Diagram of what these functions return:
Type Expression get_origin() get_args()
--------------- ------------ ----------
List[int] list (int,)
Dict[str, User] dict (str, User)
Optional[Order] Union (Order, NoneType)
Union[int, str, None] Union (int, str, NoneType)
Tuple[int, str, float] tuple (int, str, float)
Set[Product] set (Product,)
Callable[[int], str] collections.abc. (int, str)
Callable
Using Introspection with Pydantic Generics
from typing import TypeVar, Generic, get_origin, get_args, Type
from pydantic import BaseModel
T = TypeVar('T')
class Wrapper(BaseModel, Generic[T]):
data: T
@classmethod
def get_inner_type(cls) -> Type:
"""Get the actual type T was parameterized with."""
# For Wrapper[User], this returns User
for base in cls.__orig_bases__:
origin = get_origin(base)
if origin is Wrapper or (origin and issubclass(origin, Wrapper)):
args = get_args(base)
if args:
return args[0]
return None
# Usage
UserWrapper = Wrapper[User]
print(UserWrapper.get_inner_type()) # <class 'User'>
Generic Types with FastAPI
FastAPI fully supports Pydantic generic models in response types:
from fastapi import FastAPI
from pydantic import BaseModel
from typing import TypeVar, Generic
T = TypeVar('T')
class APIResponse(BaseModel, Generic[T]):
success: bool
data: T | None = None
message: str | None = None
class User(BaseModel):
id: int
name: str
app = FastAPI()
@app.get("/users/{user_id}", response_model=APIResponse[User])
async def get_user(user_id: int):
user = User(id=user_id, name="John")
return APIResponse(success=True, data=user)
The OpenAPI schema correctly reflects the nested structure:
{
"APIResponse_User_": {
"type": "object",
"properties": {
"success": {"type": "boolean"},
"data": {
"anyOf": [
{"$ref": "#/components/schemas/User"},
{"type": "null"}
]
},
"message": {
"anyOf": [
{"type": "string"},
{"type": "null"}
]
}
},
"required": ["success"]
}
}
Nested Generics
You can compose generic types for complex structures:
T = TypeVar('T')
E = TypeVar('E')
class Result(BaseModel, Generic[T, E]):
"""Either success with data T, or failure with error E"""
success: bool
data: T | None = None
error: E | None = None
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
# Nested generic: API returns paginated results that can fail
@app.get("/users", response_model=Result[PaginatedResponse[User], str])
async def list_users(page: int = 1):
try:
users = get_users(page)
paginated = PaginatedResponse(items=users, total=100, page=page)
return Result(success=True, data=paginated)
except Exception as e:
return Result(success=False, error=str(e))
Type structure visualization:
Result[PaginatedResponse[User], str]
|
+-- success: bool
|
+-- data: PaginatedResponse[User] | None
| |
| +-- items: list[User]
| | |
| | +-- User instances
| |
| +-- total: int
| +-- page: int
|
+-- error: str | None
Project Specification
Functional Requirements
Build a library of generic Pydantic models that can be used across any Python API project:
- APIResponse[T] - Standard API response envelope
- Wrap any data type with success/error status
- Include optional message and metadata
- Provide factory methods for success/error cases
- PaginatedResponse[T] - Paginated list wrapper
- Support any item type
- Include pagination metadata (page, per_page, total, pages)
- Provide helper properties (has_next, has_prev)
- Include factory method to paginate sequences
- Result[T, E] - Rust-style Result type
- Either success with data of type T
- Or failure with error of type E
- Monadic operations (map, flat_map)
- Optional[T] Enhancement - Rich optional wrapper
- Better than None for explicit absence
- Support default value on unwrap
- Chain operations safely
- BatchResponse[T] - Bulk operation results
- Track individual item successes/failures
- Aggregate statistics
- Maintain order correspondence
Model Contracts
# APIResponse[T]
class APIResponse(BaseModel, Generic[T]):
success: bool = True
data: T | None = None
message: str | None = None
errors: list[str] | None = None
meta: dict | None = None
timestamp: datetime
@classmethod
def ok(cls, data: T, message: str = None) -> "APIResponse[T]": ...
@classmethod
def error(cls, message: str, errors: list[str] = None) -> "APIResponse[T]": ...
# PaginatedResponse[T]
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int = Field(ge=1)
per_page: int = Field(ge=1, le=100)
@property
def pages(self) -> int: ...
@property
def has_next(self) -> bool: ...
@property
def has_prev(self) -> bool: ...
@classmethod
def from_sequence(cls, items: Sequence[T], page: int, per_page: int) -> "PaginatedResponse[T]": ...
# Result[T, E]
class Result(BaseModel, Generic[T, E]):
_is_ok: bool
_value: T | None
_error: E | None
@classmethod
def ok(cls, value: T) -> "Result[T, E]": ...
@classmethod
def err(cls, error: E) -> "Result[T, E]": ...
def is_ok(self) -> bool: ...
def is_err(self) -> bool: ...
def unwrap(self) -> T: ... # Raises if error
def unwrap_or(self, default: T) -> T: ...
def map(self, fn: Callable[[T], U]) -> "Result[U, E]": ...
Solution Architecture
Project Structure
generic-models/
src/
generic_models/
__init__.py
api_response.py # APIResponse[T]
paginated.py # PaginatedResponse[T]
result.py # Result[T, E]
batch.py # BatchResponse[T]
optional.py # OptionalValue[T]
types.py # Common TypeVars
utils.py # Runtime introspection helpers
tests/
__init__.py
test_api_response.py
test_paginated.py
test_result.py
test_fastapi_integration.py
examples/
fastapi_app.py # Example FastAPI integration
basic_usage.py # Simple usage examples
pyproject.toml
README.md
Component Diagram
+-----------------------------------------------------------------+
| generic_models Package |
+-----------------------------------------------------------------+
| |
| +------------------+ +-------------------+ |
| | types.py | | utils.py | |
| +------------------+ +-------------------+ |
| | T = TypeVar('T') | | get_inner_type() | |
| | E = TypeVar('E') | | resolve_generic() | |
| | ModelT = TypeVar | | is_generic() | |
| | (bound=...) | | | |
| +--------+---------+ +---------+---------+ |
| | | |
| v v |
| +------------------+ +-------------------+ |
| | api_response.py | | paginated.py | |
| +------------------+ +-------------------+ |
| | APIResponse[T] | | PaginatedResponse | |
| | - ok() | | [T] | |
| | - error() | | - from_sequence() | |
| +--------+---------+ | - pages property | |
| | +-------------------+ |
| | | |
| v v |
| +------------------+ +-------------------+ |
| | result.py | | batch.py | |
| +------------------+ +-------------------+ |
| | Result[T, E] | | BatchResponse[T] | |
| | - ok() / err() | | - BatchItem[T] | |
| | - map() | | - statistics | |
| | - unwrap() | | | |
| +------------------+ +-------------------+ |
| |
+-----------------------------------------------------------------+
|
v
+-------------------------------+
| FastAPI Integration |
+-------------------------------+
| @app.get(response_model= |
| APIResponse[User]) |
| - OpenAPI schema generation |
| - Automatic serialization |
+-------------------------------+
Type Hierarchy
BaseModel (Pydantic)
|
+-- Generic[T] (typing)
|
+-- APIResponse[T]
| |
| +-- APIResponse[User]
| +-- APIResponse[Order]
| +-- APIResponse[PaginatedResponse[Product]]
|
+-- PaginatedResponse[T]
| |
| +-- PaginatedResponse[User]
| +-- PaginatedResponse[Order]
|
+-- Result[T, E]
| |
| +-- Result[User, ValidationError]
| +-- Result[Order, str]
|
+-- BatchResponse[T]
|
+-- BatchResponse[User]
Phased Implementation Guide
Phase 1: Foundation and TypeVars (1-2 hours)
Goal: Set up the project structure and define reusable type variables.
- Create project structure:
mkdir -p generic-models/src/generic_models generic-models/tests generic-models/examples touch generic-models/src/generic_models/__init__.py touch generic-models/pyproject.toml - Create
types.pywith common TypeVars:from typing import TypeVar from pydantic import BaseModel # Generic type variable - can be any type T = TypeVar('T') # Second type variable for Result[T, E] E = TypeVar('E') # Third type variable for complex compositions U = TypeVar('U') # Bounded type variable - must be BaseModel subclass ModelT = TypeVar('ModelT', bound=BaseModel) - Create
utils.pywith introspection helpers:from typing import get_origin, get_args, Type, Any def get_inner_type(cls: Type) -> Type | None: """Extract the type parameter from a parameterized generic.""" for base in getattr(cls, '__orig_bases__', []): args = get_args(base) if args: return args[0] return None def is_generic_model(cls: Type) -> bool: """Check if a class is a parameterized generic Pydantic model.""" return hasattr(cls, '__pydantic_generic_metadata__')
Checkpoint: Import types.py without errors.
Phase 2: APIResponse[T] (2 hours)
Goal: Create a complete API response wrapper.
- Create
api_response.py:from datetime import datetime from typing import Generic from pydantic import BaseModel, Field from .types import T class APIResponse(BaseModel, Generic[T]): """Standard API response envelope.""" success: bool = True data: T | None = None message: str | None = None errors: list[str] | None = None meta: dict | None = None timestamp: datetime = Field(default_factory=datetime.utcnow) @classmethod def ok(cls, data: T, message: str | None = None, meta: dict | None = None) -> "APIResponse[T]": """Create a success response.""" return cls(success=True, data=data, message=message, meta=meta) @classmethod def error(cls, message: str, errors: list[str] | None = None) -> "APIResponse[T]": """Create an error response.""" return cls(success=False, message=message, errors=errors) - Test basic functionality:
class User(BaseModel): id: int name: str response = APIResponse[User].ok(User(id=1, name="John")) print(response.model_dump_json(indent=2))
Checkpoint: APIResponse works with concrete types and generates valid JSON.
Phase 3: PaginatedResponse[T] (2 hours)
Goal: Create a pagination wrapper with helpers.
- Create
paginated.py:from typing import Generic, Sequence from pydantic import BaseModel, Field, computed_field from .types import T class PaginatedResponse(BaseModel, Generic[T]): """Generic paginated response.""" items: list[T] total: int page: int = Field(ge=1, default=1) per_page: int = Field(ge=1, le=100, default=10) @computed_field @property def pages(self) -> int: """Total number of pages.""" if self.per_page <= 0: return 0 return (self.total + self.per_page - 1) // self.per_page @computed_field @property def has_next(self) -> bool: """Whether there's a next page.""" return self.page < self.pages @computed_field @property def has_prev(self) -> bool: """Whether there's a previous page.""" return self.page > 1 @classmethod def from_sequence( cls, items: Sequence[T], page: int = 1, per_page: int = 10 ) -> "PaginatedResponse[T]": """Create paginated response from a full sequence.""" total = len(items) start = (page - 1) * per_page end = start + per_page return cls( items=list(items[start:end]), total=total, page=page, per_page=per_page )
Checkpoint: Pagination correctly calculates pages and slices items.
Phase 4: Result[T, E] (2-3 hours)
Goal: Implement a Rust-style Result type with monadic operations.
- Create
result.py:from typing import Generic, Callable, TypeVar from pydantic import BaseModel, model_validator from .types import T, E U = TypeVar('U') class Result(BaseModel, Generic[T, E]): """Rust-style Result type: either Ok(T) or Err(E).""" _is_success: bool = True value: T | None = None error: E | None = None @model_validator(mode='after') def validate_result(self): """Ensure exactly one of value or error is set.""" has_value = self.value is not None has_error = self.error is not None if self._is_success and has_error: raise ValueError("Success result cannot have error") if not self._is_success and has_value: raise ValueError("Error result cannot have value") return self @classmethod def ok(cls, value: T) -> "Result[T, E]": """Create a success result.""" return cls(_is_success=True, value=value, error=None) @classmethod def err(cls, error: E) -> "Result[T, E]": """Create an error result.""" return cls(_is_success=False, value=None, error=error) def is_ok(self) -> bool: """Check if result is success.""" return self._is_success def is_err(self) -> bool: """Check if result is error.""" return not self._is_success def unwrap(self) -> T: """Get value or raise if error.""" if self.is_err(): raise ValueError(f"Called unwrap on error: {self.error}") return self.value def unwrap_or(self, default: T) -> T: """Get value or return default if error.""" return self.value if self.is_ok() else default def unwrap_err(self) -> E: """Get error or raise if success.""" if self.is_ok(): raise ValueError(f"Called unwrap_err on success: {self.value}") return self.error def map(self, fn: Callable[[T], U]) -> "Result[U, E]": """Transform the success value.""" if self.is_ok(): return Result[U, E].ok(fn(self.value)) return Result[U, E].err(self.error) def map_err(self, fn: Callable[[E], U]) -> "Result[T, U]": """Transform the error value.""" if self.is_err(): return Result[T, U].err(fn(self.error)) return Result[T, U].ok(self.value)
Checkpoint: Result type works with map operations.
Phase 5: BatchResponse[T] (2 hours)
Goal: Track results of bulk operations.
- Create
batch.py:from typing import Generic from pydantic import BaseModel, computed_field from .types import T from .result import Result class BatchItem(BaseModel, Generic[T]): """Single item in a batch operation.""" index: int success: bool data: T | None = None error: str | None = None class BatchResponse(BaseModel, Generic[T]): """Response for batch/bulk operations.""" results: list[BatchItem[T]] @computed_field @property def total(self) -> int: """Total items processed.""" return len(self.results) @computed_field @property def succeeded(self) -> int: """Count of successful items.""" return sum(1 for r in self.results if r.success) @computed_field @property def failed(self) -> int: """Count of failed items.""" return sum(1 for r in self.results if not r.success) @computed_field @property def success_rate(self) -> float: """Percentage of successful operations.""" if self.total == 0: return 0.0 return self.succeeded / self.total def get_successful(self) -> list[T]: """Get all successfully processed items.""" return [r.data for r in self.results if r.success and r.data is not None] def get_failures(self) -> list[tuple[int, str]]: """Get all failures as (index, error) tuples.""" return [(r.index, r.error or "Unknown error") for r in self.results if not r.success]
Checkpoint: BatchResponse correctly aggregates statistics.
Phase 6: FastAPI Integration and Testing (3 hours)
Goal: Integrate with FastAPI and create comprehensive tests.
- Create
examples/fastapi_app.py:from fastapi import FastAPI, Query, HTTPException from pydantic import BaseModel from generic_models import APIResponse, PaginatedResponse, Result app = FastAPI(title="Generic Models Demo") class User(BaseModel): id: int name: str email: str # Simulated database USERS = [User(id=i, name=f"User {i}", email=f"user{i}@example.com") for i in range(1, 101)] @app.get("/users", response_model=APIResponse[PaginatedResponse[User]]) async def list_users( page: int = Query(1, ge=1), per_page: int = Query(10, ge=1, le=50) ): paginated = PaginatedResponse.from_sequence(USERS, page, per_page) return APIResponse.ok(paginated) @app.get("/users/{user_id}", response_model=APIResponse[User]) async def get_user(user_id: int): user = next((u for u in USERS if u.id == user_id), None) if not user: return APIResponse.error(f"User {user_id} not found") return APIResponse.ok(user) - Verify OpenAPI schema at
/docsshows correct nested types.
Checkpoint: FastAPI endpoints work with generic response models.
Testing Strategy
Unit Tests for Generic Models
# tests/test_api_response.py
import pytest
from pydantic import BaseModel
from generic_models import APIResponse
class User(BaseModel):
id: int
name: str
def test_api_response_ok():
"""Test successful response creation."""
user = User(id=1, name="John")
response = APIResponse[User].ok(user, message="User found")
assert response.success is True
assert response.data == user
assert response.message == "User found"
assert response.errors is None
def test_api_response_error():
"""Test error response creation."""
response = APIResponse[User].error(
"Not found",
errors=["User does not exist"]
)
assert response.success is False
assert response.data is None
assert response.message == "Not found"
assert response.errors == ["User does not exist"]
def test_api_response_serialization():
"""Test JSON serialization."""
user = User(id=1, name="John")
response = APIResponse[User].ok(user)
data = response.model_dump()
assert data["success"] is True
assert data["data"]["id"] == 1
assert data["data"]["name"] == "John"
def test_api_response_nested_generic():
"""Test with nested generic types."""
from generic_models import PaginatedResponse
users = [User(id=i, name=f"User {i}") for i in range(1, 4)]
paginated = PaginatedResponse[User](items=users, total=3, page=1, per_page=10)
response = APIResponse[PaginatedResponse[User]].ok(paginated)
assert response.success is True
assert len(response.data.items) == 3
assert response.data.total == 3
Tests for PaginatedResponse
# tests/test_paginated.py
import pytest
from pydantic import BaseModel
from generic_models import PaginatedResponse
class Item(BaseModel):
id: int
name: str
def test_pagination_properties():
"""Test computed properties."""
paginated = PaginatedResponse[Item](
items=[Item(id=1, name="Test")],
total=100,
page=5,
per_page=10
)
assert paginated.pages == 10
assert paginated.has_next is True
assert paginated.has_prev is True
def test_pagination_first_page():
"""Test first page has no prev."""
paginated = PaginatedResponse[Item](
items=[],
total=100,
page=1,
per_page=10
)
assert paginated.has_prev is False
assert paginated.has_next is True
def test_pagination_last_page():
"""Test last page has no next."""
paginated = PaginatedResponse[Item](
items=[],
total=100,
page=10,
per_page=10
)
assert paginated.has_prev is True
assert paginated.has_next is False
def test_from_sequence():
"""Test creating paginated response from sequence."""
items = [Item(id=i, name=f"Item {i}") for i in range(1, 101)]
paginated = PaginatedResponse.from_sequence(items, page=2, per_page=10)
assert len(paginated.items) == 10
assert paginated.items[0].id == 11 # Second page starts at 11
assert paginated.total == 100
assert paginated.pages == 10
def test_from_sequence_partial_page():
"""Test when last page is partial."""
items = [Item(id=i, name=f"Item {i}") for i in range(1, 26)] # 25 items
paginated = PaginatedResponse.from_sequence(items, page=3, per_page=10)
assert len(paginated.items) == 5 # Only 5 items on last page
assert paginated.total == 25
assert paginated.pages == 3
Tests for Result Type
# tests/test_result.py
import pytest
from generic_models import Result
def test_result_ok():
"""Test success result."""
result = Result[int, str].ok(42)
assert result.is_ok() is True
assert result.is_err() is False
assert result.unwrap() == 42
def test_result_err():
"""Test error result."""
result = Result[int, str].err("Something went wrong")
assert result.is_ok() is False
assert result.is_err() is True
assert result.unwrap_err() == "Something went wrong"
def test_result_unwrap_or():
"""Test unwrap_or with default."""
ok_result = Result[int, str].ok(42)
err_result = Result[int, str].err("error")
assert ok_result.unwrap_or(0) == 42
assert err_result.unwrap_or(0) == 0
def test_result_map():
"""Test mapping over success value."""
result = Result[int, str].ok(5)
mapped = result.map(lambda x: x * 2)
assert mapped.is_ok()
assert mapped.unwrap() == 10
def test_result_map_on_error():
"""Test map does nothing on error."""
result = Result[int, str].err("error")
mapped = result.map(lambda x: x * 2)
assert mapped.is_err()
assert mapped.unwrap_err() == "error"
def test_result_map_err():
"""Test mapping over error value."""
result = Result[int, str].err("error")
mapped = result.map_err(lambda e: f"Wrapped: {e}")
assert mapped.is_err()
assert mapped.unwrap_err() == "Wrapped: error"
Integration Tests with FastAPI
# tests/test_fastapi_integration.py
import pytest
from fastapi.testclient import TestClient
from examples.fastapi_app import app
client = TestClient(app)
def test_list_users_returns_paginated():
"""Test that list users returns paginated response."""
response = client.get("/users?page=1&per_page=5")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]["items"]) == 5
assert data["data"]["total"] == 100
assert data["data"]["page"] == 1
assert data["data"]["has_next"] is True
def test_list_users_last_page():
"""Test last page pagination."""
response = client.get("/users?page=10&per_page=10")
data = response.json()
assert data["data"]["has_next"] is False
assert data["data"]["has_prev"] is True
def test_get_user_success():
"""Test getting existing user."""
response = client.get("/users/1")
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == 1
def test_get_user_not_found():
"""Test getting non-existent user."""
response = client.get("/users/999")
assert response.status_code == 200 # We return 200 with error in body
data = response.json()
assert data["success"] is False
assert "not found" in data["message"].lower()
def test_openapi_schema():
"""Test that OpenAPI schema is generated correctly."""
response = client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
# Check that generic types are in schemas
schemas = schema["components"]["schemas"]
assert any("APIResponse" in name for name in schemas)
assert any("PaginatedResponse" in name for name in schemas)
Common Pitfalls and Debugging
Pitfall 1: Forgetting to Inherit from Both BaseModel and Generic
Problem: Model doesn’t behave as generic.
# WRONG - missing Generic[T]
class Wrapper(BaseModel):
data: T # T is not a type parameter!
# CORRECT
class Wrapper(BaseModel, Generic[T]):
data: T
Symptom: T is treated as a forward reference to a class named “T”.
Solution: Always inherit from both BaseModel and Generic[T].
Pitfall 2: TypeVar Scope Issues
Problem: TypeVar defined in wrong scope.
# WRONG - T defined inside class
class Container(BaseModel, Generic[T]): # T not defined yet!
T = TypeVar('T') # This is a class attribute, not a type parameter
data: T
# CORRECT - T defined at module level
T = TypeVar('T')
class Container(BaseModel, Generic[T]):
data: T
Solution: Define TypeVars at module level before using them.
Pitfall 3: Mutable Default Arguments in Generic Classes
Problem: Shared mutable defaults across instances.
# WRONG - mutable default shared
class Response(BaseModel, Generic[T]):
items: list[T] = [] # Same list for all instances!
# CORRECT - use Field with default_factory
class Response(BaseModel, Generic[T]):
items: list[T] = Field(default_factory=list)
Symptom: Data mysteriously appears in “empty” instances.
Pitfall 4: Type Parameter Not Being Validated
Problem: Pydantic doesn’t validate the type parameter at runtime.
T = TypeVar('T')
class Box(BaseModel, Generic[T]):
value: T
# This works even though we said Box[int]!
box: Box[int] = Box(value="not an int") # No validation error!
Why: Generic type parameters are hints for static analysis. Pydantic validates when you use a concrete type.
Solution: Use parameterized types for validation:
IntBox = Box[int]
int_box = IntBox(value="not an int") # Now raises ValidationError!
Pitfall 5: Computed Fields with Generics
Problem: Using @property instead of @computed_field.
# WRONG - property not included in model_dump()
class Response(BaseModel, Generic[T]):
items: list[T]
@property
def count(self) -> int:
return len(self.items)
response.model_dump() # count not included!
# CORRECT - use computed_field
from pydantic import computed_field
class Response(BaseModel, Generic[T]):
items: list[T]
@computed_field
@property
def count(self) -> int:
return len(self.items)
response.model_dump() # {"items": [...], "count": 5}
Pitfall 6: Circular Type References
Problem: Generic models that reference each other.
# WRONG - NameError: Container not defined
T = TypeVar('T')
class Item(BaseModel, Generic[T]):
container: Container[T] # Container not defined yet!
class Container(BaseModel, Generic[T]):
items: list[Item[T]]
# CORRECT - use forward references
class Item(BaseModel, Generic[T]):
container: "Container[T]" # String forward reference
class Container(BaseModel, Generic[T]):
items: list["Item[T]"]
# Rebuild models after both are defined
Item.model_rebuild()
Container.model_rebuild()
Debugging Tips
- Check type at runtime:
print(type(response)) print(response.__class__.__bases__) print(getattr(response, '__pydantic_generic_metadata__', None)) - Inspect generic parameters:
from typing import get_args, get_origin alias = APIResponse[User] print(get_origin(alias)) # <class 'APIResponse'> print(get_args(alias)) # (<class 'User'>,) - View generated schema:
print(APIResponse[User].model_json_schema())
Extensions and Challenges
Extension 1: Async Result Type
Create an async-aware Result type that can hold coroutines:
from typing import Awaitable, Callable
class AsyncResult(Result[T, E]):
"""Result type for async operations."""
async def map_async(
self,
fn: Callable[[T], Awaitable[U]]
) -> "AsyncResult[U, E]":
if self.is_ok():
new_value = await fn(self.value)
return AsyncResult[U, E].ok(new_value)
return AsyncResult[U, E].err(self.error)
@classmethod
async def from_coroutine(
cls,
coro: Awaitable[T]
) -> "AsyncResult[T, Exception]":
"""Wrap a coroutine in a Result."""
try:
value = await coro
return cls.ok(value)
except Exception as e:
return cls.err(e)
Extension 2: Cursor-Based Pagination
Implement cursor pagination for large datasets:
class CursorPage(BaseModel, Generic[T]):
"""Cursor-based pagination for infinite scroll."""
items: list[T]
next_cursor: str | None = None
prev_cursor: str | None = None
has_more: bool = False
@classmethod
def create(
cls,
items: list[T],
cursor_field: str = "id",
limit: int = 10
) -> "CursorPage[T]":
"""Create page with cursor based on field."""
if not items:
return cls(items=[], has_more=False)
# Encode last item's cursor field as next cursor
last_item = items[-1]
next_cursor = encode_cursor(getattr(last_item, cursor_field))
return cls(
items=items,
next_cursor=next_cursor if len(items) == limit else None,
has_more=len(items) == limit
)
Extension 3: Type-Safe Factory Pattern
Create a factory that produces correctly typed generic models:
class ModelFactory(Generic[T]):
"""Factory for creating instances of generic models."""
def __init__(self, model_type: Type[T]):
self.model_type = model_type
def create_response(self, data: T) -> APIResponse[T]:
return APIResponse[T].ok(data)
def create_page(self, items: list[T], **kwargs) -> PaginatedResponse[T]:
return PaginatedResponse[T](items=items, **kwargs)
def create_result(self, value: T) -> Result[T, Exception]:
return Result[T, Exception].ok(value)
# Usage
user_factory = ModelFactory(User)
response = user_factory.create_response(user) # APIResponse[User]
Extension 4: Validation Context
Add context to generic models for conditional validation:
from pydantic import ValidationInfo
class ContextualResponse(BaseModel, Generic[T]):
"""Response with context-aware validation."""
data: T
context: dict = Field(default_factory=dict)
@model_validator(mode='after')
def validate_with_context(self, info: ValidationInfo):
"""Apply context-specific validation."""
if info.context and info.context.get('strict'):
# Additional validation in strict mode
if self.data is None:
raise ValueError("Data required in strict mode")
return self
Extension 5: Streaming Response Support
Create generic models for streaming responses:
from typing import AsyncIterator
class StreamingResponse(BaseModel, Generic[T]):
"""Wrapper for streaming responses."""
started_at: datetime
total_expected: int | None = None
class Config:
arbitrary_types_allowed = True
async def stream(
self,
source: AsyncIterator[T]
) -> AsyncIterator[StreamChunk[T]]:
"""Stream items with metadata."""
count = 0
async for item in source:
count += 1
yield StreamChunk(
item=item,
sequence=count,
is_last=False
)
yield StreamChunk(item=None, sequence=count, is_last=True)
class StreamChunk(BaseModel, Generic[T]):
item: T | None
sequence: int
is_last: bool
Real-World Connections
Where Generic Models Appear
- FastAPI Response Models
- Most FastAPI applications use generic response wrappers
- Standard patterns:
APIResponse[T],PagedResult[T]
- GraphQL Resolvers
- Relay-style connections use generics:
Connection[T],Edge[T] - Strawberry GraphQL supports generic types
- Relay-style connections use generics:
- Database Libraries
- SQLAlchemy 2.0 uses generics:
Mapped[T] - SQLModel combines Pydantic generics with ORM
- SQLAlchemy 2.0 uses generics:
- Functional Programming Libraries
returnslibrary:Result,Maybe,IOtypes- Railway-oriented programming patterns
- Message Queues
- Kafka/RabbitMQ wrappers:
Message[T],Event[T] - Type-safe message serialization
- Kafka/RabbitMQ wrappers:
Industry Examples
- Stripe API: Uses envelope patterns (
data,has_more,object) - GitHub GraphQL: Uses connection/edge pattern for pagination
- AWS SDK: Uses Result-like types for operation outcomes
- OpenAI API: Structured output uses Pydantic generics
Production Considerations
- OpenAPI Schema Generation
- Generic types generate unique schema names (e.g.,
APIResponse_User_) - Consider using
schema_extrafor cleaner names
- Generic types generate unique schema names (e.g.,
- Performance
- Generic model creation has minimal overhead
- Use
model_construct()for trusted data to skip validation
- Caching
- Pydantic caches parameterized types
Wrapper[User]creates type once, reuses thereafter
- Documentation
- Generic types appear correctly in Swagger/ReDoc
- Add docstrings for better auto-documentation
Self-Assessment Checklist
Core Understanding
- Can I explain what a TypeVar is and when to use bounds?
- Can I describe the difference between covariance and contravariance?
- Can I explain how Pydantic handles generic types at runtime?
- Can I use
get_originandget_argsfor type introspection?
Implementation Skills
- Can I create a generic BaseModel subclass?
- Can I implement factory methods that preserve type information?
- Can I compose multiple generic types (nested generics)?
- Can I add computed properties to generic models?
Integration Knowledge
- Can I use generic models as FastAPI response types?
- Can I verify OpenAPI schema generation is correct?
- Can I write tests for generic model behavior?
- Can I debug type-related issues in generic code?
Mastery Indicators
- Library works correctly with any Pydantic model type
- FastAPI generates correct OpenAPI schemas
- All edge cases are tested (empty lists, null values, etc.)
- Code is well-documented and type-checker friendly
Resources
Documentation
Books
- “Fluent Python” by Luciano Ramalho - Chapter 15 on Type Hints and Generics
- “Robust Python” by Patrick Viafore - Chapter 7 on Generics
Articles and Tutorials
- Real Python - Python Type Checking
- Mypy Generics Documentation
- PEP 484 - Type Hints
- PEP 585 - Type Hinting Generics
Related Libraries
- returns - Functional programming patterns with generics
- pydantic-generics - Official Pydantic examples
- strawberry-graphql - GraphQL library using Pydantic generics