Back to journal
Engineering

Build a Type-Safe API Client with TypeScript Generics

Stop casting API responses to 'any'. We'll build a fully type-safe HTTP client that catches errors at compile time — not at 3am in production.

PMML Engineering 4 May 2026 11 min read 0 views
Build a Type-Safe API Client with TypeScript Generics

Every TypeScript codebase has that one file — the API client with as any scattered across fetch calls. Today we fix that.

We'll build a type-safe HTTP client where your IDE autocompletes endpoint paths, validates request bodies, and infers response types — all at compile time.

The Problem

TypeScript code showing unsafe API call patterns

This is what most API clients look like:

// The "any" approach — compiles fine, crashes at runtime
const response = await fetch("/api/users");
const users = await response.json(); // type: any
console.log(users.map((u: any) => u.nmae)); // typo — no error!

No autocompletion. No error detection. You only find bugs when your users do.

What We're Building

An API client where:

// Full type safety — errors caught at compile time
const users = await api.get("/users");
// users is typed as User[]

const user = await api.post("/users", {
  body: { name: "Kofi", email: "kofi@pmml.dev" },
});
// TypeScript validates the body shape
// user is typed as User

await api.post("/users", {
  body: { nmae: "Kofi" }, // TS Error: 'nmae' not in CreateUserBody
});

Part 1: Define Your API Schema

First, describe your API as a type:

// api-schema.ts
type ApiSchema = {
  "/users": {
    GET: { response: User[] };
    POST: { body: CreateUserBody; response: User };
  };
  "/users/:id": {
    GET: { response: User };
    PUT: { body: UpdateUserBody; response: User };
    DELETE: { response: void };
  };
  "/posts": {
    GET: { response: Post[]; query: { page?: number; limit?: number } };
    POST: { body: CreatePostBody; response: Post };
  };
};

type User = {
  id: string;
  name: string;
  email: string;
  created_at: string;
};

type CreateUserBody = {
  name: string;
  email: string;
};

type UpdateUserBody = Partial<CreateUserBody>;

type Post = {
  id: string;
  title: string;
  body: string;
  author_id: string;
};

type CreatePostBody = {
  title: string;
  body: string;
};

Part 2: The Type-Safe Client (60 Lines)

Abstract representation of generic type constraints

// api-client.ts
type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" | "PATCH";

type ExtractRoutes<S, M extends HttpMethod> = {
  [K in keyof S]: M extends keyof S[K] ? K : never;
}[keyof S];

type RouteConfig<S, P extends keyof S, M extends HttpMethod> =
  M extends keyof S[P] ? S[P][M] : never;

type RequestOptions<C> = (C extends { body: infer B } ? { body: B } : {}) &
  (C extends { query: infer Q } ? { query: Q } : {});

type ResponseType<C> = C extends { response: infer R } ? R : unknown;

function buildUrl(path: string, params?: Record<string, string>): string {
  let url = path;
  if (params) {
    for (const [key, val] of Object.entries(params)) {
      url = url.replace(`:${key}`, encodeURIComponent(val));
    }
  }
  return url;
}

function buildQuery(query?: Record<string, unknown>): string {
  if (!query) return "";
  const params = new URLSearchParams();
  for (const [key, val] of Object.entries(query)) {
    if (val !== undefined && val !== null) {
      params.set(key, String(val));
    }
  }
  const str = params.toString();
  return str ? `?${str}` : "";
}

