This guide walks you through building a production-grade TypeScript API client with runtime schema validation, automatic retries, and structured error types. Following all steps takes roughly 45 to 60 minutes, and the finished client works in any Node.js 20+ or edge runtime project.

What You'll Build

  • A generic, typed ApiClient class that validates every response at runtime using Zod schemas
  • A structured error hierarchy that separates network failures, timeout errors, and schema violations
  • Automatic exponential-backoff retry logic with per-request configuration
  • A fully tree-shakeable module you can drop into any Next.js, Remix, or Node.js service
  • A test suite using Vitest and MSW 2 that covers the happy path and all three error branches

Prerequisites

  • Node.js 20 or later and pnpm 9 (or npm 10)
  • TypeScript 5.5 or later configured in your project
  • Basic familiarity with generics and async/await
  • Zod 3.23+ installed (pnpm add zod)

Step 1: Set Up the Project Structure

Create a dedicated src/lib/api folder. Keeping the client isolated makes it reusable across services without coupling it to any framework.

mkdir -p src/lib/api
touch src/lib/api/client.ts
touch src/lib/api/errors.ts
touch src/lib/api/retry.ts
touch src/lib/api/index.ts

Your folder should look like this after the commands run:

src/
  lib/
    api/
      client.ts
      errors.ts
      retry.ts
      index.ts

Why separate errors and retry into their own files?

Keeping error types and retry logic in their own modules lets you import only what you need. This keeps bundle size small in edge runtimes where every kilobyte matters.

Step 2: Define a Structured Error Hierarchy

Untyped catch (e) blocks are the main reason API clients become hard to debug in production. Define three specific error classes instead.

// src/lib/api/errors.ts

export class NetworkError extends Error {
  readonly type = 'NetworkError' as const
  constructor(message: string, public readonly cause?: unknown) {
    super(message)
    this.name = 'NetworkError'
  }
}

export class TimeoutError extends Error {
  readonly type = 'TimeoutError' as const
  constructor(public readonly timeoutMs: number) {
    super(`Request timed out after ${timeoutMs}ms`)
    this.name = 'TimeoutError'
  }
}

export class SchemaError extends Error {
  readonly type = 'SchemaError' as const
  constructor(
    message: string,
    public readonly issues: { path: (string | number)[]; message: string }[]
  ) {
    super(message)
    this.name = 'SchemaError'
  }
}

export type ApiError = NetworkError | TimeoutError | SchemaError

Each class carries a literal type discriminant. You can use a simple switch (error.type) in any calling code without any casting.

What is the common pitfall here?

Extending Error in TypeScript targets below ES2022 can break instanceof checks after transpilation. Add "target": "ES2022" or later in your tsconfig.json to avoid this.

Step 3: Build the Retry Utility

Write a small, generic retry wrapper before touching the client itself. Keeping retry logic separate means you can unit-test it in isolation.

// src/lib/api/retry.ts
import { NetworkError, TimeoutError } from './errors'

export interface RetryOptions {
  attempts: number
  baseDelayMs: number
  shouldRetry?: (error: unknown) => boolean
}

const defaultShouldRetry = (error: unknown): boolean =>
  error instanceof NetworkError || error instanceof TimeoutError

export async function withRetry(
  fn: () => Promise,
  options: RetryOptions
): Promise {
  const { attempts, baseDelayMs, shouldRetry = defaultShouldRetry } = options
  let lastError: unknown

  for (let attempt = 0; attempt < attempts; attempt++) {
    try {
      return await fn()
    } catch (error) {
      lastError = error
      if (!shouldRetry(error) || attempt === attempts - 1) throw error
      const delay = baseDelayMs * 2 ** attempt
      await new Promise((resolve) => setTimeout(resolve, delay))
    }
  }

  throw lastError
}

The delay doubles on each attempt: 200ms, 400ms, 800ms. Three attempts with a 200ms base gives a worst-case wait of 1.4 seconds before the final failure is thrown.

Step 4: Write the Core ApiClient Class

This is the main module. The client accepts a base URL and a default timeout, and exposes a single generic request method.

// src/lib/api/client.ts
import { z, ZodSchema } from 'zod'
import { NetworkError, TimeoutError, SchemaError } from './errors'
import { withRetry, RetryOptions } from './retry'

export interface RequestOptions {
  method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
  schema: ZodSchema
  body?: unknown
  headers?: Record
  timeoutMs?: number
  retry?: RetryOptions
}

export interface ApiClientConfig {
  baseUrl: string
  defaultTimeoutMs?: number
  defaultHeaders?: Record
}

export class ApiClient {
  private readonly baseUrl: string
  private readonly defaultTimeoutMs: number
  private readonly defaultHeaders: Record

  constructor(config: ApiClientConfig) {
    this.baseUrl = config.baseUrl.replace(/\/$/, '')
    this.defaultTimeoutMs = config.defaultTimeoutMs ?? 10_000
    this.defaultHeaders = config.defaultHeaders ?? {}
  }

  async request(
    path: string,
    options: RequestOptions
  ): Promise {
    const {
      method = 'GET',
      schema,
      body,
      headers = {},
      timeoutMs = this.defaultTimeoutMs,
      retry,
    } = options

    const execute = async (): Promise => {
      const controller = new AbortController()
      const timer = setTimeout(() => controller.abort(), timeoutMs)

      let response: Response
      try {
        response = await fetch(`${this.baseUrl}${path}`, {
          method,
          headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
            ...this.defaultHeaders,
            ...headers,
          },
          body: body !== undefined ? JSON.stringify(body) : undefined,
          signal: controller.signal,
        })
      } catch (error) {
        if (
          error instanceof DOMException &&
          error.name === 'AbortError'
        ) {
          throw new TimeoutError(timeoutMs)
        }
        throw new NetworkError('Fetch failed', error)
      } finally {
        clearTimeout(timer)
      }

      if (!response.ok) {
        throw new NetworkError(
          `HTTP ${response.status}: ${response.statusText}`
        )
      }

      const json: unknown = await response.json()
      const result = schema.safeParse(json)

      if (!result.success) {
        throw new SchemaError(
          'Response did not match expected schema',
          result.error.issues.map((i) => ({
            path: i.path,
            message: i.message,
          }))
        )
      }

      return result.data
    }

