Validating Entities With Zod Before Caching
An API that starts returning null where a string used to be does not cause an error at the boundary — it causes one three components deep, on a render that has nothing to do with fetching, with a stack trace pointing at a .toUpperCase() call. And because the malformed object is now in the cache, every observer of that key hits the same failure until something evicts it. Validating at the boundary converts that into a handled error with a message naming the field. This page covers doing it without paying for it twice, under Schema-Driven Normalization. It builds on the normalization pipeline in Normalizing Entities With Normalizr and complements the boundary discipline in Separating the Cache Layer From the Transport Layer.
Prerequisites
- Zod 3.x, or any parser exposing a safe-parse result.
- Entity types currently declared as hand-written interfaces.
- An error reporter, so rejected payloads are visible rather than silent.
Step 1 — Let the Schema Define the Type
Declaring the interface separately from the schema means they can disagree. Inferring one from the other removes the possibility.
import { z } from 'zod';
export const todoSchema = z.object({
id: z.string().min(1),
title: z.string(),
done: z.boolean(),
// The API sends null for an unassigned todo; the cache stores null too.
assigneeId: z.string().nullable(),
// Coerce on the way in so components never see a string date.
updatedAt: z.string().datetime().transform((value) => new Date(value)),
// Unknown fields are stripped by default, which keeps the cached object
// exactly the shape the application declares it to be.
});
export type Todo = z.infer<typeof todoSchema>;
export const todoListSchema = z.array(todoSchema);
Cache Behavior Analysis: Zod strips unrecognised properties by default, so what lands in the cache is exactly the declared shape rather than whatever the API happened to include — which bounds the cached object’s size and stops a component reading a field the schema never promised. The transform runs during parsing, so updatedAt is a real Date by the time anything reads it; note that this makes the cached value non-JSON-serializable, which matters if the cache is persisted, as covered in Cache Versioning & Migration. Inferring the TypeScript type from the schema is what guarantees the compiler’s view and the runtime check cannot drift apart.
Step 2 — Parse Inside the Query Function
Validation must happen before the value reaches setQueryData or the resolved promise, because after that every observer reads it.
import { queryOptions } from '@tanstack/react-query';
class ResponseShapeError extends Error {
constructor(readonly issues: z.ZodIssue[], resource: string) {
super(`${resource} response did not match its schema: ${issues[0]?.path.join('.')} — ${issues[0]?.message}`);
this.name = 'ResponseShapeError';
}
}
export const todoQueries = {
list: (filters: TodoFilters = {}) =>
queryOptions({
queryKey: ['todos', 'list', filters],
queryFn: async ({ signal }) => {
const raw: unknown = await fetchTodos(filters, signal);
const parsed = todoListSchema.safeParse(raw);
if (!parsed.success) {
// Throwing here means the query enters error state and the cache
// keeps whatever valid data it already had.
reportShapeError('todos.list', parsed.error.issues);
throw new ResponseShapeError(parsed.error.issues, 'todos.list');
}
return parsed.data;
},
staleTime: 30_000,
// A shape error will not fix itself on retry.
retry: (count, error) => !(error instanceof ResponseShapeError) && count < 2,
}),
};
Cache Behavior Analysis: Throwing from queryFn moves the query to error status and leaves the previously cached data in place, so a screen that was already rendering keeps its last good values rather than blanking — a strictly better outcome than caching the malformed payload and failing during render. Disabling retries for shape errors is important: a schema mismatch is deterministic, so three retries with exponential backoff produce three identical failures and delay the error state by several seconds. Reporting before throwing means the issue reaches telemetry even if an error boundary swallows the exception.
Step 3 — Decide What One Bad Row Costs
For a detail query, one invalid object means the query failed. For a collection of two thousand, failing everything because of row 1,700 is usually the wrong trade.
export interface PartialParse<T> { rows: T[]; rejected: number; firstIssue?: string }
export function parseCollection<T>(schema: z.ZodType<T>, raw: unknown[]): PartialParse<T> {
const rows: T[] = [];
let rejected = 0;
let firstIssue: string | undefined;
for (const candidate of raw) {
const parsed = schema.safeParse(candidate);
if (parsed.success) {
rows.push(parsed.data);
} else {
rejected += 1;
firstIssue ??= `${parsed.error.issues[0]?.path.join('.')}: ${parsed.error.issues[0]?.message}`;
}
}
return { rows, rejected, firstIssue };
}
// Fail the query only when the response is so broken that showing it misleads.
const REJECT_RATIO_LIMIT = 0.1;
export const resilientTodoList = queryOptions({
queryKey: ['todos', 'list', {}],
queryFn: async ({ signal }) => {
const raw = (await fetchTodos({}, signal)) as unknown[];
const { rows, rejected, firstIssue } = parseCollection(todoSchema, raw);
if (rejected > 0) reportShapeError('todos.list', firstIssue, { rejected, total: raw.length });
// A handful of bad rows: drop them. A tenth of the response: something is
// structurally wrong and a partial list would be misleading.
if (rejected / Math.max(1, raw.length) > REJECT_RATIO_LIMIT) {
throw new ResponseShapeError([], 'todos.list');
}
return rows;
},
});
Cache Behavior Analysis: Dropping individual rows rather than failing the query keeps the cache populated with everything that is valid, so the screen renders and only the broken records are missing — which for a list of todos is far better than an empty error state. The ratio threshold is what stops that becoming dishonest: if a tenth of the rows fail, the shape has probably changed globally and a partial list silently misrepresents the data. Reporting the count alongside the first issue gives the metric that matters, because a rejection rate that climbs after a deploy is the signal, not any individual failure.
Step 4 — Keep the Cost Bounded
Parsing is not free, and on a large polled collection it is paid on every fetch. Measure before assuming it is fine, and narrow the schema when it is not.
// A narrow schema for a high-frequency polled endpoint: validate the fields
// the application actually depends on, not every field the API returns.
export const todoRowSchema = z.object({
id: z.string(),
title: z.string(),
done: z.boolean(),
}).passthrough(); // keep the rest unchecked rather than stripping and re-allocating
// Validate the detail response fully; it is fetched once per open, not per poll.
export const todoDetailSchema = todoSchema.extend({
description: z.string(),
comments: z.array(z.object({ id: z.string(), body: z.string() })),
});
export function measureParseCost(schema: z.ZodTypeAny, sample: unknown) {
const start = performance.now();
for (let i = 0; i < 100; i++) schema.safeParse(sample);
return (performance.now() - start) / 100;
}
Cache Behavior Analysis: passthrough() keeps unvalidated properties on the parsed object rather than stripping them, which skips the allocation of a filtered copy for every row — meaningful on a two-thousand-row response polled every fifteen seconds, and irrelevant on a detail query. Because Zod builds a new object graph on every parse, the parsed result never shares references with the previous one, so structural sharing has to do its full value comparison on each fetch; that comparison is what preserves the cached identity, so selectors downstream still see stable references. Measuring rather than guessing matters because the cost scales with schema depth, not response size alone — a flat two-thousand-row array is often cheaper than a deeply nested single object.
Edge Cases & Gotchas
Transforms and persisted caches. A schema that transforms a string into a Date produces a cached value that does not survive JSON.stringify intact. Either keep the ISO string in the cache and convert at render, or handle the revival in the persister.
Optimistic updates bypass the boundary. setQueryData writes whatever you hand it, with no parsing. Run the same schema over optimistic values in development, or an optimistic write can insert a shape the query path would have rejected.
Schema drift between mutation and query responses. A mutation returning the updated entity should be parsed with the same schema the query uses, or the two write incompatible shapes to the same key.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| A crash deep in a component after an API change | Malformed data was cached and read by every observer | Parse in queryFn and throw before writing |
| A shape error retries three times before surfacing | The retry predicate does not exclude deterministic failures | Skip retries for ResponseShapeError |
| A whole list disappears because one row is bad | The collection is parsed as a unit | Parse per row and drop, with a ratio threshold |
| Polling became measurably slower | A deep schema runs on every fetch of a large collection | Narrow the schema and use passthrough |
| Persisted data fails to restore | A transform produced a value JSON.stringify cannot round-trip |
Keep the raw shape in the cache; transform at render |
Frequently Asked Questions
Should validation live in the transport or the query function?
Usually the transport, because that is the layer that owns the response shape and every caller — the query, a server-side render, a service worker — then gets the same guarantee without repeating it. What actually matters is the ordering rather than the file: the parse has to happen before the value is written to the cache, because once it is written every observer of that key reads it and the failure multiplies. Putting it in queryFn is a fine intermediate step when the transport is shared with code you do not want to change yet.
Does parsing break structural sharing?
Not break, but it does mean structural sharing has real work to do. Zod builds a fresh object graph on every parse, so the parsed result never shares references with the previous fetch — structural sharing then compares values and reuses the cached sub-objects where they are deeply equal, which is what keeps downstream selector memoization effective. The cost you are paying is the parse plus the comparison on each fetch, which for a polled endpoint is worth measuring rather than assuming; for a detail query opened occasionally it is irrelevant.
Is it worth validating in production?
Yes, wherever the failure mode is a crash rather than a cosmetic glitch. The value is not that validation finds bugs a type system would have caught — it cannot, because the API is outside the type system — but that it converts an unhandled TypeError on a render path into a handled query error at the boundary, with a message naming the field. That is the difference between a white screen with a minified stack trace and an error state your existing error handling already knows how to display, and it costs a few milliseconds per fetch.
Related
- Schema-Driven Normalization — the parent guide on schemas as the description of your entity graph.
- Normalizing Entities With Normalizr — the normalization step that runs after validation.
- Separating the Cache Layer From the Transport Layer — where the parse boundary belongs architecturally.
- Cache Versioning & Migration — what a transformed cached value means for persistence.