Skip to main content

How to Build a Type-Safe API Client with TypeScript

·APIScout Team
typescripttype safetyapi clientzodcodegen

How to Build a Type-Safe API Client with TypeScript

A type-safe API client catches errors at compile time instead of runtime. Instead of discovering that user.name is now user.full_name when your app crashes in production, TypeScript tells you at build time. Here's how to build one.

Level 1: Basic Typed Client

// Define your API types
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

interface CreateUserInput {
  name: string;
  email: string;
}

interface ApiResponse<T> {
  data: T;
  meta?: { total: number; page: number };
}

// Typed API client
class ApiClient {
  constructor(private baseUrl: string, private apiKey: string) {}

  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!response.ok) {
      throw new ApiError(response.status, await response.text());
    }

    return response.json();
  }

  // Each method is fully typed
  async getUsers(): Promise<ApiResponse<User[]>> {
    return this.request('GET', '/users');
  }

  async getUser(id: string): Promise<User> {
    return this.request('GET', `/users/${id}`);
  }

  async createUser(input: CreateUserInput): Promise<User> {
    return this.request('POST', '/users', input);
  }

  async updateUser(id: string, input: Partial<CreateUserInput>): Promise<User> {
    return this.request('PUT', `/users/${id}`, input);
  }

  async deleteUser(id: string): Promise<void> {
    return this.request('DELETE', `/users/${id}`);
  }
}

class ApiError extends Error {
  constructor(public status: number, public body: string) {
    super(`API Error ${status}: ${body}`);
  }
}

// Usage — everything is typed
const api = new ApiClient('https://api.example.com/v1', process.env.API_KEY!);

const users = await api.getUsers();
// users.data[0].name → string ✅
// users.data[0].phone → TypeScript error ❌

const newUser = await api.createUser({
  name: 'Jane',
  email: 'jane@example.com',
  // phone: '555-0123', → TypeScript error ❌ (not in CreateUserInput)
});

Problem: TypeScript trusts you. If the API actually returns { full_name: "Jane" } instead of { name: "Jane" }, TypeScript won't catch it. You need runtime validation.

Level 2: Runtime Validation with Zod

import { z } from 'zod';

// Define schemas that validate at runtime AND generate types
const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
  role: z.enum(['user', 'admin', 'editor']),
});

const CreateUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
});

const ApiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
  z.object({
    data: dataSchema,
    meta: z.object({
      total: z.number(),
      page: z.number(),
    }).optional(),
  });

// Types are inferred from schemas — single source of truth
type User = z.infer<typeof UserSchema>;
type CreateUserInput = z.infer<typeof CreateUserSchema>;

// Validated API client
class ValidatedApiClient {
  constructor(private baseUrl: string, private apiKey: string) {}

  private async request<T>(
    method: string,
    path: string,
    schema: z.ZodType<T>,
    body?: unknown
  ): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      method,
      headers: {
        'Authorization': `Bearer ${this.apiKey}`,
        'Content-Type': 'application/json',
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    if (!response.ok) {
      throw new ApiError(response.status, await response.text());
    }

    const json = await response.json();

    // Validate response matches expected schema
    const result = schema.safeParse(json);
    if (!result.success) {
      console.error('API response validation failed:', {
        path,
        errors: result.error.issues,
        received: json,
      });
      throw new ApiValidationError(path, result.error);
    }

    return result.data;
  }

  async getUsers() {
    return this.request(
      'GET',
      '/users',
      ApiResponseSchema(z.array(UserSchema))
    );
  }

  async getUser(id: string) {
    return this.request('GET', `/users/${id}`, UserSchema);
  }

  async createUser(input: CreateUserInput) {
    // Validate input before sending
    CreateUserSchema.parse(input);
    return this.request('POST', '/users', UserSchema, input);
  }
}

class ApiValidationError extends Error {
  constructor(public path: string, public zodError: z.ZodError) {
    super(`API response validation failed for ${path}`);
  }
}

Now you get: Compile-time type checking AND runtime validation. If the API changes its response format, you get a clear error immediately.

Level 3: OpenAPI Codegen

Generate the entire client from an OpenAPI spec:

# Install
npm install -D openapi-typescript openapi-fetch

# Generate types from OpenAPI spec
npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts
// Generated types from OpenAPI spec
import createClient from 'openapi-fetch';
import type { paths } from './schema';

const api = createClient<paths>({
  baseUrl: 'https://api.example.com/v1',
  headers: { Authorization: `Bearer ${API_KEY}` },
});

