featured image

Stop Copying Response Boilerplate Across Every Endpoint: Build a Typed Generic Envelope in Pydantic v2

Learn how to use Python's `Generic[T]` protocol with Pydantic v2's `BaseModel` to create a single, typed response envelope that propagates concrete type information through to OpenAPI schema generation, provides consistent operational metadata across all endpoints without repetition, and enforces field-level constraints on both inbound and outbound data.

Published

Fri Oct 10 2025

Technologies Used

Python Pydantic FastAPI
Beginner 6 minutes

Purpose

The Problem

You have ten API endpoints. Every one of them needs to return a request_id, a success flag, an inference timestamp, a disclaimer, and a data source list — in addition to the actual prediction payload. The naive approach is to copy-paste that boilerplate into every response schema. Three months later, a field name changes and you are hunting down ten files. A more seasoned instinct is to create a BaseResponse class and inherit from it — but inheritance does not compose well when the prediction field’s type changes for every route. You end up with Any, losing all type safety and auto-generated OpenAPI documentation precision.

The real solution is a Generic response envelope: one schema definition that wraps any payload type while preserving strict typing end-to-end. VitalCheck uses exactly this pattern, defined in app/schemas/common.py, and it is the structural backbone of all fourteen API responses.

We will build a fully typed, auto-documented Pydantic v2 generic model without any use of Any, using Python’s TypeVar and Generic protocol — concepts that trip up many intermediate-level engineers.

What You Need Before You Start: Python Generics and Pydantic Fundamentals

Knowledge Base:

  • Familiarity with Python classes and type annotations
  • Basic understanding of what an API response body is
  • You have seen Pydantic’s BaseModel before, even if only briefly
  • Optional but helpful: awareness of Python’s typing module (TypeVar, Generic)

Environment (from pyproject.toml):

Python        >= 3.11, < 3.13
pydantic      >= 2.10.0
fastapi       >= 0.115.0

Pydantic v2 is a complete rewrite of v1 with a Rust-powered core. The Generic[T] support in v2 is first-class and directly influences OpenAPI schema generation — the two systems are aware of each other. This tutorial requires v2 specifically.

🔵 Deep Dive: Python’s Generic[T] is not magic. When you subclass Generic[T], Python’s type system registers T as a placeholder that is resolved at the point of use — for example, VitalCheckResponse[DiabetesPrediction]. Pydantic v2 reads this resolved type at model construction time (via __class_getitem__) and correctly generates the prediction field’s JSON schema from the concrete type, not from the abstract T.

The Matryoshka Model: Understanding the Envelope-within-Envelope Architecture

Think of the response structure as a Russian nesting doll. The outermost shell — VitalCheckResponse — is identical on every endpoint. It contains operational metadata (request tracking, timing, disclaimer). Inside it sits the prediction field, which holds a domain-specific payload that differs per endpoint. Inside that payload, shared sub-components like RiskScore may appear again.

This nesting gives you a single parsing contract for any client consuming the API: always unwrap the outer shell first, then deserialize the inner prediction based on the endpoint you called.

graph TD
    A["HTTP Response Body"] --> B["VitalCheckResponse[T]"]
    B --> C["success: bool"]
    B --> D["request_id: UUID string"]
    B --> E["model_version: string"]
    B --> F["prediction: T"]
    B --> G["metadata: ResponseMetadata"]
    F -->|"e.g. DiabetesPrediction"| H["risk: RiskScore"]
    F -->|"e.g. BrainTumorPrediction"| I["predicted_class: string"]
    G --> J["inference_ms: float"]
    G --> K["disclaimer: string"]
    G --> L["data_sources: list[str]"]
    H --> M["probability: float [0.0, 1.0]"]
    H --> N["risk_level: RiskLevel enum"]

Analogy: Think of VitalCheckResponse[T] as a standardized shipping box. The box always has the same label format, tracking number, and sender information on the outside. What is inside the box — the cargo — changes every time. Generics let us describe both the box and its contents in a single specification without losing information about what is inside.

Building the Shared Vocabulary: A Step-by-Step Walk Through common.py

We will walk through the file in four logical blocks, from the simplest building block up to the generic envelope itself.

Block 1 — The Risk Level Taxonomy (Lines 12–26)

Before any model can return a prediction, we need a controlled vocabulary for risk. An uncontrolled string like "kinda risky" is useless to a client. An enum with four members is a contract.

