Back to Blog
When Your API Contract Becomes Your Type System
6 min readAug 11, 20262 views

When Your API Contract Becomes Your Type System

Schema validation is often an afterthought, bolted on at the edge of your application. But what if your validation library could deeply integrate with TypeScript, generating types directly from your schemas? Zod changes the game by making your runtime validation the source of truth for your static t

TypeScriptAPISoftware DesignFull-Stack
Share

by Sunil Band

The Problem with API Contracts

We've all been there: you design an API, define its shape in a backend/src/api-types.ts file, and then painstakingly replicate that structure in your frontend. Or worse, you just any your way through, hoping for the best. This duality leads to a frustrating cycle of bugs: a backend change breaks the frontend, a frontend developer assumes a field exists, and suddenly, you're debugging undefined is not a function at 3 AM.

The core issue is a mismatch between runtime validation and static type definitions. You'll often have a runtime check to ensure incoming data from a request body or a third-party API matches an expected shape. But this validation usually happens after the data has already been typed as any or some manually crafted interface. This means your TypeScript compiler isn't helping you enforce the contract where it matters most: at the boundary.

Enter Zod: Schema as Source of Truth

Zod flips this problem on its head. Instead of defining types and then writing validation, you define your validation schema, and Zod infers the TypeScript types from it. This means your runtime validation logic is your type definition. If your data passes validation, TypeScript knows its exact shape. No more manual type declarations for your API payloads, no more any, and a massive reduction in runtime type errors.

This approach brings incredible confidence. If your backend uses Zod to validate incoming requests, and your frontend uses the same Zod schemas to validate responses, you've established a single source of truth for your data contracts. Any deviation, either in runtime data or in how your code interacts with it, will be caught either at compile time by TypeScript or at runtime by Zod's validation.

A Practical Example: User Profile API

Let's walk through a common scenario: a user profile API. We'll define a schema for a user, use it for validation on a hypothetical backend, and then demonstrate how the inferred types make frontend development safer.

First, define your schema:

typescript
import { z } from 'zod';

// Define a schema for a user profile
export const UserProfileSchema = z.object({
  id: z.string().uuid(), // Enforce UUID format for ID
  username: z.string().min(3).max(20), // Username must be between 3 and 20 chars
  email: z.string().email(), // Email must be a valid email format
  age: z.number().int().positive().optional(), // Age is an optional positive integer
  roles: z.array(z.enum(['admin', 'editor', 'viewer'])).default(['viewer']), // Array of predefined roles
  createdAt: z.string().datetime(), // Timestamp string, will be parsed later
  lastLoggedIn: z.string().datetime().nullable(), // Nullable datetime string
});

// Infer the TypeScript type directly from the schema
export type UserProfile = z.infer<typeof UserProfileSchema>;

// Let's say we have an update schema, where all fields are optional
export const UserProfileUpdateSchema = UserProfileSchema.partial();
export type UserProfileUpdate = z.infer<typeof UserProfileUpdateSchema>;

// A more complex example: a list of users, potentially with pagination metadata
export const PaginatedUsersSchema = z.object({
  data: z.array(UserProfileSchema),
  totalCount: z.number().int().nonnegative(),
  currentPage: z.number().int().positive().default(1),
  pageSize: z.number().int().positive().default(10),
});
export type PaginatedUsers = z.infer<typeof PaginatedUsersSchema>;

Notice how we're defining constraints like min(3), email(), uuid(), and int().positive(). These aren't just type hints; they're runtime assertions. Zod handles the heavy lifting of ensuring data conforms to these rules.

Backend Usage

On the backend, let's imagine an Express endpoint that handles creating a new user. Instead of manually checking fields, we just parse the request body with our schema:

typescript
import express from 'express';
import { z } from 'zod';
import { UserProfileSchema } from './schemas'; // Assuming schemas.ts from above

const app = express();
app.use(express.json());

