ZEPHYRUS
000
← ARCHIVE
12 Apr 2026#Zod #TypeScript #API #DX #Validation

Validating Every API Response with Zod at the Boundary

Why casting response.data as MyType is lying to yourself, and how a thin validateApiResponse wrapper with Zod schemas catches backend drift before it reaches your UI.

TypeScript gives you type safety inside the application boundary. But axios.get<MyType>() doesn’t validate anything it just casts optimistically. If the backend changes a field name, returns null where you expected a string, or sends a completely different shape, TypeScript won’t save you at runtime. It is a compile time checker not a runtime checker.

In Sentinel, every important API call goes through a validateApiResponse wrapper backed by Zod schemas. Here’s the pattern and some real examples of where it’s paid off.

The Wrapper

export async function validateApiResponse<T>(
  responsePromise: Promise<AxiosResponse>,
  schema: z.ZodType<T>,
  options?: { invalidMessage?: string },
): Promise<{ data: T; raw: AxiosResponse }> {
  const response = await responsePromise;
  const result = schema.safeParse(response.data);

  if (!result.success) {
    const error = new ApiValidationError(
      options?.invalidMessage ?? "Invalid API response",
      result.error.issues,
    );
    throw error;
  }

  return { data: result.data, raw: response };
}

safeParse rather than parse — this way the error is wrapped in a typed ApiValidationError rather than a raw Zod ZodError, making it easier to catch and handle differently from network errors.

Distinguishing Validation Errors from Network Errors

Catch blocks need to know what kind of failure occurred:

export function isApiValidationError(
  error: unknown,
): error is ApiValidationError {
  return error instanceof ApiValidationError;
}
try {
  const res = await validateApiResponse(
    AxiosClient.post("/api/v1/users/", payload),
    venueCreateResponseSchema,
    { invalidMessage: "Invalid user creation response" },
  );
  // res.data is fully typed and validated
} catch (error) {
  if (isApiValidationError(error)) {
    console.error("Schema mismatch:", error.issues);
    // show a specific error to the user
    return;
  }
  // network error, timeout, 500, etc.
  toast.error("Something went wrong");
}

This separation matters. A validation error means the backend returned something unexpected worth logging with the full Zod issue list for debugging. A network error is a different failure mode entirely.

The .passthrough().transform() Pattern

This is where Zod really earns its place. The token response schema normalises across three different field names the backend has used at various points:

export const tokenResponseSchema = z
  .object({})
  .passthrough()
  .transform((payload, ctx) => {
    const access = payload.access ?? payload.access_token ?? payload.token;
    const refresh = payload.refresh ?? payload.refresh_token;

    if (!access) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Access token missing",
      });
    }
    if (!refresh) {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "Refresh token missing",
      });
    }

    return {
      access,
      refresh,
      email: payload.email,
      permission_level: payload.permission_level,
      user_id: payload.user_id,
    };
  });

.passthrough() on an empty object schema accepts any input without stripping fields. The .transform() then normalises the output shape. The result: the rest of the application only ever sees { access, refresh, email, ... } regardless of which field name the backend happened to send.

This survived a backend rename without a single frontend code change. The schema absorbed the variation. Which otherwise would be a pain in the ass.

The HTML Error Page Parser

The AxiosClient Axios instance also handles a common Django development failure mode getting an HTML error page (500, debug page) instead of JSON:

AxiosClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response) {
      const { status, data, config } = error.response;
      const url = config?.url || "unknown";
      const method = config?.method?.toUpperCase() || "unknown";

      if (typeof data === "string" && data.includes("<!doctype html>")) {
        const titleMatch = data.match(/<title>(.*?)<\/title>/i);
        const h1Match = data.match(/<h1>(.*?)<\/h1>/i);
        const errorMessage = titleMatch?.[1] || h1Match?.[1] || "Server Error";

        const enhancedError = new Error(
          `Server Error (${status}): ${errorMessage} — ${method} ${url}`,
        );
        (enhancedError as any).response = error.response;
        return Promise.reject(enhancedError);
      }
    }

    return Promise.reject(error);
  },
);

Without this, an HTML error page shows up in the browser console as a wall of markup. With it, you get Server Error (500): TemplateSyntaxError at /api/v1/... — POST /api/v1/.../. Far more useful which helps developers and users patch up real world issues and bugs.

Schema Examples in Practice

A sample of schemas across the app:

// Simple list
export const certificationTagsSchema = z.array(z.string());

// Refresh token — minimal
export const refreshResponseSchema = z
  .object({
    access: z.string().min(1),
  })
  .passthrough();

// Venue creation — checking specific fields exist
export const venueCreateResponseSchema = z
  .object({
    id: z.union([z.string(), z.number()]),
    name: z.string().optional(),
  })
  .passthrough();

Note .passthrough() on most schemas — this preserves extra fields the backend sends that the schema doesn’t define. Strict schemas (.strict()) are useful when you want to catch unexpected fields, but for APIs you don’t fully control, passthrough is more forgiving.

What You Catch That TypeScript Misses

Backend change TypeScript Zod
Field renamed (accessaccess_token) Silent runtime undefined Caught at parse — missing field error
Nullable field (was string, now string | null) Silent cast Caught — type mismatch
Removed field Silent undefined access Caught at parse
Wrong type ("1" vs 1) Silent Caught — z.number() rejects string
Completely different shape Silent Caught — schema mismatch

TypeScript’s type system is erased at runtime. Zod’s isn’t. For any data crossing a network boundary, that distinction matters specially in a highly decoupled environment with a full stack app. Runtime validation saves developers real hours reducing debugging from multiple days to just hours.