The str mixin on the enum is subtle but important: it makes RiskLevel.HIGH == "high" evaluate to True. Without it, you get RiskLevel.HIGH == "high"False, which breaks JSON comparison in client code and causes silent bugs in tests.

class RiskLevel(str, Enum):
    LOW      = "low"       # probability < 0.30
    MODERATE = "moderate"  # probability 0.30–0.60
    HIGH     = "high"      # probability 0.60–0.80
    CRITICAL = "critical"  # probability >= 0.80

The conversion function that maps a raw float to this enum lives directly below:

def probability_to_risk_level(prob: float) -> RiskLevel:
    if prob >= 0.80:
        return RiskLevel.CRITICAL
    elif prob >= 0.60:
        return RiskLevel.HIGH
    elif prob >= 0.30:
        return RiskLevel.MODERATE
    return RiskLevel.LOW

🔵 Deep Dive: This function is deliberately a pure function with no side effects. It takes a float, returns an enum value, touches no external state. This makes it trivially testable — assert probability_to_risk_level(0.75) == RiskLevel.HIGH — and reusable across all five risk endpoints without coupling.

Block 2 — The Shared Risk Score Sub-Model (Lines 29–32)

RiskScore is the most-reused sub-component in the codebase. Every classification endpoint embeds it. Defining it once in common.py means that when the OpenAPI spec is generated, Pydantic registers it as a single named schema component — $ref: '#/components/schemas/RiskScore' — rather than inlining it redundantly into every endpoint response body.

class RiskScore(BaseModel):
    # ge=0.0, le=1.0 are Pydantic field-level validators.
    # If a model returns 1.0001 due to floating point error, Pydantic raises
    # a ValidationError before the response ever leaves the server.
    probability: float = Field(ge=0.0, le=1.0, description="Predicted probability (0–1)")

    risk_level: RiskLevel = Field(description="Categorical risk tier")

    # Optional: not all endpoints have a reference population to compute percentiles.
    # Using 'float | None' with a default of None is idiomatic Python 3.10+ syntax.
    percentile: float | None = Field(default=None, description="Percentile vs. reference population")

Block 3 — The Operational Metadata Carrier (Lines 35–44)

ResponseMetadata separates domain data from operational data. The prediction tells you what the model thinks. The metadata tells you how long it took, where the data came from, and what legal disclaimer applies. Keeping these concerns in separate fields makes it easy for monitoring infrastructure to extract inference_ms without knowing anything about the domain.

class ResponseMetadata(BaseModel):
    inference_ms: float = Field(description="Wall-clock inference time in milliseconds")

    # The disclaimer has a default value — it ships on every response automatically.
    # An endpoint can override it, but they all opt-in to the correct default.
    disclaimer: str = Field(
        default=(
            "This API provides estimates for informational and research purposes only. "
            "It is not a medical device and must not be used for clinical diagnosis or "
            "treatment decisions. Always consult a qualified healthcare professional."
        )
    )

    # list[str] with a factory default — each endpoint populates this with
    # the specific datasets its model was trained on.
    data_sources: list[str] = Field(default_factory=list)

Block 4 — The Generic Envelope: Where It All Comes Together (Lines 47–52)

This is the payoff. Two imports enable the pattern: Generic and TypeVar from Python’s standard typing module. Pydantic v2’s BaseModel is aware of both.

from typing import Generic, TypeVar

# T is an unconstrained type variable. It will be substituted at point-of-use.
T = TypeVar("T")

# The key is the multiple inheritance: BaseModel gives us Pydantic behavior,
# Generic[T] registers T as a substitutable parameter.
class VitalCheckResponse(BaseModel, Generic[T]):
    success: bool = True

    # uuid.uuid4() generates a new UUID per instance, never per class.
    # The lambda wrapper is critical: without it, Field would call uuid4()
    # once at class definition time and every response would share the same ID.
    request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))

    model_version: str = "1.0.0"

    # T is resolved when you write VitalCheckResponse[DiabetesPrediction].
    # At that point, Pydantic knows prediction must be a DiabetesPrediction.
    prediction: T

    metadata: ResponseMetadata

And here is how a route handler uses it — the type parameter makes prediction concrete:

