TypeScript types disappear at the network boundary. On mobile - spotty connectivity, CDN caches, and backend deploys that race your app store release - runtime validation with Zod keeps API payloads honest before they reach your UI.
fetch + res.json() returns Promise<any> in TypeScript's DOM lib - assigning to a named interface does not validate anything at runtime.
Zod schemas walk the parsed object and coerce or reject fields; z.infer projects the schema into a TypeScript type at compile time.
parse throws ZodError with a path array - useful in dev to see exactly which field failed.
safeParse returns { success, data | error } - better for forms and inline retry UI where you do not want exceptions.
On mobile, validation failures often mean stale cache or a partial response on a dropped connection - log the ZodErrorissues array to your crash reporter.
import { z } from "zod";// Single source of truth - export schema + inferred type togetherexport const DeviceSchema = z.object({ id: z.string(), name: z.string(), platform: z.enum(["ios", "android"]),});export type Device = z.infer<typeof DeviceSchema>;// Narrow unknown without assertionfunction isDevice(value: unknown): value is Device { return DeviceSchema.safeParse(value).success;}// Input vs output types when using .transform or .defaulttype DeviceInput = z.input<typeof DeviceSchema>;type DeviceOutput = z.output<typeof DeviceSchema>;
Install Zod with npx expo install zod so the version stays compatible with your Expo SDK lockfile.
Keep schemas in plain .ts files - no JSX - so Metro can import them from hooks, background tasks, and tests.
Prefer z.infer over duplicating an interface Device - the interface will drift the first time the API changes.
Casting res.json() to an interface - const data = (await res.json()) as User silences TypeScript but not bad payloads. Fix: Assign to unknown, then UserSchema.parse(data).
Optional vs nullable mismatch - Backend sends avatarUrl: null but schema uses .optional() only; Zod rejects the response. Fix: Use .nullable() for SQL nulls, .optional() for absent keys, or .nullish() for both.
Number fields arriving as strings - Some gateways stringify numbers in JSON. Fix:z.coerce.number() at the boundary, or z.union([z.number(), z.string().transform(Number)]) when both shapes appear.
Date strings used as Date in UI - z.string().datetime() validates format but leaves a string; new Date(iso) in every screen duplicates logic. Fix:.transform((s) => new Date(s)) once in the schema.
Validating only success responses - Error bodies with a different shape throw opaque ZodError in fetch helpers. Fix: Branch on !res.ok, parse ApiErrorSchema first, then parse success schema.
Huge list responses without pagination schema - Accepting z.array(ItemSchema) when the API adds { items, nextCursor } breaks every screen at once. Fix: Model the envelope explicitly (UserListSchema above).
Caching unvalidated JSON in AsyncStorage - An old app version wrote a shape your new schema rejects; users crash-loop on launch. Fix: Version cache keys (users:v2) and run safeParse on read; evict on failure.