export function createApiClient<S>(baseUrl: string) {
  async function request<P extends keyof S, M extends HttpMethod>(
    method: M,
    path: P & string,
    options?: RequestOptions<RouteConfig<S, P, M>> & { params?: Record<string, string> }
  ): Promise<ResponseType<RouteConfig<S, P, M>>> {
    const url =
      baseUrl +
      buildUrl(path, options?.params) +
      buildQuery((options as Record<string, unknown>)?.query as Record<string, unknown>);

    const init: RequestInit = { method };
    if ("body" in (options ?? {})) {
      init.headers = { "Content-Type": "application/json" };
      init.body = JSON.stringify((options as Record<string, unknown>).body);
    }

    const res = await fetch(url, init);
    if (!res.ok) {
      throw new Error(`API ${method} ${path} failed: ${res.status}`);
    }
    if (res.status === 204) return undefined as ResponseType<RouteConfig<S, P, M>>;
    return res.json();
  }

  return {
    get: <P extends ExtractRoutes<S, "GET">>(
      path: P & string,
      options?: RequestOptions<RouteConfig<S, P, "GET">> & { params?: Record<string, string> }
    ) => request("GET", path, options),

    post: <P extends ExtractRoutes<S, "POST">>(
      path: P & string,
      options?: RequestOptions<RouteConfig<S, P, "POST">> & { params?: Record<string, string> }
    ) => request("POST", path, options),

    put: <P extends ExtractRoutes<S, "PUT">>(
      path: P & string,
      options?: RequestOptions<RouteConfig<S, P, "PUT">> & { params?: Record<string, string> }
    ) => request("PUT", path, options),

    delete: <P extends ExtractRoutes<S, "DELETE">>(
      path: P & string,
      options?: RequestOptions<RouteConfig<S, P, "DELETE">> & { params?: Record<string, string> }
    ) => request("DELETE", path, options),
  };
}

Part 3: Usage

const api = createApiClient<ApiSchema>("https://api.example.com");

// All of these are fully type-safe:
const users = await api.get("/users");
// Type: User[]

const user = await api.post("/users", {
  body: { name: "Kofi Asante", email: "kofi@pmml.dev" },
});
// Type: User

const posts = await api.get("/posts", {
  query: { page: 1, limit: 20 },
});
// Type: Post[]

// These would cause compile-time errors:
// api.get("/nonexistent");  // Error: not a valid path
// api.post("/users", { body: { wrong: true } });  // Error: body type mismatch
// api.delete("/users");  // Error: DELETE not defined for /users

How the Generics Work

Developer exploring TypeScript generics in an IDE

ExtractRoutes — Filter Paths by Method

type ExtractRoutes<S, M extends HttpMethod> = {
  [K in keyof S]: M extends keyof S[K] ? K : never;
}[keyof S];

// ExtractRoutes<ApiSchema, "DELETE"> = "/users/:id"
// because only /users/:id has a DELETE definition

This mapped type iterates every path in the schema. If the method exists on that path, it keeps the key. Otherwise it maps to never. The final [keyof S] union collapses the nevers.

Conditional Request Options

type RequestOptions<C> = (C extends { body: infer B } ? { body: B } : {}) &
  (C extends { query: infer Q } ? { query: Q } : {});

If the route config includes body, the options must include it. If not, the body key doesn't even appear as an option. Same for query. The intersection (&) combines both conditions.

ResponseType Inference

type ResponseType<C> = C extends { response: infer R } ? R : unknown;

Simple conditional inference — extract the response type from the config, or fall back to unknown.

Exercises

Exercise 1: Add Error Types

Extend the schema to type error responses:

type ApiSchema = {
  "/users": {
    POST: {
      body: CreateUserBody;
      response: User;
      error: { code: "EMAIL_TAKEN"; message: string } | { code: "VALIDATION"; fields: string[] };
    };
  };
};

Exercise 2: Add Middleware

Support request/response interceptors:

const api = createApiClient<ApiSchema>("https://api.example.com", {
  onRequest: (init) => {
    init.headers = {
      ...init.headers,
      Authorization: `Bearer ${getToken()}`,
    };
    return init;
  },
  onResponse: (res) => {
    if (res.status === 401) redirectToLogin();
    return res;
  },
});

Exercise 3: Runtime Validation with Zod

Pair compile-time types with runtime validation:

import { z } from "zod";

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

type User = z.infer<typeof UserSchema>;

// In the client, validate responses:
const raw = await res.json();
return UserSchema.parse(raw); // throws if shape doesn't match

Why This Matters

Type-safe API clients catch an entire category of bugs at compile time:

  • Misspelled field names — caught immediately
  • Wrong endpoint paths — IDE shows only valid options
  • Missing required fields — TypeScript won't let you forget
  • Wrong response assumptions — the type tells you exactly what you get

The 60 lines of generic code we wrote replace hundreds of hand-written type assertions. That's the power of TypeScript generics — write the pattern once, and the compiler does the work forever.

What API patterns does your team struggle with? Tell us at pmml.info@gmail.com.

PMML Engineering builds type-safe systems, web platforms, and AI products for teams that ship. Based in Accra, Ghana.

#typescript#generics#api#type-safety#tutorial

Keep reading

You might also like