| |

TypeScript 53 🔷 Type-Safe API Clients

An API client is the layer of code that talks to a backend. It builds the request, sends it, parses the response, and hands the result back to the caller. In an untyped client, the request URL is a string, the response is any, and a typo in the path, a missing field, or a changed response shape is discovered at runtime. In a typed client, the endpoint is a key in a type, the request and response shapes are declared, and the compiler rejects the mismatch before the code runs. The challenge is that the API is external — TypeScript cannot inspect the server — so the types must be written or generated and kept in sync. This chapter covers the design of a type-safe API client: how to model endpoints, how to type requests and responses, how to handle errors, how to keep the types in sync with the server, and the patterns that make the client maintainable as the API evolves.

Key point: A type-safe API client is built from an endpoint map — an interface whose keys are endpoint names and whose values describe the request and response for each. The client is generic over the map, and its methods are typed so that the request body and the response type are both checked. The types are a contract with the server, and the contract must be maintained: either written by hand from the API documentation, or generated from an OpenAPI specification, or inferred from a shared schema like tRPC or Zod. The client is only as correct as the contract, and keeping the contract in sync is the ongoing work.


Why API clients need types

The network is the boundary where types are lost. The server sends JSON, which is just bytes with a shape the client assumes. TypeScript cannot verify the shape at runtime, so the type is a claim the developer makes.

The cost of an untyped client. The response is any, so every access is unchecked. A typo in a field name produces undefined at runtime. A changed response shape produces a crash at the point of use. An endpoint that does not exist produces a 404 that is handled as a generic error. None of these are caught before the code runs.

What types buy. A typed client catches the mismatches at compile time: the field that does not exist, the request body missing a required field, the endpoint that is not in the map. The compiler’s error is cheaper than the runtime error because it appears before the code is deployed.

Why the types are a claim, not a guarantee. TypeScript’s types are erased at runtime. A response typed as User is not validated to be a User — the type is a claim the server is trusted to honor. If the server changes without the client being updated, the type is wrong and the error appears at runtime. Validation — with Zod, io-ts, or a similar library — is what turns the claim into a guarantee.

Why the contract must be maintained. The types live in the client, but the truth lives in the server. Keeping them in sync is the ongoing work: either the types are generated from an OpenAPI spec, or the server and client share a schema (tRPC), or the types are written by hand and updated when the API changes. The first two are more reliable because they are automated; the third is simpler but drifts.

Why generated types are preferred for public APIs. A public API evolves on its own schedule, and the documentation is the source of truth. Generating types from the OpenAPI spec means the client is updated when the spec is, and the drift is caught. Hand-written types for a public API are a maintenance burden that grows with the API surface.


Modeling endpoints

The endpoint map is the contract. Each key is an endpoint name, and each value describes the request and response.

interface ApiEndpoints {
  getUser: {
    method: 'GET';
    path: '/users/:id';
    params: { id: string };
    response: User;
  };
  createUser: {
    method: 'POST';
    path: '/users';
    body: { name: string; email: string };
    response: User;
  };
  listUsers: {
    method: 'GET';
    path: '/users';
    query: { page?: number; limit?: number };
    response: User[];
  };
}

Each endpoint declares its HTTP method, its path, its request inputs (path params, query, body), and its response type. The map is the specification — every endpoint the client knows about is listed, and the compiler checks the calls against it.

Why the method is a literal type. The method is 'GET', 'POST', 'PUT', 'PATCH', or 'DELETE', and the literal type means the client can dispatch on it. The method is part of the contract, and the client uses it to decide how to build the request.

Why the path uses placeholders. The path is a template with :id placeholders. The client substitutes the values from params, and the compiler can check that every placeholder has a corresponding param. The path is a string literal type, and the template is parsed at the type level.

Why the request inputs are separated. Path params, query parameters, and the body are different shapes with different encodings. Separating them in the type means the client knows where each goes: params into the path, query into the query string, body into the request body.

