A Typed Fetch Wrapper in TypeScript

Overview

A fetch wrapper centralises all HTTP logic in one place — base URL, headers, JSON serialisation — so every call site stays clean and consistent. This guide builds a FetchWrapper class in TypeScript that is fully generic: callers declare the expected response type and TypeScript enforces it at compile time.


The JavaScript Starting Point

The original JavaScript class looks like this:

class FetchWrapper {
  constructor(baseURL) {
    this.baseURL = baseURL
  }

  get(endpoint) {
    return fetch(this.baseURL + endpoint).then((response) => response.json())
  }

  put(endpoint, body) {
    return this.#send('put', endpoint, body)
  }

  post(endpoint, body) {
    return this.#send('post', endpoint, body)
  }

  delete(endpoint, body) {
    return this.#send('delete', endpoint, body)
  }

  #send(method, endpoint, body) {
    return fetch(this.baseURL + endpoint, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }).then((response) => response.json())
  }
}

This works in JavaScript but has no type safety — every method returns Promise<any>. Callers have no idea what shape the response will have, and typos in response properties are silent at runtime.


The TypeScript Version

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'

class FetchWrapper {
  readonly #baseURL: string

  constructor(baseURL: string) {
    this.#baseURL = baseURL
  }

  get<T>(endpoint: string): Promise<T> {
    return fetch(this.#baseURL + endpoint).then((response) =>
      this.#parseResponse<T>(response),
    )
  }

  post<T>(endpoint: string, body: unknown): Promise<T> {
    return this.#send<T>('POST', endpoint, body)
  }

  put<T>(endpoint: string, body: unknown): Promise<T> {
    return this.#send<T>('PUT', endpoint, body)
  }

  delete<T>(endpoint: string, body?: unknown): Promise<T> {
    return this.#send<T>('DELETE', endpoint, body)
  }

  #send<T>(method: HttpMethod, endpoint: string, body?: unknown): Promise<T> {
    return fetch(this.#baseURL + endpoint, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: body !== undefined ? JSON.stringify(body) : undefined,
    }).then((response) => this.#parseResponse<T>(response))
  }

  async #parseResponse<T>(response: Response): Promise<T> {
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
    }
    return response.json() as Promise<T>
  }
}

What Each Type Decision Does

readonly #baseURL: string

readonly prevents the base URL from being reassigned after construction. The # prefix is JavaScript's native private field — inaccessible outside the class at runtime (not just at compile time like TypeScript's private keyword).

const api = new FetchWrapper('https://api.example.com')
api.#baseURL = 'https://evil.com' // ❌ Error — private field

type HttpMethod

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'

A union of string literals. Passing an arbitrary string to #send is now a compile-time error:

this.#send('get', endpoint, body) // ❌ Error — 'get' is not assignable to HttpMethod
this.#send('GET', endpoint, body) // ✅

Generic T on every method

Each public method and #send accepts a type parameter T that represents the shape of the response body. The caller provides it at the call site:

get<T>(endpoint: string): Promise<T>

When T is not provided, TypeScript attempts inference — but for HTTP responses there is nothing to infer from, so always pass it explicitly.

body: unknown

The body parameter accepts unknown rather than any. This is intentional: unknown forces the caller to pass a real value. It is serialised with JSON.stringify which accepts any value, so there is no practical loss. Using any would silently accept nonsense; unknown is the safer default for data you do not need to inspect.

#parseResponse<T>

The private helper exists for two reasons:

  1. Error handlingfetch only rejects on network failure (no internet, DNS error, etc.). A 404 or 500 response resolves successfully with response.ok === false. Without checking response.ok, failed requests silently return unusable data.
  2. Single cast pointresponse.json() as Promise<T> is the only place where TypeScript is told "trust me, this JSON matches T". Keeping it in one method means there is exactly one place to audit or harden later.

Usage

Define your types

interface User {
  id: number
  name: string
  email: string
}

interface CreateUserPayload {
  name: string
  email: string
}

Instantiate once and reuse

const api = new FetchWrapper('https://api.example.com')

GET — read data

const users = await api.get<User[]>('/users')
// users: User[] — fully typed, autocomplete works on users[0].name

POST — create

const newUser = await api.post<User>('/users', {
  name: 'Sam Green',
  email: '[email protected]',
} satisfies CreateUserPayload)
// newUser: User

PUT — replace

const updated = await api.put<User>('/users/42', {
  name: 'Sam Blue',
  email: '[email protected]',
})

DELETE — remove

await api.delete<{ success: boolean }>('/users/42')

Error Handling

#parseResponse throws on non-2xx responses. Wrap calls in try/catch:

try {
  const user = await api.get<User>('/users/999')
} catch (error) {
  if (error instanceof Error) {
    console.error(error.message) // "HTTP 404: Not Found"
  }
}

For broader use, extend with a custom error class:

class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message)
    this.name = 'ApiError'
  }
}

// In #parseResponse:
if (!response.ok) {
  throw new ApiError(response.status, response.statusText)
}

Callers can now distinguish API errors from network errors:

catch (error) {
  if (error instanceof ApiError && error.status === 401) {
    redirectToLogin()
  }
}

Complete File

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'

export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(`HTTP ${status}: ${message}`)
    this.name = 'ApiError'
  }
}

export class FetchWrapper {
  readonly #baseURL: string

  constructor(baseURL: string) {
    this.#baseURL = baseURL
  }

  get<T>(endpoint: string): Promise<T> {
    return fetch(this.#baseURL + endpoint).then((res) =>
      this.#parseResponse<T>(res),
    )
  }

  post<T>(endpoint: string, body: unknown): Promise<T> {
    return this.#send<T>('POST', endpoint, body)
  }

  put<T>(endpoint: string, body: unknown): Promise<T> {
    return this.#send<T>('PUT', endpoint, body)
  }

  delete<T>(endpoint: string, body?: unknown): Promise<T> {
    return this.#send<T>('DELETE', endpoint, body)
  }

  #send<T>(method: HttpMethod, endpoint: string, body?: unknown): Promise<T> {
    return fetch(this.#baseURL + endpoint, {
      method,
      headers: { 'Content-Type': 'application/json' },
      body: body !== undefined ? JSON.stringify(body) : undefined,
    }).then((res) => this.#parseResponse<T>(res))
  }

  async #parseResponse<T>(response: Response): Promise<T> {
    if (!response.ok) {
      throw new ApiError(response.status, response.statusText)
    }
    return response.json() as Promise<T>
  }
}

Import and use anywhere in your project:

import { FetchWrapper } from './FetchWrapper'

export const api = new FetchWrapper(import.meta.env.VITE_API_URL)

Summary

  • #baseURL uses native private fields (#) for true runtime privacy, marked readonly to prevent reassignment
  • HttpMethod is a string literal union — invalid method strings are a compile error
  • Every method is generic: get<T>, post<T>, etc. — callers declare the expected response shape
  • body: unknown is safer than body: any — it accepts any serialisable value without bypassing type checks
  • #parseResponse is the single point that checks response.ok and performs the as T cast
  • fetch only rejects on network failure — response.ok must be checked manually for 4xx/5xx responses
  • Throwing a typed ApiError (with status) lets callers distinguish HTTP errors from network errors