app.post('/users', (req, res) => {
  try {
    // Validate and parse the request body
    const newUser = UserProfileSchema.parse({
      ...req.body,
      id: 'some-generated-uuid',
      createdAt: new Date().toISOString() // Backend-generated fields
    });

    // At this point, newUser is guaranteed to be of type UserProfile
    // and all its properties conform to the schema rules.
    console.log('New user created:', newUser);

    // In a real app, you'd save newUser to a database
    res.status(201).json(newUser);

  } catch (error) {
    if (error instanceof z.ZodError) {
      // Zod provides detailed error messages
      return res.status(400).json({ errors: error.errors });
    }
    res.status(500).json({ message: 'Internal server error' });
  }
});

app.listen(3000, () => console.log('Server running on port 3000'));

The UserProfileSchema.parse() method is the key. If the req.body doesn't match the schema, it throws a ZodError with detailed information about what went wrong. Otherwise, newUser is statically typed as UserProfile, and you get full autocompletion and type checking for all its properties.

Frontend Usage

Now, let's fetch this user data on the frontend. We can reuse the exact same schema. This is where the power of a shared schema truly shines. If you put your schemas in a shared package (e.g., a common or api-schemas folder in a monorepo), both frontend and backend benefit.

typescript
import React, { useState, useEffect } from 'react';
import { UserProfile, UserProfileSchema } from './schemas'; // Reusing the schema

function UserProfileDisplay({ userId }: { userId: string }) {
  const [user, setUser] = useState<UserProfile | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const rawData = await response.json();

        // Validate the incoming data from the API response
        const validatedUser = UserProfileSchema.parse(rawData);
        setUser(validatedUser);

      } catch (err) {
        if (err instanceof Error) {
          setError(err.message);
        } else if (err instanceof z.ZodError) {
          setError('Data validation error: ' + JSON.stringify(err.errors, null, 2));
        } else {
          setError('An unknown error occurred');
        }
      } finally {
        setLoading(false);
      }
    }
    fetchUser();
  }, [userId]);

  if (loading) return <p>Loading user data...</p>;
  if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
  if (!user) return <p>No user found.</p>;

  return (
    <div>
      <h2>{user.username} ({user.email})</h2>
      <p>ID: {user.id}</p>
      {user.age && <p>Age: {user.age}</p>}
      <p>Roles: {user.roles.join(', ')}</p>
      <p>Member since: {new Date(user.createdAt).toLocaleDateString()}</p>
      {user.lastLoggedIn && (
        <p>Last logged in: {new Date(user.lastLoggedIn).toLocaleString()}</p>
      )}
    </div>
  );
}

// Example usage:
// <UserProfileDisplay userId="a1b2c3d4-e5f6-7890-1234-567890abcdef" />

After UserProfileSchema.parse(rawData), validatedUser is now confirmed to be UserProfile. If the API sends back malformed data, it's caught before it can cause runtime errors in your UI. This is crucial for defensive programming against unexpected API responses or third-party data sources.

Trade-offs and Considerations

While Zod is fantastic, it's important to acknowledge its place. It's primarily a runtime validation library. The type inference is a powerful byproduct of that. This means:

  1. Bundle Size: Zod schemas add JavaScript to your bundle. For extremely performance-sensitive clients where every KB counts, this is a factor. However, for most modern web applications, the overhead is negligible given the benefits in reliability and developer experience.
  2. No Type-Level Logic: Zod schemas are JavaScript objects. You can't use complex TypeScript type-level features (like conditional types that depend on values) directly within Zod to generate dynamic schema shapes. For those advanced scenarios, you might still need to compose Zod types with manual TypeScript types or rely on Zod's refine and superRefine methods for more complex runtime logic.
  3. Error Handling: While Zod's error messages are excellent, converting them into user-friendly UI messages requires some mapping. The error.errors array gives you detailed context, but you'll still need to decide how to present those to your users.
  4. Backend Integration: If your backend isn't TypeScript or doesn't use a similar schema-driven validation approach (e.g., Go with struct tags, Python with Pydantic), you might still have a
More from the blog
Available for projectsReady to make something fun 🎈

Ready to build the next system?Wanna build something awesome together?

Currently accepting high-impact opportunities in frontend engineering and scalable web applications.Got a cool idea rattling around? Let's grab a virtual coffee and turn it into something people love. ☕