Why the response is a single type. The response is what the endpoint returns on success. Errors are handled separately, because every endpoint can fail and the failure shape is usually shared.

Why the map should be complete. Every endpoint the client uses should be in the map. An endpoint that is called but not declared is a compile error, which is the point — the map is the registry of what the client can do.


A typed client

The client is generic over the endpoint map. Its methods accept an endpoint name and the inputs, and return the response type.

type EndpointMap = Record<string, {
  method: string;
  path: string;
  params?: Record<string, string>;
  query?: Record<string, unknown>;
  body?: unknown;
  response: unknown;
}>;

type EndpointKey<T extends EndpointMap> = string & keyof T;
type ResponseOf<T extends EndpointMap, K extends EndpointKey<T>> = T[K]['response'];
type ParamsOf<T extends EndpointMap, K extends EndpointKey<T>> =
  T[K] extends { params: infer P } ? P : undefined;
type BodyOf<T extends EndpointMap, K extends EndpointKey<T>> =
  T[K] extends { body: infer B } ? B : undefined;

The helper types extract the response, params, and body from an endpoint. ResponseOf gives the response type, ParamsOf gives the path parameters or undefined, and BodyOf gives the body type or undefined. These are the building blocks for the client’s method signatures.

class ApiClient<T extends EndpointMap> {
  constructor(private readonly baseUrl: string) {}

  async call<K extends EndpointKey<T>>(
    endpoint: K,
    ...args: ParamsOf<T, K> extends undefined
      ? BodyOf<T, K> extends undefined
        ? []
        : [body: BodyOf<T, K>]
      : [params: ParamsOf<T, K>, body?: BodyOf<T, K>]
  ): Promise<ResponseOf<T, K>> {
    const config = this.endpointConfig(endpoint);
    const url = this.buildUrl(config, args[0] as Record<string, string> | undefined);

    const response = await fetch(url, {
      method: config.method,
      headers: { 'Content-Type': 'application/json' },
      body: config.body && args[1] ? JSON.stringify(args[1]) : undefined,
    });

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

    return response.json() as Promise<ResponseOf<T, K>>;
  }

