Introduction: Why a Proper HTTP Client is the Foundation of a Modern Web Application
Any modern web application interacts with a server via HTTP. Whether it's loading a product list, submitting a registration form, or fetching data for a dashboard — it all boils down to sending HTTP requests. With TypeScript and its powerful type system, you can build not just a working, but a truly reliable and predictable HTTP client.
In this article, we'll break down how to create an HTTP client in TypeScript that handles errors, correctly types responses, manages headers, and supports middleware. You'll learn why using raw fetch without a wrapper is bad practice, and how to turn it into a powerful tool.
Basics: From Raw fetch to a Typed Client
Let's start with a basic example. The built-in fetch in JavaScript is a good starting point, but it doesn't provide convenient typing or error handling. With TypeScript, we can wrap it in our own function.
A Simple Wrapper for fetch
Let's create a basic HttpClient class that accepts a base URL and settings.
interface RequestOptions { headers?: Record<string, string>; params?: Record<string, string>;}
class HttpClient { private baseUrl: string; private defaultHeaders: Record<string, string>;
constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) { this.baseUrl = baseUrl; this.defaultHeaders = defaultHeaders; }
private buildUrl(path: string, params?: Record<string, string>): string { const url = new URL(`${this.baseUrl}${path}`); if (params) { Object.entries(params).forEach(([key, value]) => url.searchParams.append(key, value)); } return url.toString(); }
async get<T>(path: string, options?: RequestOptions): Promise<T> { const url = this.buildUrl(path, options?.params); const response = await fetch(url, { method: 'GET', headers: { ...this.defaultHeaders, ...options?.headers }, });
if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); }
return response.json() as Promise<T>; }}Now calling client.get<User>('/users/1') will return a typed User object. This is already much better than working with any.
Advanced Error Handling and Response Typing
Simply throwing an error is not the best approach. We need to distinguish between network errors, server errors (4xx, 5xx), and JSON parsing errors. Let's create our own error classes.
Creating Error Classes
class HttpError extends Error { status: number; body: any;
constructor(message: string, status: number, body?: any) { super(message); this.name = 'HttpError'; this.status = status; this.body = body; }}
class NetworkError extends Error { constructor(message: string) { super(message); this.name = 'NetworkError'; }}
class ParseError extends Error { constructor(message: string) { super(message); this.name = 'ParseError'; }}Improved Handling in the get Method
async get<T>(path: string, options?: RequestOptions): Promise<T> { try { const url = this.buildUrl(path, options?.params); const response = await fetch(url, { method: 'GET', headers: { ...this.defaultHeaders, ...options?.headers }, });
let body: any; try { body = await response.json(); } catch { throw new ParseError('Failed to parse response as JSON'); }
if (!response.ok) { throw new HttpError( `Request failed with status ${response.status}`, response.status, body ); }
return body as T; } catch (er