// Fully typed — paths, methods, parameters, responses all inferred
const { data, error } = await api.GET('/users/{id}', {
  params: { path: { id: '123' } },
});
// data is typed as paths['/users/{id}']['get']['responses']['200']['content']['application/json']

const { data: newUser } = await api.POST('/users', {
  body: { name: 'Jane', email: 'jane@example.com' },
});

// TypeScript errors:
// api.GET('/nonexistent') → path doesn't exist
// api.POST('/users', { body: { invalid: true } }) → wrong body shape
// data.nonExistentField → property doesn't exist

Level 4: End-to-End Type Safety

Share types between your API and client (for APIs you own):

// shared/types.ts — shared between server and client
export interface User {
  id: string;
  name: string;
  email: string;
}

export interface CreateUserInput {
  name: string;
  email: string;
}

// Or with tRPC — full stack type safety, zero codegen

// server/router.ts
import { z } from 'zod';
import { router, publicProcedure } from './trpc';

export const appRouter = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      return db.users.findById(input.id);
    }),

  createUser: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return db.users.create(input);
    }),
});

export type AppRouter = typeof appRouter;

// client/api.ts
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from '../server/router';

const trpc = createTRPCClient<AppRouter>({ /* config */ });

// Types flow from server to client automatically
const user = await trpc.getUser.query({ id: '123' });
// user.name → string ✅
// user.nonExistent → TypeScript error ❌

const newUser = await trpc.createUser.mutate({
  name: 'Jane',
  email: 'jane@example.com',
});
// Argument validation happens on both client and server

Utility Patterns

Generic Paginated Fetcher

async function fetchAllPages<T>(
  fetcher: (cursor?: string) => Promise<{
    data: T[];
    nextCursor?: string;
    hasMore: boolean;
  }>
): Promise<T[]> {
  const allResults: T[] = [];
  let cursor: string | undefined;
  let hasMore = true;

  while (hasMore) {
    const page = await fetcher(cursor);
    allResults.push(...page.data);
    cursor = page.nextCursor;
    hasMore = page.hasMore;
  }

  return allResults;
}

// Type-safe usage
const allUsers = await fetchAllPages<User>(async (cursor) => {
  const response = await api.GET('/users', {
    params: { query: { cursor, limit: 100 } },
  });
  return response.data!;
});
// allUsers is User[] — fully typed

Type-Safe Error Handling

// Define error types
type ApiErrorCode =
  | 'NOT_FOUND'
  | 'UNAUTHORIZED'
  | 'VALIDATION_ERROR'
  | 'RATE_LIMITED'
  | 'SERVER_ERROR';

interface TypedApiError {
  code: ApiErrorCode;
  message: string;
  status: number;
  details?: Record<string, string[]>;
}

// Result type — forces handling both success and error
type Result<T, E = TypedApiError> =
  | { success: true; data: T }
  | { success: false; error: E };

async function safeApiCall<T>(fn: () => Promise<T>): Promise<Result<T>> {
  try {
    const data = await fn();
    return { success: true, data };
  } catch (error) {
    if (error instanceof ApiError) {
      return {
        success: false,
        error: {
          code: mapStatusToCode(error.status),
          message: error.body,
          status: error.status,
        },
      };
    }
    return {
      success: false,
      error: { code: 'SERVER_ERROR', message: 'Unknown error', status: 500 },
    };
  }
}

// Usage — TypeScript forces you to handle errors
const result = await safeApiCall(() => api.getUser('123'));

if (result.success) {
  console.log(result.data.name); // TypeScript knows data exists
} else {
  console.error(result.error.code); // TypeScript knows error exists
}

Type Safety Comparison

ApproachCompile-Time SafetyRuntime SafetyEffortBest For
Manual typesLowQuick prototyping
Zod validationMediumThird-party APIs
OpenAPI codegenLow (automated)APIs with OpenAPI spec
OpenAPI + ZodMediumMaximum safety
tRPCLowAPIs you own

Common Mistakes

MistakeImpactFix
Using any in API callsNo type safetyUse generics and explicit types
Trusting API responses without validationRuntime crashesAdd Zod validation
Hand-writing types for OpenAPI-documented APIsTypes drift from realityUse codegen from OpenAPI spec
Not validating inputs before sendingWasted API callsValidate with Zod before fetch
Casting with as instead of validatingHides type mismatchesUse type guards or Zod parse
Not generating types in CITypes go staleRun codegen in CI pipeline

Find APIs with TypeScript SDKs and OpenAPI specs on APIScout — SDK quality ratings, type safety support, and codegen compatibility.

Comments