Skip to content

Why we validate every API response with Zod in React Native — and what breaks when you don't

5/11/2026Mobile Development7 min read

Why we validate every API response with Zod in React Native — and what breaks when you don't

Every API response parsed with Zod before touching the store — the DTO contract that prevents silent runtime crashes in production.

TypeScript gives you compile-time safety. It does nothing about the JSON blob your backend returns at runtime. When the server sends null where your type expects a string, TypeScript has already been compiled away. Your app crashes — or worse, it renders "null" in the UI and nobody notices until a user screenshots it.

The pattern in this boilerplate is simple: every service method parses the response through a Zod schema before returning. If the data does not match the schema, Zod throws. The crash happens at the boundary, with a clear parse error pointing at the exact field that misfired, rather than three stack frames later inside a Zustand selector.

The situation

The boilerplate has 15 schemas in dto/auth.dto.ts alone, 16 in dto/tenant.dto.ts, and shared pagination and error shapes in dto/common.dto.ts. Every schema is defined with z.object(), inferred as a TypeScript type with z.infer<typeof Schema>, and exported alongside the schema itself. The service layer imports both and uses the schema at the call site:

// services/auth.service.client.ts
import {
  LoginResponse,
  LoginResponseSchema,
  SafeUser,
  SafeUserSchema,
  Session,
  SessionSchema,
} from "@/dto/auth.dto";

export class AuthClientService {
  static async login(payload: LoginRequest): Promise<LoginResponse> {
    const res = await axiosInstance.post("/api/system/auth/login", payload);
    return LoginResponseSchema.parse(res.data);
  }

  static async getSession(): Promise<SafeUser> {
    const res = await axiosInstance.get("/api/system/auth/session");
    return SafeUserSchema.parse(res.data?.user);
  }

  static async getSessions(): Promise<Session[]> {
    const res = await axiosInstance.get("/api/system/auth/me/sessions");
    return z.array(SessionSchema).parse(res.data?.sessions ?? []);
  }
}

LoginResponseSchema.parse(res.data) — not res.data as LoginResponse. The type cast would have compiled, but it would have lied. .parse() either returns a fully validated object or throws a ZodError with the field path and the expected vs. received type.

What the schemas actually enforce

dto/auth.dto.ts shows the full shape of the auth contract:

// dto/auth.dto.ts
export const UserSecuritySchema = z.object({
  totpEnabled: z.boolean().default(false),
  otpMethods: z.array(OTPMethodEnum).default([]),
  passkeyCount: z.number().default(0),
  otpVerifyNeeded: z.boolean().default(false),
});

export const LoginResponseSchema = z.object({
  user: SafeUserSchema,
  userSecurity: UserSecuritySchema.optional(),
});

UserSecuritySchema uses .default() on every field. This is deliberate. If the backend sends a partial userSecurity — missing passkeyCount, for example — Zod fills it in with 0 rather than crashing. The field otpVerifyNeeded is the one that drives the 2FA branch in the login screen. If that field arrived as undefined without a default, the OTP check if (userSecurity?.otpVerifyNeeded) would silently skip 2FA. With a .default(false), the behavior is explicit and predictable.

userSecurity: UserSecuritySchema.optional() covers the case where the login endpoint does not return security metadata at all — older API versions, or a server that omits the field on error paths. optional() means the field can be absent from the JSON entirely; without it, a missing userSecurity key would throw a parse error even when the login itself succeeded.

What the user type looks like

// dto/auth.dto.ts
export const UserSchema = z.object({
  userId: z.string(),
  email: z.string().email().optional().nullable(),
  name: z.string().optional().nullable(),
  phone: z.string().optional().nullable(),
  image: z.string().url().optional().nullable(),
  userRole: UserRoleEnum.default("USER"),
  language: z.string().optional().nullable(),
  theme: z.string().optional().nullable(),
  createdAt: z.string().optional(),
  updatedAt: z.string().optional(),
});
export type SafeUser = z.infer<typeof SafeUserSchema>;

