HTTP client in TypeScript: proper requests and response handling

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

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

Blogs

Book Recommendations