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:
- Error handling —
fetchonly rejects on network failure (no internet, DNS error, etc.). A404or500response resolves successfully withresponse.ok === false. Without checkingresponse.ok, failed requests silently return unusable data. - Single cast point —
response.json() as Promise<T>is the only place where TypeScript is told "trust me, this JSON matchesT". 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
#baseURLuses native private fields (#) for true runtime privacy, markedreadonlyto prevent reassignmentHttpMethodis 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: unknownis safer thanbody: any— it accepts any serialisable value without bypassing type checks#parseResponseis the single point that checksresponse.okand performs theas Tcastfetchonly rejects on network failure —response.okmust be checked manually for 4xx/5xx responses- Throwing a typed
ApiError(withstatus) lets callers distinguish HTTP errors from network errors