Back to Blog
When Your API Contract Becomes Your Type System
4 min readSep 25, 20264 views

When Your API Contract Becomes Your Type System

I've been burned by runtime data validation too many times. You write an API, define types for it, but at runtime, the data coming in doesn't match. Zod lets your schema *be* your type definition, catching errors earlier and making your backend and frontend more robust.

TypeScriptAPISoftware DesignToolingFull-Stack
Share

by Sunil Band

Your Data, My Types: The Eternal Disconnect

We all love TypeScript. It brings a sense of security, a calm assurance that our code should work at compile time. But that confidence often evaporates the moment data hits our application from an external source – a user input, an environment variable, or, most commonly, an API endpoint. You meticulously define your types based on the API contract, but what if the API lies? What if the data coming over the wire doesn't match the types you declared?

This isn't just about malformed requests from a malicious user. It's often about subtle mismatches between frontend expectations and backend reality, or third-party APIs that evolve without perfect communication. Relying solely on TypeScript at compile time for external data is like having a perfectly designed blueprint but no quality control for the actual construction materials. You need runtime validation, but traditionally, that's meant writing a separate validation layer, duplicating your type definitions, and then fighting to keep them in sync. It's a maintenance nightmare waiting to happen.

Zod: Your Schema is Your Source of Truth

This is where Zod shines. Zod is a TypeScript-first schema declaration and validation library. What makes it powerful isn't just that it validates your data at runtime; it's that it infers static TypeScript types directly from your schemas. This eliminates the duplication problem entirely. Your single source of truth for data structure and validation becomes the Zod schema itself.

Let's consider a common scenario: building a robust API endpoint that accepts a user profile update. We want to ensure the incoming data conforms to a specific shape and type, and then use that validated, type-safe data throughout our application.

typescript
import { z } from 'zod';

// 1. Define your Zod schema for a user profile
const userProfileSchema = z.object({
  id: z.string().uuid(), // Enforce UUID format for IDs
  name: z.string().min(3).max(50), // Name must be a string between 3 and 50 chars
  email: z.string().email(), // Enforce email format
  age: z.number().int().min(18).optional(), // Age is an optional integer, min 18
  roles: z.array(z.enum(['admin', 'editor', 'viewer'])).default(['viewer']), // Array of specific roles, defaults to 'viewer'
  address: z.object({
    street: z.string(),
    city: z.string(),
    zip: z.string().regex(/^[0-9]{5}(?:-[0-9]{4})?$/), // US zip code format
  }).partial().optional(), // Address object is optional, and its fields are also optional
  isActive: z.boolean().default(true), // Defaults to true if not provided
});

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

// Let's simulate some incoming data
const goodData = {
  id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
  name: 'Sunil Band',
  email: 'sunil@example.com',
  age: 42,
  roles: ['admin', 'editor'],
  address: {
    street: '123 Dev St',
    city: 'Coder Town',
    zip: '90210'
  },
};

const badData = {
  id: 'invalid-uuid',
  name: 'Su',
  email: 'not-an-email',
  age: 17, // Too young
  address: {
    zip: 'abcde'
  }
};

const partialData = {
  id: 'a1b2c3d4-e5f6-7890-1234-567890abcde1',
  name: 'Jane Doe',
  email: 'jane@example.com',
  // age, roles, address, isActive will use defaults or be undefined/optional
};

// 3. Validate the data at runtime
function processUserProfile(data: unknown) {
  try {
    const validatedProfile = userProfileSchema.parse(data); // This throws if validation fails
    console.log('Validation successful:', validatedProfile);

    // At this point, validatedProfile is guaranteed to be of type UserProfile
    // TypeScript knows its shape, and we can use it safely.
    if (validatedProfile.age) {
      console.log(`User ${validatedProfile.name} is ${validatedProfile.age} years old.`);
    }
    console.log(`Roles: ${validatedProfile.roles.join(', ')}`);
    if (validatedProfile.address?.city) {
      console.log(`Lives in ${validatedProfile.address.city}`);
    }

  } catch (error) {
    if (error instanceof z.ZodError) {
      console.error('Validation failed:', error.errors); // Zod provides detailed error messages
    } else {
      console.error('An unexpected error occurred:', error);
    }
  }
}

processUserProfile(goodData);
console.log('---\n');
processUserProfile(badData);
console.log('---\n');
processUserProfile(partialData);

This example demonstrates a few key aspects. First, the schema definition is incredibly expressive. You can define primitives, objects, arrays, enums, unions, and even apply transformations. More importantly, notice type UserProfile = z.infer<typeof userProfileSchema>;. This line is the magic. It tells TypeScript to extract the static type from your Zod schema, ensuring your runtime validation and compile-time types are perfectly aligned. No more manual type definitions that drift out of sync with your validation logic.

When userProfileSchema.parse(data) is called, Zod attempts to coerce and validate the data against the schema. If it fails, a ZodError is thrown with a highly detailed list of issues, making debugging significantly easier than generic

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. ☕