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.
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
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)
// 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
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.