# In app/routers/risk.py — the [DiabetesPrediction] resolves T.
# FastAPI reads this annotation to generate the OpenAPI response schema.
@router.post("/diabetes", response_model=VitalCheckResponse[DiabetesPrediction])
async def predict_diabetes(req: DiabetesRequest, ...) -> VitalCheckResponse[DiabetesPrediction]:
    ...
    return VitalCheckResponse(
        prediction=DiabetesPrediction(risk=risk, ...),
        metadata=ResponseMetadata(inference_ms=elapsed, data_sources=DIABETES_SOURCES),
    )

Notice that success, request_id, and model_version are never set in the route handler. They have defaults. The handler only provides what is unique per request.

Why the Compiler Cares: Generics, OpenAPI, and What Goes Wrong Without Them

The OpenAPI Generation Story

When FastAPI builds the OpenAPI specification at startup, it inspects the response_model annotation on each route. For VitalCheckResponse[DiabetesPrediction], Pydantic v2 creates a parameterized model — essentially a dynamically generated subclass with prediction bound to DiabetesPrediction. The generated schema for /diabetes correctly shows the full nested structure of a diabetes prediction, including all its fields and their types.

If you used Any instead of T, the OpenAPI schema for prediction would be {} — a schema that matches any JSON value. Every endpoint’s documentation would say “prediction can be anything.” That is useless for API consumers and cannot be used to auto-generate client SDKs.

The default_factory Lambda — A Memory Safety Detail

# WRONG — uuid4() is called once at class definition time.
# Every single response shares the same request_id forever.
request_id: str = Field(default=str(uuid.uuid4()))

# CORRECT — the lambda is called once per model instantiation.
# Each response gets a fresh UUID.
request_id: str = Field(default_factory=lambda: str(uuid.uuid4()))

This is one of the most common Pydantic v1→v2 migration bugs. The default_factory parameter exists precisely because mutable or side-effecting defaults must be produced fresh on each call.

Big-O Perspective: VitalCheckResponse instantiation is O(1) with respect to payload size. Pydantic v2’s Rust core validates fields in a tight loop; the UUID generation is a single system call. The overall schema construction at startup is O(n) in the number of model fields, but this happens once — not per request.

When the Envelope Breaks: Validation Failures, Versioning Traps, and Type Erasure at Runtime

What happens if a model returns an out-of-range probability?

Pydantic validators on RiskScore.probability (the ge=0.0, le=1.0 constraints) fire on every response, not just on requests. If a buggy ML pipeline returns 1.0001, Pydantic raises a ValidationError inside the route handler, which the global exception handler in main.py catches and converts to a sanitized HTTP 500. The bad value never reaches the client in raw form.

🔴 Danger: Generic[T] type information is erased at runtime in Python. That means isinstance(response, VitalCheckResponse[DiabetesPrediction]) will raise a TypeError. You cannot use parameterized generics in isinstance checks. At runtime, the object is simply an instance of VitalCheckResponse. Reserve type parameter checks for static analysis tools (mypy, pyright) and IDE tooling only.

Concurrency: Is the shared model_version = "1.0.0" a race condition?

No. model_version is a class-level default on an immutable string. Python strings are immutable; there is no shared mutable state here. Each VitalCheckResponse instance gets its own copy of the string value during instantiation. No locks are needed.

The from __future__ import annotations Line

Every schema file starts with this import. It enables PEP 563 “postponed evaluation of annotations” — type hints are stored as strings rather than evaluated immediately at import time. This allows forward references (list[str] before str is imported, dict | None syntax on Python 3.9) to work without TYPE_CHECKING guards. It is a stylistic choice that improves import performance and enables cleaner syntax.

You Now Know How to Never Write Boilerplate Response Code Again

You have learned how to use Python’s Generic[T] protocol with Pydantic v2’s BaseModel to create a single, typed response envelope that:

  • Propagates concrete type information through to OpenAPI schema generation
  • Provides consistent operational metadata (request IDs, timing, disclaimers) across all endpoints without repetition
  • Uses Field(default_factory=...) correctly to produce per-instance unique values
  • Enforces field-level constraints on both inbound and outbound data

The core skill transfer is this: the Generic pattern is the correct abstraction whenever you have a stable “container” shape and a variable “contents” shape. You will encounter this in pagination wrappers, event envelopes, API gateways, and anywhere responses share structure but differ in payload. The mechanics are always the same: TypeVar, Generic[T], concrete parameterization at the point of use.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!