Zod describes what valid data looks like at runtime and infers the same TypeScript type your IDE autocompletes. On mobile, that matters twice: TextInput always delivers strings, and users expect inline errors under the field they just left - not a modal after tapping Submit.
// lib/schemas/profile.ts - shared between form and API clientimport { z } from "zod";export const ProfileSchema = z.object({ displayName: z .string() .trim() .min(1, "Display name is required") .max(40, "Keep it under 40 characters"), bio: z .string() .max(280, "Bio must be 280 characters or fewer") .optional() .or(z.literal("")), age: z .string() .min(1, "Age is required") .pipe( z.coerce .number({ invalid_type_error: "Enter a number" }) .int("Whole numbers only") .min(13, "You must be at least 13") .max(120, "Enter a realistic age") ), newsletter: z.boolean().default(false),});export type ProfileValues = z.infer<typeof ProfileSchema>;export const ProfileUpdateSchema = ProfileSchema.pick({ displayName: true, bio: true, newsletter: true,});export type ProfileUpdateValues = z.infer<typeof ProfileUpdateSchema>;
import { z } from "zod";const OrderSchema = z.object({ quantity: z.coerce.number().positive(), note: z.string().optional(),});type OrderInput = z.input<typeof OrderSchema>; // before transforms/coercetype OrderOutput = z.output<typeof OrderSchema>; // after - same as z.infer when no transformstype OrderValues = z.infer<typeof OrderSchema>;
Install with npx expo install zod @hookform/resolvers react-hook-form.
Export Schema, type Values = z.infer<typeof Schema>, and safeParse helpers from one module.
Reuse API response schemas with .pick() / .omit() / .extend() so list, detail, and form shapes stay aligned.
Duplicating types and schemas - Maintaining a interface Profile separate from ProfileSchema drifts on the first API change. Fix: Export only z.infer<typeof ProfileSchema>.
Validating numbers without coercion - z.number() fails when TextInput delivers "25" as a string. Fix:z.coerce.number() or z.string().regex(/^\d+$/).transform(Number).
.optional() vs empty string - Optional bio field with "" fails z.string().email() chained after. Fix: Trim and transform "" → undefined, or use .or(z.literal("")).
Showing errors before touch - Validating onChange on every keystroke frustrates mobile users. Fix:mode: "onTouched" and render isTouched && error.
Password match on wrong path - .refine() without path puts the error on _form root. Fix:path: ["confirmPassword"] so the message sits under the right field.
Nullable API fields in forms - Backend sends middleName: null but form uses "". Fix: Normalize in defaultValues and schema with .nullable().transform(v => v ?? "").
Giant monolithic schema - One 40-field schema makes error paths hard to read. Fix: Compose step schemas and merge with .merge() for wizards, or validate per step with .pick().