    if (retry) {
      return withRetry(execute, retry)
    }

    return execute()
  }

  get(path: string, options: Omit, 'method'>) {
    return this.request(path, { ...options, method: 'GET' })
  }

  post(path: string, options: Omit, 'method'>) {
    return this.request(path, { ...options, method: 'POST' })
  }
}

Why use AbortController instead of a race promise?

The Fetch API's native AbortController cancels the underlying network request, not just the JavaScript awaiting it. This prevents zombie requests from consuming server resources on timeout.

Step 5: Export a Public Interface

Re-export everything from the index file so consumers only need one import path.

// src/lib/api/index.ts
export { ApiClient } from './client'
export type { ApiClientConfig, RequestOptions } from './client'
export { withRetry } from './retry'
export type { RetryOptions } from './retry'
export { NetworkError, TimeoutError, SchemaError } from './errors'
export type { ApiError } from './errors'

Step 6: Use the Client in Your Application

Create a singleton instance at the application boundary and share it across your services. Avoid creating a new instance per request.

// src/lib/api/instance.ts
import { ApiClient } from './index'

export const apiClient = new ApiClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'https://api.example.com',
  defaultTimeoutMs: 8_000,
  defaultHeaders: {
    'X-App-Version': '2.0.0',
  },
})

Then use it with a Zod schema in any service file:

import { z } from 'zod'
import { apiClient } from '@/lib/api/instance'
import { SchemaError, NetworkError } from '@/lib/api'

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

export async function getUser(userId: string) {
  try {
    return await apiClient.get(`/users/${userId}`, {
      schema: UserSchema,
      retry: { attempts: 3, baseDelayMs: 200 },
    })
  } catch (error) {
    if (error instanceof SchemaError) {
      console.error('Unexpected API shape:', error.issues)
    }
    if (error instanceof NetworkError) {
      console.error('Network problem:', error.message)
    }
    throw error
  }
}

Step 7: Write Tests with Vitest and MSW 2

Install the test dependencies first:

pnpm add -D vitest msw@2

Then write a minimal test file covering the three error branches:

// src/lib/api/client.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { z } from 'zod'
import { ApiClient } from './client'
import { NetworkError, SchemaError } from './errors'

const server = setupServer()
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

const client = new ApiClient({ baseUrl: 'http://test.local' })
const schema = z.object({ id: z.string(), name: z.string() })

describe('ApiClient', () => {
  it('returns parsed data on a valid response', async () => {
    server.use(
      http.get('http://test.local/item', () =>
        HttpResponse.json({ id: '1', name: 'Widget' })
      )
    )
    const result = await client.get('/item', { schema })
    expect(result.name).toBe('Widget')
  })

  it('throws NetworkError on a 500 response', async () => {
    server.use(
      http.get('http://test.local/item', () =>
        new HttpResponse(null, { status: 500 })
      )
    )
    await expect(client.get('/item', { schema })).rejects.toBeInstanceOf(NetworkError)
  })

  it('throws SchemaError when response shape is wrong', async () => {
    server.use(
      http.get('http://test.local/item', () =>
        HttpResponse.json({ unexpected: true })
      )
    )
    await expect(client.get('/item', { schema })).rejects.toBeInstanceOf(SchemaError)
  })
})

Run the suite with pnpm vitest run. All three tests should pass in under two seconds.

Frequently Asked Questions

Does this work in edge runtimes like Cloudflare Workers or Vercel Edge Functions?

Yes. The client relies on the native Fetch API and AbortController, both of which are available in all major edge runtimes as of 2026. Avoid Node.js-only modules in the client files and it will work without modification.

How is this different from a library like ky or axios?

This client adds Zod schema validation directly in the request layer, so your TypeScript types are guaranteed by the runtime, not just inferred. Axios and ky give you typed responses only at the TypeScript level, with no check that the actual JSON matches.

What if I need to handle 401 responses and refresh a token?

Add a responseInterceptor option to the ApiClientConfig interface and call it after checking response.ok. Pass a callback that fetches a new token and returns true to signal a retry. This keeps auth logic out of the core client.

Does the retry logic work for POST requests?

By default, retrying a POST is only safe if your API is idempotent. Pass a custom shouldRetry function that returns false for non-idempotent methods, or only enable retry on specific endpoints where you control idempotency.

What TypeScript strict settings does this require?

The client is tested with "strict": true enabled. It also expects "moduleResolution": "Bundler" or "NodeNext" to resolve the .ts imports correctly in a modern monorepo setup like the ones described in our TypeScript Turborepo guide.

Next Steps

From here, you can extend this client with request-level caching using the Cache API, add OpenTelemetry spans around each execute call for distributed tracing, or generate your Zod schemas automatically from an OpenAPI spec using openapi-zod-client.

If you are building a product that needs this kind of infrastructure across multiple services, a structured approach to the entire data layer pays dividends early. The team at Lenka Studio works with SMBs in Australia, Singapore, Canada, and the US to design and build exactly this kind of scalable frontend architecture. If you want a second opinion on your current API layer or need help building out a data-fetching strategy from scratch, reach out and start a conversation.