HTTP client in TypeScript: making requests correctly from A to Z
\n\nModern web development is unthinkable without interacting with remote servers. Whether it's fetching data from a REST API, submitting forms, or uploading files — HTTP requests are the foundation of client-server architecture. TypeScript, with its strict typing and powerful tools, makes working with HTTP requests not only safe but also convenient for maintenance. In this article, we'll break down how to create a well-designed HTTP client in TypeScript, avoid common mistakes, and write code that behaves predictably.
\n\n1. Basics: fetch API and its typing
\n\nThe browser's built-in fetch is a modern and flexible tool for making HTTP requests. However, using it in TypeScript requires proper typing of the response. By default, fetch returns Promise<Response>, and we need to explicitly specify what data we expect to receive.
interface User {\n id: number;\n name: string;\n email: string;\n}\n\nasync function getUser(id: number): Promise<User> {\n const response = await fetch(`https://api.example.com/users/${id}`);\n \n if (!response.ok) {\n throw new Error(`HTTP error! status: ${response.status}`);\n }\n \n const data: User = await response.json();\n return data;\n}\n\n\nNote the check for response.ok. This is critically important: even if the server returns an error (404, 500, etc.), fetch will not throw an exception automatically. We must handle the status manually.
For POST requests with a body, you need to specify the Content-Type header:
async function createUser(userData: Omit<User, 'id'>): Promise<User> {\n const response = await fetch('https://api.example.com/users', {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify(userData),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to create user: ${response.statusText}`);\n }\n\n return response.json();\n}\n\n\n2. Creating a universal HTTP client
\n\nInstead of writing fetch from scratch every time, it's better to create a wrapper — a universal HTTP client. This allows you to centrally manage the base URL, headers, and error handling. Let's look at an example based on classes:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';\n\ninterface RequestConfig<TBody = unknown> {\n method: HttpMethod;\n url: string;\n body?: TBody;\n headers?: Record<string, string>;\n params?: Record<string, string>;\n}\n\nclass HttpClient {\n private baseUrl: string;\n private defaultHeaders: Record<string, string>;\n\n constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {\n this.baseUrl = baseUrl;\n this.defaultHeaders = {\n 'Content-Type': 'application/json',\n ...defaultHeaders,\n };\n }\n\n private async request<TResponse, TBody = unknown>(\n config: RequestConfig<TBody>\n ): Promise<TResponse> {\n const url = new URL(`${this.baseUrl}${config.url}`);\n \n if (config.params) {\n Object.entries(config.params).forEach(([key, value]) => {\n url.searchParams.append(key, value);\n });\n }\n\n const response = await fetch(url.toString(), {\n method: config.method,\n headers: { ...this.defaultHeaders, ...config.headers },\n body: config.body ? JSON.stringify(config.body) : undefined,\n });\n\n if (!response.ok) {\n const errorBody = await response.text();\n throw new Error(`Request failed: ${response.status} ${response.statusText}. Body: ${errorBody}`);\n }\n\n // For DELETE or other methods that may not return a body\n if (response.status === 204) {\n return undefined as TResponse;\n }\n\n return response.jso