Benjamin Shawki
Potters.Tools

Building Type-Safe APIs with Schema Validation

TypeScriptAPI DesignSchema ValidationZodValibotDesign Patterns

In modern web development, ensuring type safety across the entire application stack has become increasingly important. This article explores how to create robust, type-safe API interactions using TypeScript and schema validation libraries. We'll dive into patterns for creating self-documenting, type-safe APIs that provide confidence at compile time rather than discovering issues at runtime.

The Problem: Type Safety Across Boundaries

When working with APIs, we often face a fundamental challenge: how do we ensure that the data crossing system boundaries conforms to our expectations? TypeScript provides excellent type safety within our application, but those guarantees disappear when we interact with external systems or APIs.

Consider this common scenario:

typescript
// A typical API call
const fetchUserData = async (userId: string) => {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data; // What's the type of data? We don't know!
};

The data returned from this function could be anything. TypeScript has no way to verify that the server response matches our expected shape. We could manually type it:

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

const fetchUserData = async (userId: string): Promise<User> => {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();
  return data as User; // Unsafe type assertion!
};

But this approach has a critical flaw: we're simply telling TypeScript to trust us that the data will conform to our User interface. There's no runtime validation, and if the API changes, our code might break in unpredictable ways.

The Solution: Schema Validation

Schema validation libraries like Zod or Valibot solve this problem by providing both runtime validation and compile-time type inference. Let's see how this works:

typescript
import { z } from 'zod';

// Define a schema
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

// Infer a type from the schema
type User = z.infer<typeof UserSchema>;

const fetchUserData = async (userId: string): Promise<User> => {
  const response = await fetch(`/api/users/${userId}`);
  const data = await response.json();

  // Validate the data at runtime
  const validatedData = UserSchema.parse(data);
  return validatedData; // Now we know this is a valid User
};

Now we have both runtime validation and proper TypeScript types. If the API returns unexpected data, we'll get a helpful validation error rather than mysterious bugs later on.

Building a Type-Safe API Client

Let's take this concept further and build a complete type-safe API client system using design patterns that promote reusability and type safety.

1. The Schema Repository Pattern

First, we'll define all our API schemas in a central location:

typescript
// schemas.ts
import { z } from 'zod';

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

export const CreateUserInputSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(8),
});

export const UpdateUserInputSchema = z.object({
  name: z.string().min(2).optional(),
  email: z.string().email().optional(),
});

// Infer types from schemas
export type User = z.infer<typeof UserSchema>;
export type CreateUserInput = z.infer<typeof CreateUserInputSchema>;
export type UpdateUserInput = z.infer<typeof UpdateUserInputSchema>;

This approach centralizes our data definitions and makes it easy to update schemas as our API evolves.

2. The API Response Wrapper Pattern

Next, let's define a standard response wrapper to handle common API response patterns:

typescript
// api-types.ts
export interface ApiSuccessResponse<T> {
  status: 'success';
  data: T;
}

export interface ApiErrorResponse {
  status: 'error';
  message: string;
  code?: string;
}

export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

// Type guard to check if the response is successful
export function isApiSuccess<T>(response: ApiResponse<T>): response is ApiSuccessResponse<T> {
  return response.status === 'success';
}

3. The Type-Safe API Client Pattern

Now, let's create a reusable function to make type-safe API calls:

typescript
// api-client.ts
import { z } from 'zod';
import { ApiResponse } from './api-types';

export async function apiRequest<TResponseSchema extends z.ZodType, TInput = void>(
  url: string,
  method: 'GET' | 'POST' | 'PUT' | 'DELETE',
  responseSchema: TResponseSchema,
  input?: TInput,
): Promise<ApiResponse<z.infer<TResponseSchema>>> {
  try {
    const response = await fetch(url, {
      method,
      headers: {
        'Content-Type': 'application/json',
      },
      body: input ? JSON.stringify(input) : undefined,
    });

    const responseData = await response.json();

    if (!response.ok) {
      return {
        status: 'error',
        message: responseData.message || 'An unknown error occurred',
        code: responseData.code,
      };
    }

    // Validate the response against the schema
    const validatedData = responseSchema.parse(responseData);

    return {
      status: 'success',
      data: validatedData,
    };
  } catch (error) {
    return {
      status: 'error',
      message: error instanceof Error ? error.message : 'An unknown error occurred',
    };
  }
}

4. The Endpoint Factory Pattern

Finally, let's create a factory function that generates type-safe API endpoints:

typescript
// api-endpoints.ts
import { z } from 'zod';
import { apiRequest } from './api-client';
import { ApiResponse } from './api-types';

interface EndpointOptions<TResponseSchema extends z.ZodType, TInput> {
  url: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  responseSchema: TResponseSchema;
  inputSchema?: z.ZodType<TInput>;
}

export function createEndpoint<TResponseSchema extends z.ZodType, TInput = void>(
  options: EndpointOptions<TResponseSchema, TInput>,
) {
  return async (input?: TInput): Promise<ApiResponse<z.infer<TResponseSchema>>> => {
    // Validate input if schema is provided
    if (options.inputSchema && input) {
      options.inputSchema.parse(input);
    }

    return apiRequest(options.url, options.method, options.responseSchema, input);
  };
}

// Type inference helper
export type InferEndpointResponse<T> = T extends (...args: any[]) => Promise<ApiResponse<infer R>>
  ? R
  : never;
export type InferEndpointInput<T> = T extends (input: infer I) => any ? I : void;

5. Putting It All Together

Now we can create type-safe API endpoints:

typescript
// users-api.ts
import { z } from 'zod';
import { createEndpoint, InferEndpointResponse, InferEndpointInput } from './api-endpoints';
import { UserSchema, CreateUserInputSchema, UpdateUserInputSchema } from './schemas';

// Define endpoints
export const getUserEndpoint = createEndpoint({
  url: '/api/users',
  method: 'GET',
  responseSchema: z.array(UserSchema),
});

export const createUserEndpoint = createEndpoint({
  url: '/api/users',
  method: 'POST',
  responseSchema: UserSchema,
  inputSchema: CreateUserInputSchema,
});

export const updateUserEndpoint = createEndpoint({
  url: '/api/users',
  method: 'PUT',
  responseSchema: UserSchema,
  inputSchema: UpdateUserInputSchema,
});

// Infer types from endpoints
export type GetUsersResponse = InferEndpointResponse<typeof getUserEndpoint>;
export type CreateUserParams = InferEndpointInput<typeof createUserEndpoint>;
export type UpdateUserParams = InferEndpointInput<typeof updateUserEndpoint>;

And finally, we can use these endpoints with full type safety:

typescript
// Example usage
import { getUserEndpoint, createUserEndpoint, isApiSuccess } from './api';

async function fetchUsers() {
  const response = await getUserEndpoint();

  if (isApiSuccess(response)) {
    // response.data is fully typed as User[]
    const users = response.data;
    console.log(users.map((user) => user.name).join(', '));
  } else {
    console.error('Error fetching users:', response.message);
  }
}

async function createUser() {
  const response = await createUserEndpoint({
    name: 'John Doe',
    email: 'john@example.com',
    password: 'secure123',
  });

  if (isApiSuccess(response)) {
    // response.data is fully typed as User
    console.log(`Created user: ${response.data.id}`);
  } else {
    console.error('Error creating user:', response.message);
  }
}

Benefits of This Approach

The schema validation and type-safe API patterns we've explored offer several significant benefits:

  1. Compile-time Safety: TypeScript will catch many errors before they reach runtime.
  2. Self-documenting Code: The schema definitions serve as both validation logic and documentation.
  3. Runtime Validation: We validate data at runtime, providing a defense against API changes or malformed data.
  4. Separation of Concerns: The factory pattern separates endpoint definitions from their implementation.
  5. DRY Code: We define the schema once and derive both types and validation from it.
  6. Type Inference: We can infer types from schemas and endpoints, reducing the need for duplicate type definitions.

Design Patterns Used

This approach incorporates several design patterns:

  1. Repository Pattern: Centralizing schemas in a single location.
  2. Factory Pattern: Creating endpoints with consistent behavior.
  3. Adapter Pattern: The API client adapts the fetch API to our type-safe interface.
  4. Type Guard Pattern: Using isApiSuccess to narrow types safely.
  5. Decorator Pattern: Adding validation to our API calls.

Conclusion

By combining TypeScript with schema validation, we can create API interactions that are both type-safe at compile time and validated at runtime. This approach significantly reduces the likelihood of bugs caused by unexpected data shapes and provides a more robust way to handle API responses.

The patterns shown here can be adapted to work with any schema validation library and any API client. Whether you're using Axios, fetch, or another HTTP client, these patterns will help you create more reliable, self-documenting code that's easier to maintain and evolve over time.

As web applications continue to grow in complexity, investing in this type of robust type safety infrastructure pays dividends in reduced bugs, improved developer experience, and more maintainable codebases.