Back to Home

API Validation in runtime with Zod and TypeScript

The article explains TypeScript limitations in runtime for API data and suggests schema validation with Zod. Code examples and library comparisons for middle/senior developers.

Zod for runtime validation of TypeScript API
Advertisement 728x90

Runtime API Response Validation: Why TypeScript Isn't Enough

TypeScript only checks types at compile time. Data from APIs arrives at runtime with no guarantees it matches your interfaces. Without extra validation, mismatches show up as user-facing errors: undefined fields, wrong types, missing objects.

Problems Without Runtime Checks

TypeScript interfaces give a false sense of security. Here's typical code without validation:

interface User {
  id: number;
  name: string;
  email: string;
}

const getUser = async (): Promise<User> => {
  const res = await fetch('/api/user');
  return res.json(); // No check on actual data
};

TypeScript trusts the type annotation and ignores the real server response. Key risks:

Google AdInline article slot
  • Backend structure changes: full_namefullName leads to undefined.
  • Type mismatches: string "123" instead of number 123 causes concatenation instead of addition.
  • Conditional fields: address missing in edge cases, crash on user.address.city.
  • Third-party APIs with outdated docs.

Schema Validation Libraries

The fix? Runtime schema validation libraries. They parse real data and throw errors on mismatches. Top TypeScript options:

  • Zod: Infers types from schemas, huge ecosystem.
  • Yup: Works great with Formik.
  • Valibot: Bundle-friendly, tree-shakable.
  • Joi: Node.js roots, backend-focused.
  • ArkType: Performance-first.

Implementing with Zod

Zod combines schema, types, and validation in one. User example:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
});

type User = z.infer<typeof UserSchema>;

const getUser = async (): Promise<User> => {
  const res = await fetch('/api/user');
  const data = await res.json();
  return UserSchema.parse(data); // Errors on mismatch
};

parse() throws a ZodError with details on failure. Use safeParse() for an object with success and error.

Google AdInline article slot

Scaling in Real Projects

For large apps, centralize schemas for all endpoints. Wrap fetch like this:

const apiFetch = async <T>(url: string, schema: z.ZodSchema<T>): Promise<T> => {
  const res = await fetch(url);
  const data = await res.json();
  return schema.parse(data);
};

const users = await apiFetch('/api/users', z.array(UserSchema));

Error handling: Log to Sentry, show user-friendly messages. For arrays, validate each item.

  • Use z.array(UserSchema) for lists.
  • z.union() for polymorphic responses.
  • z.lazy() for recursive structures.
  • z.transform() to normalize data.

Key Takeaways

  • TypeScript doesn't guarantee runtime types for external data: APIs, localStorage, URL params.
  • Schema validation catches mismatches early, cutting debug time.
  • Zod is the frontend standard: types from schemas, easy integration.
  • In teams with separate backends, it prevents silent failures.
  • Scale with fetch wrappers for consistency.

Runtime validation fills the gap between TypeScript's compile-time checks and execution. In production, it slashes bugs from out-of-sync API contracts.

Google AdInline article slot

— Editorial Team

Advertisement 728x90

Read Next