  private endpointConfig<K extends EndpointKey<T>>(endpoint: K): T[K] {
    return endpoints[endpoint] as T[K];
  }

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

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

The call method takes the endpoint name and the inputs, builds the URL from the path and params, sends the request, and returns the response typed as ResponseOf<T, K>. The arguments are typed by the conditional tuple, so an endpoint with no params and no body takes no arguments, an endpoint with a body takes the body, and an endpoint with params takes the params.

Why the arguments are a conditional tuple. The args parameter is typed as a tuple whose shape depends on whether the endpoint has params and a body. This is the technique from TypeScript 50 (the typed event emitter), applied to the API client. It makes the call sites natural: client.call('listUsers') takes no arguments, client.call('createUser', { name, email }) takes the body, and client.call('getUser', { id: '1' }) takes the params.

Why the response is cast. The response.json() returns Promise<any>, and the cast to ResponseOf<T, K> is the claim that the server returned the expected shape. The cast is localized to the client, and the caller receives the typed response. Validation, if used, would happen before the cast.

Why the client is a class. The client holds the base URL and the endpoint configuration, and the methods operate on that state. A class is the natural shape. A functional version is possible but the class is clearer.

Why ApiError carries the status. The status is the primary discriminator for error handling — 401 triggers a refresh, 404 is treated as absence, 500 is a server error. The error class carries the status and the raw body, and the caller can decide how to handle each.


Request and response validation

The cast in the client is a claim. To make it a guarantee, the response should be validated against a schema.

import { z } from 'zod';

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

type User = z.infer<typeof UserSchema>;

The Zod schema defines the shape at runtime and produces the TypeScript type. The type is inferred from the schema, so the two cannot drift — changing the schema changes the type.

async call<K extends EndpointKey<T>>(...): Promise<ResponseOf<T, K>> {
  // ...
  const data = await response.json();
  const schema = this.responseSchema(endpoint);
  if (schema) {
    return schema.parse(data) as ResponseOf<T, K>;
  }
  return data as ResponseOf<T, K>;
}

The parse method validates the data and throws if it does not match. The return type is the schema’s inferred type, which matches the declared response. If the server returns a different shape, the validation fails with a descriptive error.

Why validation matters at the boundary. The response from the server is untrusted input. A field may be missing, a type may have changed, the server may have returned an error page instead of JSON. Validation catches these at the boundary, where the error can be handled, rather than deep in the application where the wrong type causes a crash.

Why Zod is the common choice. Zod provides runtime validation and TypeScript inference from the same schema. The schema is the single source of truth — the type is derived from it, and the validation enforces it. Alternatives like io-ts, Valibot, and ArkType serve the same purpose with different tradeoffs.

Why validation has a cost. Parsing the response against a schema is work the client does on every request. For a large response, the cost is measurable. The tradeoff is between the safety of validation and the performance of trusting the server. For most applications, the validation is worth it; for a high-throughput path, it may not be.

Why the schema should match the type. The schema and the type must agree. Zod’s z.infer derives the type from the schema, which guarantees agreement. If the type is written by hand and the schema separately, the two can drift, and the validation passes while the type is wrong. Deriving one from the other is the safe pattern.


Keeping types in sync with the server

The types are a claim about the server, and the claim must be maintained. Three approaches cover the common cases.

Hand-written types. The types are written from the API documentation. This is the simplest approach and the one with the most drift. The types are updated when someone notices the server changed, which is after the fact.

Generated types from OpenAPI. The server publishes an OpenAPI specification, and a tool generates the TypeScript types from it. The types are updated when the spec is, and the drift is caught at generation time. openapi-typescript generates types from an OpenAPI document, and openapi-fetch builds a client on top of them.

npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts

The generated file contains the types for every endpoint, and the client uses them. Regenerating on spec changes is the maintenance step, and it can be automated in CI.

Shared schema with tRPC. tRPC takes a different approach: the server and client share the TypeScript types directly, and the client’s calls are typed by the server’s procedure signatures. There is no spec and no generation — the types flow from the server to the client through the import.

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

// Client
const user = await trpc.getUser.query({ id: '1' });
// user is typed from the server's return type

tRPC requires both ends to be TypeScript and in the same project or a monorepo. When that is the case, it eliminates the drift entirely. When the server is not TypeScript or is in a different language, it does not apply.

Why the choice depends on the constraints. A public API with a non-TypeScript backend needs OpenAPI. A full-stack TypeScript monorepo can use tRPC. A small internal API with a stable shape may be fine with hand-written types. The right choice is the one that keeps the drift low with the least overhead.

Why drift is the real problem. The types are only useful if they match the server. A type that is wrong is worse than no type, because it gives false confidence. The maintenance strategy — generation, sharing, or manual — is what determines whether the types stay correct.

Why validation and generation are complementary. Generation keeps the types in sync with the spec. Validation catches the cases where the server does not honor the spec — a bug, a deployment mismatch, a proxy that returns an error page. The two together are the full protection: the types are correct by construction, and the runtime catches the exceptions.


Error handling in the client

An API call can fail in several ways: a network error, a non-2xx response, a response that does not match the schema. Each should be distinguishable and handleable.

class ApiError extends Error {
  constructor(
    readonly kind: 'network' | 'http' | 'validation',
    readonly status: number | null,
    readonly body: unknown,
  ) {
    super(`${kind} error${status ? ` ${status}` : ''}`);
  }
}

async call<K extends EndpointKey<T>>(...): Promise<ResponseOf<T, K>> {
  let response: Response;
  try {
    response = await fetch(url, { ... });
  } catch (error) {
    throw new ApiError('network', null, error);
  }

  if (!response.ok) {
    throw new ApiError('http', response.status, await response.json().catch(() => null));
  }

  const data = await response.json();
  const schema = this.responseSchema(endpoint);
  if (schema) {
    const result = schema.safeParse(data);
    if (!result.success) {
      throw new ApiError('validation', response.status, result.error);
    }
    return result.data as ResponseOf<T, K>;
  }
  return data as ResponseOf<T, K>;
}

The error kind distinguishes the three failure modes. The status is the HTTP status for HTTP and validation errors, and null for network errors. The body carries the raw response or the validation error.

Why the kind matters. Network errors are usually retryable and often indicate a connection problem. HTTP errors are the server’s response and carry a status that classifies them. Validation errors indicate a contract mismatch — the server returned something the client did not expect. Each requires different handling.

Why safeParse is used instead of parse. safeParse returns a result object instead of throwing, which lets the client wrap the validation error in its own ApiError type. The caller gets a consistent error type from the client, regardless of the underlying cause.

Why the error should carry the raw body. The raw body is often a structured error from the server — a validation error with field-level details, an error code, a message. The caller can read it to show the user or to decide on a recovery action. Discarding it makes the error harder to handle.

Why the client should not swallow errors. The client throws, and the caller decides. Swallowing an error in the client hides the failure and makes it invisible. The client’s job is to send the request and report the result; the decision about what to do with a failure is the caller’s.


Complete Example Session

// ============================================
// PART 1: RESPONSE SCHEMAS
// ============================================

import { z } from 'zod';

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

type User = z.infer<typeof UserSchema>;

// ============================================
// PART 2: ENDPOINT MAP
// ============================================

interface ApiEndpoints {
  getUser: {
    method: 'GET';
    path: '/users/:id';
    params: { id: string };
    response: User;
  };
  createUser: {
    method: 'POST';
    path: '/users';
    body: { name: string; email: string };
    response: User;
  };
  listUsers: {
    method: 'GET';
    path: '/users';
    query: { page?: number };
    response: User[];
  };
  deleteUser: {
    method: 'DELETE';
    path: '/users/:id';
    params: { id: string };
    response: void;
  };
}

// ============================================
// PART 3: HELPER TYPES
// ============================================

type EndpointMap = Record<string, {
  method: string;
  path: string;
  params?: Record<string, string>;
  query?: Record<string, unknown>;
  body?: unknown;
  response: unknown;
}>;

type EndpointKey<T extends EndpointMap> = string & keyof T;
type ResponseOf<T extends EndpointMap, K extends EndpointKey<T>> = T[K]['response'];
type ParamsOf<T extends EndpointMap, K extends EndpointKey<T>> =
  T[K] extends { params: infer P } ? P : undefined;
type BodyOf<T extends EndpointMap, K extends EndpointKey<T>> =
  T[K] extends { body: infer B } ? B : undefined;

// ============================================
// PART 4: THE CLIENT
// ============================================

class ApiError extends Error {
  constructor(
    readonly kind: 'network' | 'http' | 'validation',
    readonly status: number | null,
    readonly body: unknown,
  ) {
    super(`${kind} error${status ? ` ${status}` : ''}`);
  }
}

class ApiClient<T extends EndpointMap> {
  constructor(private readonly baseUrl: string) {}

