response-shape-mismatch
Response Shape Mismatch
Discovery
Infer as much as possible from the codebase before asking. Then confirm:
- Where are responses consumed? — fetch/axios calls, React Query hooks, SWR, RTK Query, or tRPC?
- Are response types declared? — hand-written interfaces, generated from OpenAPI, inferred from tRPC, or
any/untyped? - Is the backend accessible? — can we read route handlers, Prisma schema, or serializers directly, or only the frontend?
- What's the failure mode? — known crash, silent wrong data, or proactive audit before it breaks?
Audit Strategy
Run all four checks. Each catches a distinct failure class.
| Check | What it catches |
|---|---|
| Structural diff | Fields frontend assumes that backend never sends |
| Nullability gap | Fields typed as T but backend returns T | null |
| Optional chaining audit | ?. masking real errors vs. ! causing crashes |
| Serialization delta | Shape in DB vs. shape after backend transforms it |
Check 1: Structural Diff (Assumed-but-Missing Fields)
The most common source of silent bugs — the frontend types a field that exists in the DB but the backend never includes in the response.
How to find it:
Trace from the fetch call backward to the serializer/controller:
// Frontend assumes:
type OrderResponse = { id: string; user: { name: string; email: string }; items: Item[] }
// Backend actually sends (Express example):
res.json({ id: order.id, userId: order.userId, items: order.items })
// ↑ "user" is never populated — frontend gets undefined, not an error
Pattern to look for: Any nested object type on the frontend that maps to a foreign key (userId, authorId) on the backend model. The backend often returns the ID, not the hydrated relation.
Fix:
// Option A: explicitly select and include the relation in the query
const order = await prisma.order.findUnique({
where: { id },
include: { user: { select: { name: true, email: true } } }
})
// Option B: flatten the frontend type to match what's actually sent
type OrderResponse = { id: string; userId: string; items: Item[] }
Check 2: Nullability Gap
TypeScript's strictest flaw for API work: a field typed as string is trusted at compile time, but the backend may return null — and TS never catches it because the cast happened at the fetch boundary.
Where it hides:
// The cast at the fetch boundary silently strips null from the type:
const data = await res.json() as UserResponse
// TS now believes data.bio is string — but Prisma's bio is String? (nullable)
How to find it systematically:
Compare the frontend interface against the Prisma schema (or DB schema) field by field:
Prisma: bio String? → nullable
Frontend: bio string → assumed non-null ← GAP
If there's no Prisma schema, look at the backend serializer/controller for any field that conditionally exists:
// Backend
const response = {
...user,
avatar: user.avatarUrl ?? null, // ← null possible, check frontend
lastLogin: user.sessions[0]?.createdAt // ← undefined possible
}
Fix — validate at the boundary, not after:
import { z } from "zod"
const UserSchema = z.object({
id: z.string(),
bio: z.string().nullable(), // matches backend reality
avatar: z.string().url().nullable(),
})
// In the fetch hook:
const data = UserSchema.parse(await res.json())
// Now TypeScript knows bio is string | null — and enforces it everywhere
Check 3: Optional Chaining Audit
?. is not always safe — it suppresses both "field doesn't exist" (real bug) and "field is intentionally optional" (correct). Audit which is which.
Dangerous pattern — ?. hiding a structural mismatch:
// If user.address is always present per the API contract,
// this silently renders nothing instead of throwing:
<p>{user.address?.city}</p>
// A backend change that stops sending `address` becomes invisible
Dangerous pattern — non-null assertion on API data:
// This crashes if backend ever returns null/undefined:
const name = user.profile!.displayName
How to audit:
Search for these patterns on any variable that originated from an API response:
?.on fields typed as required — signals an unacknowledged mismatch!on fields fromjson()casts — guaranteed future crash|| ""/?? ""on fields used in logic (not just display) — often masks wrong type
Fix — use discriminated unions for partial responses:
// Instead of optional chaining on ambiguous shape:
type ApiUser =
| { status: "complete"; profile: { displayName: string; avatar: string } }
| { status: "pending"; profile: null }
// Now TS forces you to check status before accessing profile
Check 4: Serialization Delta
The DB shape and the API response shape are often different. Middleware, serializers, toJSON() overrides, and ORM transforms all mutate data between DB and wire.
Common deltas to check:
| Transform | What changes |
|---|---|
JSON.stringify on Date |
Date object → ISO string — frontend types it as string but often forgets to parse back |
Prisma select |
Only selected fields exist — included relations absent unless explicitly selected |
class-transformer / NestJS @Exclude() |
Fields present in the class but stripped from response |
Express middleware (e.g. camelCase transform) |
snake_case DB fields renamed — types must match the transformed name |
Date pitfall (extremely common):
// Backend sends: { "createdAt": "2024-01-15T10:30:00.000Z" }
// Frontend type: createdAt: Date ← WRONG, it's a string after JSON parse
// Correct:
type Post = { createdAt: string }
// Parse explicitly where needed:
const date = new Date(post.createdAt)
NestJS @Exclude() pitfall:
@Entity()
class User {
@Expose() id: string
@Expose() email: string
@Exclude() passwordHash: string // stripped from response
}
// Frontend must not type passwordHash — it will always be undefined
Hardening: Validate at the Fetch Boundary
The only permanent fix. All other checks are audits — this prevents regressions.
With Zod (framework-agnostic):
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
const json = await res.json()
return UserSchema.parse(json) // throws with a clear error if shape is wrong
}
With React Query — centralize in queryFn:
const { data } = useQuery({
queryKey: ["user", id],
queryFn: async () => {
const res = await fetch(`/api/users/${id}`)
return UserSchema.parse(await res.json())
}
})
// data is now User (not User | undefined in shape), null handling is explicit
Safe parse for non-throwing validation (dev logging):
const result = UserSchema.safeParse(json)
if (!result.success) {
console.error("API shape mismatch:", result.error.flatten())
// log to Sentry, show fallback UI, etc.
}
Output Checklist
- All nested object types verified against backend serializer (not just DB schema)
- Nullable DB fields matched to
T | nullin frontend types -
?.on required fields replaced with explicit null checks or discriminated unions -
!assertions on API data eliminated -
Datefields typed asstringon the wire, parsed explicitly where needed -
@Exclude()/select-omitted fields removed from frontend types - Zod (or equivalent) parse added at fetch boundary for at-risk endpoints
More from blunotech-dev/agents
anti-purple-ui
Enforce a strict monochrome UI with a single high-contrast accent color, removing generic tech gradients and “AI-style” palettes. Use when the user wants minimal, anti-AI, or non-generic aesthetics, or says the UI looks too techy or generic.
9harmonize-whitespace
Align all spacing (padding, margins, gaps) to a consistent 4pt/8pt grid. Use when spacing feels off, inconsistent, cramped, or unbalanced, or when the user asks for a spacing scale or alignment fix.
9typographic-hierarchy
Improve typography by adjusting font sizes, weights, spacing, and contrast to create clear visual hierarchy and readability. Use when text feels flat, unstructured, or when the user asks to refine headings, type scale, or overall readability.
6micro-interaction-adder
Add polished CSS micro-interactions like hover effects, transitions, and feedback states to improve UI feel. Use when the user asks for animations, better UX, or when the interface feels static, plain, or unresponsive.
4consistent-border-radius
Normalizes rounded corners across a file so buttons, inputs, cards, modals, badges, and all UI elements share the exact same curvature. Use this skill whenever the user mentions inconsistent border radii, wants to unify rounded corners, asks to make UI elements look more cohesive, or says things like "make the corners match", "fix the rounding", "unify border radius", "standardize my rounded corners", or "buttons and cards don't match". Also trigger when the user pastes a CSS/HTML/JSX/TSX file and asks for a design consistency pass, border radius is one of the first things to normalize.
4component-split
Analyze a component and determine when and how to split it based on size, responsibility, and reuse signals, producing a refactored structure with clear boundaries. Use when users share large, mixed-concern, or hard-to-test components, or ask about splitting, refactoring, or improving component architecture.
3