A TypeScript pattern for creating type-safe API clients where request parameters and response types are automatically inferred from a route definition object, eliminating the need for code generation tools.
// Route definition types
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
interface RouteDefinition {
method: HttpMethod;
path: string;
params?: Record<string, unknown>;
query?: Record<string, unknown>;
body?: unknown;
response: unknown;
}
type RouteMap = Record<string, RouteDefinition>;
// Extract path parameters from a path string like '/users/:id/posts/:postId'
type ExtractPathParams<T extends string> =
T extends `${string}:${infer Param}/${infer Rest}`
? Param | ExtractPathParams<`/${Rest}`>
: T extends `${string}:${infer Param}`
? Param
: never;
// Request configuration type based on route definition
type RequestConfig<TRoute extends RouteDefinition> =
(TRoute['params'] extends Record<string, unknown> ? { params: TRoute['params'] } : {}) &
(TRoute['query'] extends Record<string, unknown> ? { query: TRoute['query'] } : {}) &
(TRoute['body'] extends undefined ? {} : TRoute['body'] extends never ? {} : { body: TRoute['body'] });
// Check if config is required (has any required fields)
type IsConfigRequired<TRoute extends RouteDefinition> =
TRoute['params'] extends Record<string, unknown> ? true :
TRoute['body'] extends undefined ? false :
TRoute['body'] extends never ? false : true;
// API Client type with inferred methods
type ApiClient<TRoutes extends RouteMap> = {
[K in keyof TRoutes]: IsConfigRequired<TRoutes[K]> extends true
? (config: RequestConfig<TRoutes[K]>) => Promise<TRoutes[K]['response']>
: (config?: RequestConfig<TRoutes[K]>) => Promise<TRoutes[K]['response']>;
};
// Build URL with path parameters
function buildUrl(basePath: string, path: string, params?: Record<string, unknown>): string {
let url = `${basePath}${path}`;
if (params) {
for (const [key, value] of Object.entries(params)) {
url = url.replace(`:${key}`, encodeURIComponent(String(value)));
}
}
return url;
}
// Add query parameters to URL
function addQueryParams(url: string, query?: Record<string, unknown>): string {
if (!query || Object.keys(query).length === 0) return url;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
}
// Create the type-safe API client
function createApiClient<TRoutes extends RouteMap>(
routes: TRoutes,
options: { baseUrl: string; headers?: Record<string, string> }
): ApiClient<TRoutes> {
const client = {} as ApiClient<TRoutes>;
for (const [name, route] of Object.entries(routes)) {
(client as Record<string, unknown>)[name] = async (config?: {
params?: Record<string, unknown>;
query?: Record<string, unknown>;
body?: unknown;
}) => {
let url = buildUrl(options.baseUrl, route.path, config?.params);
url = addQueryParams(url, config?.query);
const fetchOptions: RequestInit = {
method: route.method,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
};
if (config?.body && ['POST', 'PUT', 'PATCH'].includes(route.method)) {
fetchOptions.body = JSON.stringify(config.body);
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error ${response.status}: ${error}`);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return response.json();
}
return response.text();
};
}
return client;
}
// ===== Example Usage =====
// Define your API schema
interface User {
id: number;
name: string;
email: string;
}
interface CreateUserInput {
name: string;
email: string;
}
interface Post {
id: number;
title: string;
content: string;
authorId: number;
}
interface PaginationQuery {
page?: number;
limit?: number;
}
// Define routes with full type information
const apiRoutes = {
getUsers: {
method: 'GET' as const,
path: '/users',
query: {} as PaginationQuery,
response: {} as User[],
},
getUser: {
method: 'GET' as const,
path: '/users/:id',
params: {} as { id: number },
response: {} as User,
},
createUser: {
method: 'POST' as const,
path: '/users',
body: {} as CreateUserInput,
response: {} as User,
},
updateUser: {
method: 'PUT' as const,
path: '/users/:id',
params: {} as { id: number },
body: {} as Partial<CreateUserInput>,
response: {} as User,
},
deleteUser: {
method: 'DELETE' as const,
path: '/users/:id',
params: {} as { id: number },
response: {} as { success: boolean },
},
getUserPosts: {
method: 'GET' as const,
path: '/users/:userId/posts',
params: {} as { userId: number },
query: {} as PaginationQuery,
response: {} as Post[],
},
} satisfies RouteMap;
// Create the client
const api = createApiClient(apiRoutes, {
baseUrl: 'https://api.example.com',
headers: {
'Authorization': 'Bearer token123',
},
});
// Usage examples with full type safety
async function examples() {
// GET request with path params - TypeScript enforces { params: { id: number } }
const user = await api.getUser({ params: { id: 1 } });
console.log(user.name); // user is typed as User
// GET request with query params
const users = await api.getUsers({ query: { page: 1, limit: 10 } });
console.log(users.length); // users is typed as User[]
// POST request with body
const newUser = await api.createUser({
body: { name: 'John Doe', email: 'john@example.com' },
});
console.log(newUser.id); // newUser is typed as User
// PUT request with params and body
const updated = await api.updateUser({
params: { id: 1 },
body: { name: 'Jane Doe' },
});
console.log(updated.email);
// GET with both params and query
const posts = await api.getUserPosts({
params: { userId: 1 },
query: { page: 1, limit: 5 },
});
console.log(posts[0].title); // posts is typed as Post[]
}
examples().catch(console.error);This pattern creates a type-safe API client by leveraging TypeScript's advanced type inference capabilities. The core idea is to define your API routes as a single object where each route specifies its HTTP method, path, parameters, body shape, and response type. The createApiClient function then generates a client object where each method is fully typed based on its corresponding route definition.
The type system works through several key type utilities. RouteDefinition establishes the shape of each route, while RequestConfig conditionally builds the required configuration type for each endpoint. If a route has params, the config must include them; if it has a body, that's required too. The IsConfigRequired type determines whether the configuration parameter should be optional (for simple GET requests with no params) or mandatory.
The runtime implementation in createApiClient iterates over the route definitions and creates a function for each one. These functions handle URL building with path parameter substitution, query string construction, and proper request body serialization. The buildUrl function replaces path parameters like :id with actual values, while addQueryParams safely constructs query strings with proper encoding.
One key design decision is using {} as TypeName for defining route schemas. This tells TypeScript 'this field has this type' without providing actual runtime values. The satisfies RouteMap constraint ensures your route definitions conform to the expected structure while preserving literal types for better inference. This approach avoids code generation while maintaining full type safety.
This pattern works best for small to medium APIs where you control the type definitions. For large OpenAPI specs, dedicated code generators like openapi-typescript might be more practical. However, this pattern shines when you want tight control over types, need to add custom transformations, or want to avoid build-step dependencies. Edge cases like file uploads, streaming responses, or non-JSON APIs would require extensions to the base pattern.