  async call<K extends EndpointKey<T>>(
    endpoint: K,
    ...args: ParamsOf<T, K> extends undefined
      ? BodyOf<T, K> extends undefined
        ? []
        : [body: BodyOf<T, K>]
      : [params: ParamsOf<T, K>, body?: BodyOf<T, K>]
  ): Promise<ResponseOf<T, K>> {
    // implementation in the next parts
    throw new Error('not implemented');
  }
}

// ============================================
// PART 5: USAGE
// ============================================

const api = new ApiClient<ApiEndpoints>('https://api.example.com');

// No params, no body
const users = await api.call('listUsers');
// users is User[]

// Params only
const user = await api.call('getUser', { id: '1' });
// user is User

// Body only
const created = await api.call('createUser', {
  name: 'Alice',
  email: 'alice@example.com',
});
// created is User

// Params only, void response
await api.call('deleteUser', { id: '1' });

// ============================================
// PART 6: COMPILE-TIME ERRORS
// ============================================

// api.call('getUser');
// ❌ Expected 2 arguments, but got 1

// api.call('getUser', { id: 1 });
// ❌ Type 'number' is not assignable to type 'string'

// api.call('createUser', { name: 'Alice' });
// ❌ Property 'email' is missing

// api.call('nonexistent');
// ❌ Argument of type '"nonexistent"' is not assignable

// ============================================
// PART 7: ERROR HANDLING
// ============================================

try {
  const user = await api.call('getUser', { id: '1' });
  console.log(user.name);
} catch (error) {
  if (error instanceof ApiError) {
    if (error.kind === 'network') {
      console.error('Check your connection');
    } else if (error.kind === 'http' && error.status === 404) {
      console.error('User not found');
    } else if (error.kind === 'validation') {
      console.error('Unexpected response shape', error.body);
    }
  }
}

// ============================================
// PART 8: OPENAPI GENERATION
// ============================================

// npx openapi-typescript https://api.example.com/openapi.json -o src/api/schema.d.ts

// The generated schema.d.ts contains types for every endpoint.
// A client like openapi-fetch uses them.

// ============================================
// PART 9: tRPC ALTERNATIVE
// ============================================

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

// Client:
// const user = await trpc.getUser.query({ id: '1' });
// user is typed from the server's return type.

// ============================================
// PART 10: WHAT NOT TO DO
// ============================================

// Don't use fetch directly with string URLs
// const data = await fetch('/api/users').then(r => r.json());
// data is any

// Don't trust the response without validation
// const user = await response.json() as User;
// The cast is a claim, not a check.

// Don't write hand-written types for a large public API
// They drift.

// Don't ignore the error kind
// Network, HTTP, and validation errors are different.

The ten parts cover schemas, the endpoint map, the helper types, the client, usage, compile-time errors, error handling, OpenAPI generation, tRPC, and the anti-patterns.


Quick Reference

Endpoint Map

FieldPurpose
methodHTTP method
pathURL template with :params
paramsPath parameters
queryQuery string parameters
bodyRequest body
responseSuccess response type

Helper Types

TypePurpose
EndpointKey<T>Union of endpoint names
ResponseOf<T, K>Response type for an endpoint
ParamsOf<T, K>Params type or undefined
BodyOf<T, K>Body type or undefined

Error Kinds

KindCauseStatus
networkConnection failednull
httpNon-2xx responseHTTP status
validationSchema mismatchHTTP status

Sync Strategies

StrategyWhen
Hand-writtenSmall, stable API
OpenAPI generatedPublic API, any backend
tRPCFull-stack TypeScript monorepo
Shared Zod schemaShared validation

Validation Libraries

LibraryPurpose
ZodSchema + inference
io-tsSchema + inference
ValibotLightweight schema
ArkTypeFast schema
AjvJSON Schema validation

Best Practices

✅ Do This:

// Define the endpoint map as the contract
interface ApiEndpoints { getUser: { method: 'GET'; path: '/users/:id'; ... } } // ✅

// Derive the response type from the schema
type User = z.infer<typeof UserSchema>;                        // ✅

// Use the conditional tuple for arguments
...args: ParamsOf<T, K> extends undefined ? [] : [params: ParamsOf<T, K>] // ✅

// Validate the response at the boundary
const result = schema.safeParse(data);                         // ✅

// Distinguish error kinds
if (error.kind === 'network') { ... }                          // ✅

// Generate types from OpenAPI
// npx openapi-typescript ...                                  // ✅

// Use tRPC when both ends are TypeScript
const user = await trpc.getUser.query({ id: '1' });            // ✅

❌ Don’t Do This:

// Don't fetch with raw strings and cast the response
const data = await fetch('/api/users').then(r => r.json()) as User[]; // ⚠️

// Don't write hand-written types for a large API
// They drift and give false confidence                       // ⚠️

// Don't skip validation on untrusted responses
// The cast is a claim, not a check                           // ⚠️

// Don't swallow errors in the client
catch (e) { return null; }  // hides the failure              // ⚠️

// Don't use `any` for the response
const data: any = await response.json();                       // ⚠️

// Don't ignore the status in error handling
// 401, 404, and 500 are different                           // ⚠️

// Don't assume the schema and type agree
// Derive one from the other                                  // ⚠️

Common Pitfalls

PitfallProblemSolution
Response cast without validationRuntime crashValidate with a schema
Hand-written types driftWrong typesGenerate from OpenAPI
any responseNo checkingType the response
Missing endpoint in the mapCompile errorAdd to the map
Wrong argument shapeCompile errorConditional tuple
Swallowed errorSilent failureThrow and handle
Status ignoredWrong handlingDistinguish by status
Schema and type driftFalse confidenceDerive type from schema

Real-World Examples

1. Endpoint definition

getUser: { method: 'GET'; path: '/users/:id'; params: { id: string }; response: User }

2. Typed call

const user = await api.call('getUser', { id: '1' });

3. Response schema

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

4. Validation

const result = UserSchema.safeParse(data);

5. Error kinds

class ApiError { constructor(readonly kind: 'network' | 'http' | 'validation') {} }

6. Network error

catch (e) { throw new ApiError('network', null, e); }

7. HTTP error

if (!response.ok) throw new ApiError('http', response.status, await response.json());

8. OpenAPI generation

npx openapi-typescript https://api.example.com/openapi.json -o schema.d.ts

9. tRPC query

const user = await trpc.getUser.query({ id: '1' });

10. Typed list

const users = await api.call('listUsers');
// users is User[]

Visual: The Endpoint Map

┌──────────────────────────────────────────────────────────┐
│  interface ApiEndpoints {                                │
│    getUser: {                                            │
│      method:   'GET'                                     │
│      path:     '/users/:id'                              │
│      params:   { id: string }                            │
│      response: User                                      │
│    }                                                     │
│    createUser: {                                         │
│      method:   'POST'                                    │
│      path:     '/users'                                  │
│      body:     { name: string; email: string }           │
│      response: User                                      │
│    }                                                     │
│  }                                                       │
│                                                          │
│  The map is the contract.                                │
│  The compiler checks every call against it.              │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: The Client Call

┌──────────────────────────────────────────────────────────┐
│  api.call('getUser', { id: '1' })                        │
│    │                                                     │
│    ├── K = 'getUser'                                     │
│    ├── ParamsOf<T, K> = { id: string }                   │
│    ├── BodyOf<T, K> = undefined                          │
│    └── ResponseOf<T, K> = User                           │
│                                                          │
│  Arguments:  [params: { id: string }]                    │
│  Return:     Promise<User>                               │
│                                                          │
│  The types flow from the endpoint map.                   │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  api.call('createUser', { name: 'A', email: 'a@b.c' })   │
│    │                                                     │
│    ├── ParamsOf<T, K> = undefined                        │
│    ├── BodyOf<T, K> = { name: string; email: string }    │
│    └── ResponseOf<T, K> = User                           │
│                                                          │
│  Arguments:  [body: { name: string; email: string }]     │
│  Return:     Promise<User>                               │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Validation at the Boundary

┌──────────────────────────────────────────────────────────┐
│  SERVER                                                  │
│    │                                                     │
│    │  JSON response                                      │
│    ▼                                                     │
│  CLIENT                                                  │
│    │                                                     │
│    ├── response.json() ──► unknown                       │
│    │                                                     │
│    ├── schema.safeParse(data)                            │
│    │     │                                               │
│    │     ├── success ──► typed data                      │
│    │     │                                               │
│    │     └── failure ──► ApiError('validation')          │
│    │                                                     │
│    └── return typed data                                 │
│                                                          │
│  Validation turns the claim into a guarantee.            │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Error Kinds

┌──────────────────────────────────────────────────────────┐
│  NETWORK                                                 │
│    fetch() threw                                         │
│    status: null                                          │
│    Handle: retry, show connection message                │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  HTTP                                                    │
│    response.ok === false                                 │
│    status: 4xx or 5xx                                    │
│    Handle: 401 refresh, 404 absence, 500 generic         │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  VALIDATION                                              │
│    schema.safeParse failed                               │
│    status: the HTTP status                               │
│    Handle: log, alert, contract mismatch                 │
│                                                          │
│  Each kind requires different handling.                  │
│                                                          │
└──────────────────────────────────────────────────────────┘

Visual: Sync Strategies

┌──────────────────────────────────────────────────────────┐
│  HAND-WRITTEN                                            │
│    Types from documentation                              │
│    Drift: high                                           │
│    Effort: low initially, grows with API                 │
│    Use: small, stable API                                │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  OPENAPI GENERATED                                       │
│    Types from the spec                                   │
│    Drift: caught at generation                           │
│    Effort: automation in CI                              │
│    Use: public API, any backend                          │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  tRPC                                                    │
│    Types shared from server                              │
│    Drift: none                                           │
│    Effort: requires TypeScript both ends                 │
│    Use: full-stack TypeScript monorepo                   │
│                                                          │
├──────────────────────────────────────────────────────────┤
│  SHARED SCHEMA                                           │
│    Zod schema shared                                     │
│    Drift: caught at validation                           │
│    Effort: shared package                                │
│    Use: shared validation between ends                   │
│                                                          │
└──────────────────────────────────────────────────────────┘

Summary

ItemValue
Endpoint mapContract with the server
Helper typesResponseOf, ParamsOf, BodyOf
ClientGeneric over the map
ArgumentsConditional tuple
ResponseTyped and optionally validated
ErrorsNetwork, HTTP, validation
SyncHand-written, OpenAPI, tRPC, shared schema
ValidationZod, io-ts, Valibot
Compile-timeEndpoint, params, body, response
RuntimeValidation, error kinds

Key takeaways:

  • The endpoint map is the contract — each key is an endpoint, and each value declares the method, path, params, body, and response
  • The client is generic over the map — the helper types extract the response, params, and body, and the call signature is typed by the endpoint
  • The conditional tuple makes the call sites natural — an endpoint with no params and no body takes no arguments, one with a body takes the body, one with params takes the params
  • The response type is a claim, not a guarantee — TypeScript’s types are erased at runtime, so the server must be trusted or the response must be validated
  • Validation turns the claim into a guarantee — a Zod schema checks the response at the boundary, where the error can be handled
  • Deriving the type from the schema keeps them in sync — z.infer guarantees that the type and the runtime check agree
  • The three error kinds are different — network, HTTP, and validation errors require different handling, and the client should distinguish them
  • The error should carry the raw body — the server’s structured error is often the most useful information, and discarding it makes handling harder
  • Keeping the types in sync is the ongoing work — hand-written types drift, OpenAPI generated types are caught at generation, and tRPC eliminates the drift when both ends are TypeScript
  • The client is only as correct as the contract — a wrong type is worse than no type because it gives false confidence, and the maintenance strategy is what determines whether the types stay correct

Remember: A type-safe API client is a contract with the server, expressed as an endpoint map, enforced by the compiler at every call site. The types are a claim about the server, and the claim must be maintained — by generation, by sharing, or by hand. Validation is the runtime check that catches the cases where the server does not honor the claim. The client is small, and the discipline of keeping the contract in sync is the real work.


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!