One envelope, many payloads - the pattern behind every paginated response you have ever consumed.
Overview
The duplication
Imagine an API that wraps every response in an envelope. The envelope has a status, a message and a payload. The payload changes from endpoint to endpoint. You could write a separate envelope model for each one, but they would all repeat the same fields.
A generic model solves that. You define the envelope once, with a type parameter standing in for the payload. When you use it, you fill in the parameter, and you get a model specialised for that payload. The shared structure is written once, and the varying part is chosen at the point of use.
This article starts with the duplication and then shows the generic version. It makes clear that the result is still fully validated, so the payload is checked against the type you chose. Each filled-in version is a real class of its own, which you can inspect and reuse. The last section covers bounded parameters, which limit what types are allowed. Generic models fit best for the wrappers and containers that appear all over an API, such as pages of results.
If you have ever copied a model and changed one field, this article is for you. Wrappers for pages, results, responses and events all share this pattern. The typing is not complicated, and you need no advanced knowledge of Python generics to follow it. You will see how a single definition produces many classes. You will also see how Pydantic checks the filled-in type, so a mistake in the payload is caught exactly as it would be in a hand-written model. The article ends with bounds, which let you say that a parameter must be a model, or a number, instead of anything at all. That keeps the shared structure honest and gives your editor better hints. Small as the idea is, it removes a surprising amount of repeated code from a typical API project.
each API with more than one list endpoint grows the same class a few times:
class ModulePage(BaseModel):
items: List[Module]
total: int
page: int
class LessonPage(BaseModel):
items: List[Lesson]
total: int
page: int
same apart from one type. A third resource means a third copy. A change to pagination means editing all of them. Or, more realistically, editing most of them.
Worth knowing
Inherit from both BaseModel and Generic[T], then parameterise with Page[Module].
The parameter is fully validated — a generic gives you reuse, not an escape from checking.
Each parameterisation is a distinct class, built and cached the first time you use it.
TypeVar(bound=X) restricts the parameter, which lets methods on the generic safely use what X guarantees.
The generated schema names each parameterisation (Page[Module]), so clients get real named types rather than an envelope of anything.
Parameterise at module level rather than inside a hot function — building the class the first time costs something.
Generic Models: One Envelope, Many Payloads
The pattern behind each paginated response, written once instead of once per resource.
The duplication a generic removes
Two envelopes with identical structure and one differing field. A third resource means a third copy.
example_01.pyPydantic
from typing import List
from pydantic import BaseModel
class Module(BaseModel):
title: str
class Lesson(BaseModel):
name: str
class ModulePage(BaseModel):
items: List[Module]
total: int
page: int
class LessonPage(BaseModel): # the same class again
items: List[Lesson]
total: int
page: int
print(ModulePage(items=[{"title": "Vectors"}], total=1, page=1))
print(LessonPage(items=[{"name": "Direction"}], total=1, page=1))
Output
One envelope, parameterised
Inherit from Generic[T] and the payload type becomes an argument. Validation applies to whatever you fill it with.
example_02.pyPydantic
from typing import Generic, List, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int
page: int = 1
class Module(BaseModel):
title: str
minutes: int
class Lesson(BaseModel):
name: str
mp = Page[Module](items=[{"title": "Vectors", "minutes": "8"}], total=1)
lp = Page[Lesson](items=[{"name": "Direction"}], total=1)
print("modules:", mp)
print("lessons:", lp)
print()
print("inner type:", type(mp.items[0]).__name__, "| coerced:", mp.items[0].minutes)
Output
The parameter is validated
A generic is not a free pass. The payload is checked against whatever type you supplied, with the usual errors and paths.
example_03.pyPydantic
from typing import Generic, List, TypeVar
from pydantic import BaseModel, ValidationError
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int
class Module(BaseModel):
title: str
minutes: int
try:
Page[Module](items=[{"title": "Vectors", "minutes": "soon"}], total=1)
except ValidationError as e:
err = e.errors()[0]
print("path:", err["loc"])
print("said:", err["msg"])
# And a plain type works just as well as a model:
print()
print("ints:", Page[int](items=["1", "2", "3"], total=3))
Output
Bounded and constrained parameters
TypeVar(bound=...) restricts what the parameter may be, which keeps a generic honest about what it can hold.
example_04.pyPydantic
from typing import Generic, List, TypeVar
from pydantic import BaseModel
class Resource(BaseModel):
id: int
TResource = TypeVar("TResource", bound=Resource)
class Page(BaseModel, Generic[TResource]):
items: List[TResource]
def ids(self):
return [item.id for item in self.items] # safe: bound guarantees .id
class Module(Resource):
title: str
p = Page[Module](items=[{"id": 1, "title": "Vectors"},
{"id": 2, "title": "Norms"}])
print(p)
print("ids:", p.ids())
Output
Several parameters
A generic can take more than one, which is how a result-or-error envelope gets written once.
example_05.pyPydantic
from typing import Generic, Optional, TypeVar
from pydantic import BaseModel
D = TypeVar("D")
E = TypeVar("E")
class Result(BaseModel, Generic[D, E]):
ok: bool
data: Optional[D] = None
error: Optional[E] = None
class Module(BaseModel):
title: str
class Problem(BaseModel):
code: str
detail: str
good = Result[Module, Problem](ok=True, data={"title": "Vectors"})
bad = Result[Module, Problem](ok=False,
error={"code": "not_found", "detail": "no such module"})
print("ok :", good)
print("bad :", bad)
Output
More than one is allowed, and the result-or-error envelope is the common case:
Written once, used as Result[Module, Problem] all over. This is a pattern many codebases reinvent per endpoint.
Keep the count low. Two params is comfortable, three is a stretch. Beyond that the type is often trying to be a few types at once.
What the schema does with it
Each parameterisation becomes its own definition, so a generated client gets a real named type rather than an untyped envelope.
example_06.pyPydantic
import json
from typing import Generic, List, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int
class Module(BaseModel):
title: str
schema = Page[Module].model_json_schema()
print("title :", schema["title"])
print("props :", list(schema["properties"]))
print("$defs :", list(schema.get("$defs", {})))
print()
print("items :", json.dumps(schema["properties"]["items"]))
Output
The generic version
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int
page: int = 1
Inherit from BaseModel and plain[T], use T where the varying type goes. Parameterise at the point of use:
Page[Module](items=[...], total=1)
One meaning. Adding a field to the envelope reaches each resource at once. There's no possibility of the copies disagreeing because there are no copies.
It is still validated
A common assumption is that a plain loosens things. It doesn't.
Page[Module] validates items as a list of Module, with each rule Module carries. Coercion works, constraints run, errors have the usual paths. ("items", 0, "minutes") names the first item's field just as it would in a non-plain model.
Page[int] validates a list of ints, with the same coercion rules as anywhere else. The param can be any type Pydantic understands, not only models.
Each parameterisation is a real class
Page[Module] is a distinct class, created the first time you write it and cached later.
That has two results worth knowing.
It has a name, Page[Module]. This Appears in the schema and in error messages. A consumer sees a exact type rather than a vague envelope.
And building it costs a thing. The cost is small and paid once per parameterisation. It means Page[Module] inside a hot function is doing a lookup that a module-level alias would avoid:
ModulePage = Page[Module]
That alias is also better style: it names the type once and each use site is shorter.
Bounded parameters
An unbounded TypeVar can be anything. A plain can't assume anything about it. A bound fixes that:
TResource = TypeVar("TResource", bound=Resource)
class Page(BaseModel, Generic[TResource]):
items: List[TResource]
def ids(self):
return [item.id for item in self.items]
Because the param must be a Resource, each item is sure to have id. The method is safe. Without the bound, mypy would object and the method would be a runtime gamble.
Bounds are also docs. plain[TResource] with a bound says what kind of thing this envelope is for. A bare T says nothing.
Schemas and clients
Each parameterisation makes its own schema, titled with the parameterised name.
That is the hands-on payoff for consumers. A made TypeScript client gets PageModule and PageLesson as distinct types, each with right typed items. Without type vars you'd have written those classes by hand and got the same result at more cost. With a hand-rolled items: List[Any] envelope you'd get a client that types items as any and helps no one.
FastAPI handles plain response models right. response_model=Page[Module] — and docs it right.
Inheritance and generics together
A plain model can be subclassed, and a subclass can fix the param:
class ModulePage(Page[Module]):
facets: Dict[str, int] = {}
That gives you the shared envelope plus a thing exact to one resource. It's a good pattern when one endpoint truly needs an extra field. A bad one if each subclass adds a thing. At that point the envelope isn't really shared.
When not to reach for one
Two use sites. Two near-same classes are easier to read than a plain. The pattern earns its keep at three or four. The cost of waiting is small.
The classes differ in more than one type. A plain with a param and three overridden fields isn't a shared shape.
A union would say it better. If the payload is one of a fixed small set rather than any, a discriminated union shows that just and a plain doesn't.
The envelope has no structure.plain[T] wrapping a single field of type T is a box. TypeAdapter validates the payload right without one.
Generics and validation cost
Parameterising isn't free, and it's worth knowing where the cost falls.
Page[Module] builds a class the first time it's written. Resolving the type var, making a schema, caching the result. each later use of the same parameterisation reuses it.
So the cost is per distinct parameterisation, paid once. It's the same cost a hand-written ModulePage would have paid at import. check itself is same. The schema the plain makes is the schema the hand-written class would have made.
The mistake, as with TypeAdapter, is doing the parameterisation one spot repetitive. Page[Module] inside a function called per request performs a cached lookup each time. A module-level alias performs it once. The gap is small and free to avoid.
Generics with FastAPI
response_model=Page[Module] works right, and the docs names the type right. PageModule in the made schema, with items typed as an array of Module.
That is worth doing rather than falling back to an untyped envelope. A response model of Page[Module] gives each consumer a real type. A model with items: List[Any] gives them nothing. They'll write the type by hand on their side and get it wrong when yours changes.
The same applies to a Result[Data, Error] envelope. If your API wraps each response, making that wrapper plain is the gap between a client library that knows what each endpoint returns and one that unwraps any.
Generic Models: summary
class Page(BaseModel, plain[T]), used as Page[Module]. The param is fully checked with its own rules. Each parameterisation is a real, named class that appears right in the schema and in made clients.
Bound the type var when the plain needs to lean on what the param gives. Alias parameterisations at module level. Keep the param count to one or two.
And reach for it at the third copy of an envelope, not the second. Two similar classes are easier to read than a plain. The pattern earns its keep as soon as there are more.
Mistakes people make with Generic Models
Reaching for one at the second copy. Two similar classes read better than a plain. The pattern earns its keep around the third or fourth, and waiting costs almost nothing.
Parameterising in a hot path.Page[Module] performs a cached class lookup each time it's checked. A module-level alias does it once and reads better at each use site.
Leaving the type var unbounded when methods need it. A method calling item.id on a bare T is a runtime gamble that mypy will object to. TypeVar(bound=Resource) makes the promise real and docs what the envelope is for.
Too many params. Two is comfortable. Three is a stretch. Beyond that the type is trying to be a few types. Split classes will be clearer.
Wrapping a single field.plain[T] around a model with one field of type T is a box. TypeAdapter validates the payload right and makes a schema that shows what the data really is.
given a plain is looser. It isn't. Page[Module] validates its items with each rule Module carries. The error paths are same to a hand-written envelope's.
**Losing response_model=Page[Module].** Falling back to an untyped envelope in FastAPI means each consumer gets any and writes the type by hand on their side. Where it will be wrong the first time yours changes.
Reading a generic model
One hands-on note for anyone keeping these.
A plain reads worse than the classes it replaces. That is the trade. class ModulePage(BaseModel) with three concrete fields can be understood at a glance. class Page(BaseModel, plain[T]) needs the reader to hold a type var in their head and then find the parameterisations to know what it's ever filled with.
Two things make that cost small. Name the type var usefully. TResource says more than T once there's more than one. And alias the parameterisations at module level. A reader searching for "what does the modules endpoint return" finds ModulePage = Page[Module] rather than an formula buried in a decorator.
Neither costs anything, and both turn a plain from a thing clever into a thing plain.
A worked shape
Most APIs end up with two plain envelopes and nothing else.
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: List[T]
total: int
page: int = 1
size: int = 20
class Result(BaseModel, Generic[T]):
ok: bool
data: Optional[T] = None
error: Optional[Problem] = None
ModulePage = Page[Module]
LessonPage = Page[Lesson]
Two meanings and a handful of aliases replace one envelope class per resource. This In a system of fifteen resources is fifteen classes all saying the same thing.
The aliases matter as much as the type vars. They give each parameterisation a name a reader can search for, keep the parameterisation out of hot paths. Make endpoint signatures short: response_model=ModulePage rather than a bracketed formula in a decorator.
Adding a field to pagination. A cursor, a has_more flag — is then one edit that reaches each list endpoint at once, right, including in the docs and in each made client. That is the property worth having. The reason the pattern lives contact with a real code.
Generics and static checking
One gain that doesn't show up at runtime at all.
Mypy and Pyright understand plain[T]. So Page[Module] is a type they can reason about. page.items[0].title is checked. page.items[0].name is flagged before the code runs.
A hand-written ModulePage gives the same static gain. This isn't an arg for type vars over concrete classes. It's an arg against the shortcut people reach for when they tire of writing envelopes:
class Page(BaseModel):
items: List[Any]
That validates nothing about the payload, tells mypy nothing. Makes a schema in which items is an array of anything. each consumer. Your own code, a static checker, a made client — is worse off.
The choice isn't really "plain or concrete". It's "typed or untyped". The plain is what makes the typed option cheap enough that no one reaches for the untyped one.
Questions people ask about Generic Models
What is a generic model? A model parameterised by a type, declared with class Page(BaseModel, Generic[T]). You then write Page[Course] to get a version whose items are validated as courses.
Do I need Generic[T] in Pydantic v2? Not on Python 3.12 and later with a recent Pydantic, where class Page[T](BaseModel) works. On older versions inherit from Generic[T].
Is Page[Course] a separate class? Yes. Pydantic creates and caches a real subclass for each parameterisation, so Page[Course] and Page[Track] validate independently.
How do I restrict what T can be? Give the type variable a bound, such as T = TypeVar("T", bound=BaseModel). Anything else is rejected by type checkers.
What happens if I use Page without a parameter?T is treated as Any (or its bound), so items are not validated beyond that. Always use Page[Course] for real validation.
Do generics work with FastAPI? Yes. A parameterised model such as Page[Course] works as a response_model and appears in the OpenAPI schema with a name built from the parameter.
Why envelopes end up generic
It's worth noticing why this pattern appears in nearly each API of any size. It isn't really about type vars.
An API with fifteen list endpoints has fifteen responses that differ in just one place. That is a shape. Shapes want names. Writing fifteen classes to express one shape is the kind of duplication that looks harmless in a small code and turns a maintenance surface in a large one. Not because typing it's hard. Because changing it later means finding all fifteen.
The plain is simply the language feature that lets the shape have a name. Page is the concept. Page[Module] is that concept applied. Once written, adding a field to pagination is a single edit that can't miss a case. Each consumer sees the change always.
That is the same arg as extracting a nested model, or naming a constrained type, or putting config on a shared base. Each is a other method for the same principle. State a choice once, in a place that has a name. Let all that needs it refer to that rather than to a copy.
Check yourself
0 of 4
Answer without scrolling back up.
Does `Page[Module]` validate its `items`?
A generic gives reuse, not an escape from checking. Coercion, constraints and error paths all behave exactly as in a non-generic model.
Why alias `ModulePage = Page[Module]` at module level?
`Page[Module]` creates and caches a distinct class. Naming it once is both clearer at every use site and avoids doing the lookup in a hot path.
What does `TypeVar("T", bound=Resource)` let you do?
The bound means every parameterisation is a Resource, so a method can safely use `item.id`. It also documents what the envelope is for.
When is a generic the wrong tool?
Two near-identical classes read better than a generic; the pattern earns its keep around the third. And a fixed small set of payloads is a discriminated union, which says more.
Cheat sheet
Generic Models
Build one generic model for paginated responses, wrapped results or envelopes, then reuse it across every resource type.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.