FastAPI 422 or useParams Undefined? 4 Contract Mismatches
Three classic scenes in frontend-backend integration: a POST returns 422; a route page's parameter stays undefined forever; an SSE request sits at 200 while the UI silently shows nothing. Different symptoms, one family of root causes β the two sides disagreeing about the interface contract.
Encountered this while building an AI Agent SaaS platform for a client, frontend and backend developed in parallel β we hit all four, and here is the locating method and the prevention discipline.
TL;DRβ
Contract mismatches come in four shapes, producing three kinds of symptoms:
| Pitfall | Mismatch | Symptom |
|---|---|---|
| Field name | frontend sends name, backend wants label | 422 |
| Field type | frontend sends objects, backend expects List[str] | 422 |
| Route param | route defines :id, component reads agentId | undefined, feature silently dead |
| Event field | backend pushes {"content": ...}, frontend checks token | 200, UI shows nothing |
Prevention is a single discipline: align on the schema before writing code β field names, types, route param names, event data shapes, all sourced from the backend schema (or an interface doc both sides confirmed).
Pitfall 1: Field Name Mismatch β 422β
The most basic one. The frontend TypeScript interface declares name + key for creating an API key; the backend Pydantic schema expects label + api_key:
// what the frontend sends
interface CreateApiKeyInput {
name: string
key: string
}
# what the backend declares
class ApiKeyCreate(BaseModel):
label: str
api_key: str
POST /api/api-keys β 422 Unprocessable Entity
Locate it from the 422 response body β don't guess. FastAPI's 422 carries a detail array spelling out every missing field and why:
[
{ "loc": ["body", "label"], "msg": "Field required", "type": "missing" },
{ "loc": ["body", "api_key"], "msg": "Field required", "type": "missing" }
]
Field required = the field is not in your request body at all = a field-name mismatch. Expand the response in the Network tab and it takes ten seconds.
Pitfall 2: Field Type Mismatch β 422β
Names can line up and types can still betray you. The backend declares mcp_tools as a list of strings; the frontend sends an array of objects (each with tool_id and token_id β the business genuinely needs OAuth token binding):
// what the frontend sends
{ mcp_tools: [{ tool_id: "t1", token_id: "k1" }] }
# what the backend expects
mcp_tools: List[str]
PATCH /api/agents/{id} β 422 Unprocessable Entity
This time detail says Input should be a valid string β a type mismatch. The fix is making the backend schema accept both shapes (the frontend's structure has a real business reason to exist; don't amputate it):
from typing import List, Union
class McpToolConfig(BaseModel):
tool_id: str
token_id: str | None = None
mcp_tools: List[Union[str, McpToolConfig]]
Union lets the schema accept both a plain string (tool reference only) and an object (with binding config); Pydantic tries them in order. The pattern generalizes to any "API evolves, frontend moves first" situation: the schema accommodates the real business shape, not the other way around.
Pitfall 3: useParams Name Mismatch β undefinedβ
Route definition and component each written from memory, one word apart:
// route definition
<Route path="/agents/:id/memory" element={<MemoryPage />} />
// component
const { agentId } = useParams<{ agentId: string }>()
// agentId is undefined forever β the route param is named id, not agentId
No error, status codes fine, feature silently dead β agentId is undefined, so downstream API calls carry undefined or never fire.
Two fixes:
// 1. match the route definition
const { id } = useParams<{ id: string }>()
// 2. or rename while destructuring
const { id: agentId } = useParams<{ id: string }>()
What makes this one sneaky is the generic: useParams<{ agentId: string }> β TypeScript does not verify that the keys in your generic actually exist in the route definition. The type annotation hands you false confidence.
Pitfall 4: Event Field Mismatch β 200 but Silent Failureβ
In an SSE stream, the backend pushes {"content": "..."} while the frontend's event check looks for token:
// the frontend's event predicate
const isTokenEvent = (d: any) => 'token' in d // always false
// what the backend actually pushes
data: {"content": "hello"}
The Network tab shows 200 and the stream flowing; the UI shows nothing β the predicate never fires and events are silently dropped. This one is harder to catch than a 422: no error, just a feature that never happens.
Fix: align the predicate with the backend's actual data shape:
const isTokenEvent = (d: any) => 'content' in d && !('type' in d)
For SSE/WebSocket interfaces carrying many event kinds over one connection, prefer an explicit type field for dispatching; inferring event kinds from "which keys happen to exist" turns every contract change into silent breakage.
Prevention: One Disciplineβ
All four pitfalls share an origin: each side wrote its half of the interface from imagination. Prevention needs no tooling, one discipline β
Before writing any interface code, align on the schema: field names, types, route param names, event shapes β item by item. The backend Pydantic schema (or OpenAPI doc) is the single source of truth; the frontend TypeScript interfaces mirror it. Whoever changes the contract changes the schema first, both sides confirm, then code moves.
When symptoms appear, triage in order: 422 β expand the response detail and read loc; undefined β check the route's :param names; 200 but nothing happened β capture a real response and diff it field-by-field against the frontend predicate.
The server side of streaming interfaces has a companion pitfall (exceptions and leaked resources on client disconnect), covered in FastAPI SSE CancelledError on Client Disconnect? Catch and Re-raise in the Generator.
Watch out
Frontend TypeScript interfaces (interface XxxInput) are compile-time paper constraints β as any and third-party request wrappers can bypass them entirely. Types matching does not mean fields matching. The final arbiter of contract truth is always a comparison against the backend schema, not the absence of red squiggles.
FAQβ
Why does FastAPI return 422 Unprocessable Entity?β
In 9 out of 10 cases the request body does not match the Pydantic schema: a field name the backend does not expect (sending name when the schema wants label) or a field type mismatch (an array of objects where List[str] is declared). The 422 response body lists every offending field in its detail array β read it before guessing.
How do I debug which field caused a FastAPI 422?β
Open the response body in the browser Network tab or curl: FastAPI returns a detail array where each entry has loc (the field path), msg (Field required / Input should be a valid string), and type. loc pointing at a field you did send under a different name is the classic field-name mismatch.
Why does useParams return undefined in React Router?β
The keys of useParams come from the route definition: a route of /agents/:id exposes only id. Destructuring a name that does not appear in any :param β like agentId β yields undefined silently, no error thrown. Rename during destructuring instead: const { id: agentId } = useParams().
CCLEE
Independent developer, 24 years in e-commerce, focused on grounding AI in real business scenarios.
Work with me