userId is the only required field. Everything else is optional().nullable(). This reflects reality: users can be created without an email (phone-only accounts), without a profile image, without a display name. The Zod schema documents which fields are genuinely required versus which are wishful thinking in an earlier type definition.

z.string().email() on the email field means an address that fails RFC 5322 basic validation will throw at parse time rather than being stored silently and failing later in an email-send flow. The URL validation on image catches the same class of bug for avatar URLs.

The enum guard

export const OTPMethodEnum = z.enum(["EMAIL", "SMS", "TOTP_APP"]);
export type OTPMethod = z.infer<typeof OTPMethodEnum>;

UserRoleEnum and OTPMethodEnum are Zod enums. If the backend adds a new role — say "MODERATOR" — and this enum is not updated, the parse will throw on any response containing that role. That is the intended behavior. A new role the frontend does not understand should surface immediately at the response boundary, not silently default to undefined and cause a rendering crash three screens later.

The lesson

The rule the boilerplate follows, stated in registry.index.json conventions: "Parse every response with the matching schema; never trust raw JSON."

Applied in practice:

  • Every axiosInstance.get/post result goes through .parse() or .array().parse() before the data leaves the service class.
  • Types are always z.infer<typeof Schema> — never written by hand. The schema is the source of truth; the TypeScript type is derived from it.
  • .default() on optional fields makes missing-field behavior explicit, not implicit.
  • optional() on entire sub-objects covers partial backends without crashing callers.

The getSessions method shows the pattern for array responses:

static async getSessions(): Promise<Session[]> {
  const res = await axiosInstance.get("/api/system/auth/me/sessions");
  return z.array(SessionSchema).parse(res.data?.sessions ?? []);
}

res.data?.sessions ?? [] handles the case where the key is missing entirely — the optional chaining returns undefined, the nullish coalescing returns [], and z.array(SessionSchema).parse([]) returns an empty typed array rather than crashing. This means a backend that returns { sessions: null } or {} gets handled gracefully, while a backend that returns { sessions: [{ sessionId: null }] } will still throw where it should.

Where this does not apply

Zod parsing at every response boundary adds overhead. For endpoints that return large arrays — thousands of items — parsing every item through a schema adds measurable time. The pattern makes sense at every auth-adjacent endpoint (session, user, tenant data) where field correctness is safety-critical. For bulk list endpoints returning paginated display data, .safeParse() with fallback to an empty array may be preferable to letting a single malformed item in a 500-row response crash the entire screen.

The other exception: client-to-server requests. The boilerplate defines request schemas (LoginRequestSchema, RegisterRequestSchema) that are exported but not consistently used to validate outgoing data before sending. The server validates anyway, but parsing the outgoing payload through the schema before sending catches developer mistakes at the call site rather than as a 400 error from the backend.

Trade-off

Zod parse errors throw by default. If LoginResponseSchema.parse(res.data) fails because the backend returned an unexpected shape, the AuthClientService.login() call will throw a ZodError, not a network error. The catch block in login.tsx will show the user a parse error message that reads like a developer error, not a friendly "Login failed" message. The fix is to catch ZodError separately in the service layer and rethrow a domain error. The boilerplate does not do this yet — extractErrorMessage(err) in dto/common.dto.ts handles generic errors but does not special-case Zod parse failures.

Business impact

Silent type errors in mobile apps produce crash reports that are hard to reproduce and expensive to debug. A ZodError thrown at the response boundary is loud, specific, and always points to the exact field and value that violated the contract. That precision turns a three-hour debugging session into a five-minute fix. For a solo operator or a small team, the cost of adding Zod validation at service boundaries is about ten lines per endpoint; the cost of not having it is every production bug that surfaces as "the app crashed on login for some users" with no obvious cause.

What to do next

Open the service file for the last endpoint you shipped. Count how many responses you are returning as res.data as SomeType versus passing through SomeSchema.parse(res.data). For any that use a type cast, write the Zod schema first — you will find at least one field that was optional in your type but required in reality, or vice versa. That mismatch is a latent crash.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox