HTTP client in TypeScript: making requests correctly
TypeScript is a strictly typed extension of JavaScript that adds reliability and code readability to web development. When working with external APIs or a backend, the key skill becomes the ability to correctly execute HTTP requests. In this article, we will break down how to create a full-fledged HTTP client in TypeScript, starting from basic principles and ending with advanced error handling and typing patterns.
Why is TypeScript ideal for HTTP clients?
JavaScript offers several ways to execute HTTP requests: the built-in fetch, the axios and node-fetch libraries. However, TypeScript allows you to take working with them to a new level thanks to static typing. You can describe the structure of the request and response in advance, which eliminates many errors at the compilation stage. Imagine you are working with an API that returns a list of users. Without types, you risk accessing a non-existent field, and the error will only appear at runtime. TypeScript solves this problem.
Main part: creating an HTTP client from scratch
1. Basic GET request with fetch and typing
Let's start with the simplest — a GET request. In TypeScript, we can define an interface for the response and pass it to the generic fetch function. Here is an example:
interface User { id: number; name: string; email: string;}
async function getUser(id: number): Promise<User> { const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data: User = await response.json(); return data;}
// UsagegetUser(1) .then(user => console.log(user.name)) .catch(error => console.error('Error:', error.message));Here we explicitly state that the function returns Promise<User>. If the API changes, the TypeScript compiler will warn you about a type mismatch. Note the response.ok check — this is a mandatory step for handling HTTP errors (404, 500, etc.).
2. POST request with body and headers
Sending data to the server requires setting headers and serializing the request body. Let's create a function to create a new user:
interface CreateUserRequest { name: string; email: string;}
interface CreateUserResponse { id: number; name: string; email: string;}
async function createUser(userData: CreateUserRequest): Promise<CreateUserResponse> { const response = await fetch('https://jsonplaceholder.typicode.com/users', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(userData), });
if (!response.ok) { const errorBody = await response.text(); throw new Error(`Creation error: ${response.status} - ${errorBody}`); }
return await response.json();}
// Example callconst newUser: CreateUserRequest = { name: 'Ivan', email: 'ivan@example.com' };createUser(newUser).then(result => console.log('Created ID:', result.id));Typing CreateUserRequest and CreateUserResponse makes the code self-documenting. Other developers immediately see which fields are required for sending and what to expect in the response.
3. Universal HTTP client with error handling
In real projects, you rarely get by with just one function. It is better to create a universal class or set of functions that centralizes the request logic. Here is an example of an advanced client:
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
interface RequestConfig { method: HttpMethod; endpoint: string; body?: unknown; params?: Record<string, string>;}
class HttpClient { private baseUrl: string;\