Prerequisites
- Understanding of HTTP methods and status codes 📝
- Basic knowledge of Promises and async/await ⚡
- Experience with TypeScript interfaces and types 💻
What you'll learn
- Master the Fetch API for HTTP requests in TypeScript 🎯
- Implement type-safe API clients with error handling 🏗️
- Build robust request/response patterns with interceptors 🐛
- Create production-ready HTTP utilities and abstractions ✨
🎯 Introduction
Welcome to the modern world of HTTP requests with the Fetch API! 🌐 If XMLHttpRequest was the old rotary phone of web requests, then Fetch is the sleek smartphone - Promise-based, clean, and perfect for modern TypeScript applications!
The Fetch API provides a powerful and flexible way to make network requests. Combined with TypeScript’s type safety, you’ll build robust, maintainable HTTP clients that handle everything from simple GET requests to complex API interactions with authentication, retries, and error recovery.
By the end of this tutorial, you’ll be a master of HTTP communication, able to build type-safe API clients, handle complex request patterns, and create production-ready networking solutions. Let’s fetch some knowledge! 📡
📚 Understanding the Fetch API
🤔 What Is the Fetch API?
The Fetch API provides a modern, Promise-based interface for making HTTP requests. It’s cleaner and more powerful than XMLHttpRequest, with built-in support for streaming, CORS, and modern web standards.
// 🌟 Basic Fetch API usage
// Simple GET request
async function fetchUser(id: number): Promise<User> {
console.log(`📡 Fetching user ${id}...`);
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`❌ HTTP error! status: ${response.status}`);
}
const user: User = await response.json();
console.log('✅ User fetched successfully:', user);
return user;
}
// 📦 Type definitions
interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
// 🚀 Usage
fetchUser(123)
.then(user => console.log('👤 User:', user))
.catch(error => console.error('💥 Error:', error));
💡 Key Advantages
- 🎯 Promise-Based: Clean async/await syntax
- 🌊 Streaming Support: Handle large responses efficiently
- 🔒 CORS-Aware: Built-in cross-origin request support
- 📦 Request/Response Objects: Rich API for headers, body, etc.
- 🛠️ Extensible: Easy to create abstractions and utilities
// 🎨 Understanding Request and Response objects
interface FetchOptions extends RequestInit {
timeout?: number;
retries?: number;
}
class TypeSafeFetch {
private baseUrl: string;
private defaultOptions: RequestInit;
constructor(baseUrl: string = '', options: RequestInit = {}) {
this.baseUrl = baseUrl;
this.defaultOptions = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
}
async request<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<T> {
const url = `${this.baseUrl}${endpoint}`;
const config: RequestInit = {
...this.defaultOptions,
...options,
headers: {
...this.defaultOptions.headers,
...options.headers,
},
};
console.log(`🚀 Making ${config.method || 'GET'} request to: ${url}`);
try {
const response = await this.fetchWithTimeout(url, config, options.timeout);
return await this.handleResponse<T>(response);
} catch (error) {
console.error('💥 Request failed:', error);
throw error;
}
}
private async fetchWithTimeout(
url: string,
options: RequestInit,
timeout: number = 10000
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
if (error.name === 'AbortError') {
throw new Error(`⏱️ Request timeout after ${timeout}ms`);
}
throw error;
}
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await this.safeJsonParse(response);
throw new HttpError(
response.status,
response.statusText,
errorData
);
}
const contentType = response.headers.get('content-type');
if (contentType?.includes('application/json')) {
return await response.json();
} else if (contentType?.includes('text/')) {
return await response.text() as unknown as T;
} else {
return await response.blob() as unknown as T;
}
}
private async safeJsonParse(response: Response): Promise<any> {
try {
return await response.json();
} catch {
return { message: await response.text() };
}
}
}
// 🚨 Custom error class for HTTP errors
class HttpError extends Error {
constructor(
public status: number,
public statusText: string,
public data?: any
) {
super(`HTTP ${status}: ${statusText}`);
this.name = 'HttpError';
}
get isClientError(): boolean {
return this.status >= 400 && this.status < 500;
}
get isServerError(): boolean {
return this.status >= 500;
}
}
🛠️ HTTP Methods and Request Types
📥 GET Requests - Fetching Data
GET requests are the most common HTTP method for retrieving data from servers. Let’s build type-safe GET operations:
// 🎯 Type-safe GET requests
interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
limit: number;
hasNext: boolean;
hasPrev: boolean;
}
interface QueryParams {
[key: string]: string | number | boolean | undefined;
}
class ApiClient {
constructor(private fetcher: TypeSafeFetch) {}
// 📋 Get single resource
async get<T>(endpoint: string): Promise<T> {
return this.fetcher.request<T>(endpoint, {
method: 'GET',
});
}
// 📊 Get with query parameters
async getWithParams<T>(
endpoint: string,
params: QueryParams = {}
): Promise<T> {
const url = this.buildUrlWithParams(endpoint, params);
return this.fetcher.request<T>(url, {
method: 'GET',
});
}
// 📄 Get paginated data
async getPaginated<T>(
endpoint: string,
page: number = 1,
limit: number = 10,
filters: QueryParams = {}
): Promise<PaginatedResponse<T>> {
const params = {
page,
limit,
...filters,
};
return this.getWithParams<PaginatedResponse<T>>(endpoint, params);
}
private buildUrlWithParams(endpoint: string, params: QueryParams): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${endpoint}?${queryString}` : endpoint;
}
}
// 🎮 Example usage - User management
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'moderator';
isActive: boolean;
lastLogin: string;
}
interface UserFilters {
role?: string;
isActive?: boolean;
search?: string;
}
const fetcher = new TypeSafeFetch('/api');
const api = new ApiClient(fetcher);
// 👤 Get single user
const getUser = async (id: number): Promise<User> => {
console.log(`🔍 Fetching user ${id}...`);
return api.get<User>(`/users/${id}`);
};
// 👥 Get users with pagination and filters
const getUsers = async (
page: number = 1,
filters: UserFilters = {}
): Promise<PaginatedResponse<User>> => {
console.log('👥 Fetching users list...');
return api.getPaginated<User>('/users', page, 20, filters);
};
// 🔍 Search users
const searchUsers = async (query: string): Promise<User[]> => {
console.log(`🔍 Searching users: "${query}"`);
const response = await api.getWithParams<ApiResponse<User[]>>('/users/search', {
q: query,
limit: 50,
});
return response.data;
};
📤 POST Requests - Creating Data
POST requests are used to send data to the server to create new resources:
// 🎨 Type-safe POST requests
interface CreateUserRequest {
name: string;
email: string;
password: string;
role?: 'user' | 'moderator';
}
interface UpdateUserRequest {
name?: string;
email?: string;
role?: 'admin' | 'user' | 'moderator';
isActive?: boolean;
}
interface FileUploadOptions {
onProgress?: (progress: number) => void;
allowedTypes?: string[];
maxSize?: number; // in bytes
}
class ApiClient {
// ... previous methods
// ✨ Create new resource
async post<TRequest, TResponse>(
endpoint: string,
data: TRequest
): Promise<TResponse> {
return this.fetcher.request<TResponse>(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
// 🔄 Update existing resource
async put<TRequest, TResponse>(
endpoint: string,
data: TRequest
): Promise<TResponse> {
return this.fetcher.request<TResponse>(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// 🔧 Partial update
async patch<TRequest, TResponse>(
endpoint: string,
data: TRequest
): Promise<TResponse> {
return this.fetcher.request<TResponse>(endpoint, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
// 🗑️ Delete resource
async delete<TResponse = void>(endpoint: string): Promise<TResponse> {
return this.fetcher.request<TResponse>(endpoint, {
method: 'DELETE',
});
}
// 📁 File upload with progress
async uploadFile(
endpoint: string,
file: File,
additionalData: Record<string, string> = {},
options: FileUploadOptions = {}
): Promise<any> {
// 🔍 Validate file
this.validateFile(file, options);
const formData = new FormData();
formData.append('file', file);
// 📝 Add additional fields
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, value);
});
// 📊 Track upload progress
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && options.onProgress) {
const progress = (event.loaded / event.total) * 100;
options.onProgress(Math.round(progress));
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch {
resolve(xhr.responseText);
}
} else {
reject(new HttpError(xhr.status, xhr.statusText));
}
});
xhr.addEventListener('error', () => {
reject(new Error('📁 Upload failed'));
});
xhr.open('POST', `${this.fetcher['baseUrl']}${endpoint}`);
xhr.send(formData);
});
}
private validateFile(file: File, options: FileUploadOptions): void {
if (options.maxSize && file.size > options.maxSize) {
throw new Error(`📏 File too large. Max size: ${options.maxSize} bytes`);
}
if (options.allowedTypes && !options.allowedTypes.includes(file.type)) {
throw new Error(`🚫 Invalid file type. Allowed: ${options.allowedTypes.join(', ')}`);
}
}
}
// 🎮 Example usage - User management
const createUser = async (userData: CreateUserRequest): Promise<User> => {
console.log('👤 Creating new user...');
try {
const response = await api.post<CreateUserRequest, ApiResponse<User>>(
'/users',
userData
);
console.log('✅ User created successfully:', response.data);
return response.data;
} catch (error) {
if (error instanceof HttpError && error.status === 409) {
throw new Error('📧 Email already exists');
}
throw error;
}
};
// 🔄 Update user
const updateUser = async (
id: number,
updates: UpdateUserRequest
): Promise<User> => {
console.log(`🔄 Updating user ${id}...`);
const response = await api.patch<UpdateUserRequest, ApiResponse<User>>(
`/users/${id}`,
updates
);
console.log('✅ User updated successfully');
return response.data;
};
// 📁 Upload user avatar
const uploadAvatar = async (
userId: number,
file: File,
onProgress?: (progress: number) => void
): Promise<string> => {
console.log('📁 Uploading avatar...');
const response = await api.uploadFile(
`/users/${userId}/avatar`,
file,
{ userId: userId.toString() },
{
allowedTypes: ['image/jpeg', 'image/png', 'image/gif'],
maxSize: 5 * 1024 * 1024, // 5MB
onProgress,
}
);
console.log('✅ Avatar uploaded successfully');
return response.avatarUrl;
};
🔧 Advanced Request Patterns
🔄 Request Interceptors and Middleware
Interceptors allow you to modify requests and responses globally:
// 🎛️ Request/Response interceptors
type RequestInterceptor = (config: RequestInit, url: string) => RequestInit | Promise<RequestInit>;
type ResponseInterceptor = (response: Response) => Response | Promise<Response>;
interface InterceptorConfig {
onRequest?: RequestInterceptor[];
onResponse?: ResponseInterceptor[];
onError?: ((error: Error) => Error | Promise<Error>)[];
}
class InterceptedFetch extends TypeSafeFetch {
private interceptors: InterceptorConfig = {
onRequest: [],
onResponse: [],
onError: [],
};
// 🔧 Add request interceptor
addRequestInterceptor(interceptor: RequestInterceptor): void {
this.interceptors.onRequest?.push(interceptor);
}
// 📥 Add response interceptor
addResponseInterceptor(interceptor: ResponseInterceptor): void {
this.interceptors.onResponse?.push(interceptor);
}
// 🚨 Add error interceptor
addErrorInterceptor(interceptor: (error: Error) => Error | Promise<Error>): void {
this.interceptors.onError?.push(interceptor);
}
async request<T>(endpoint: string, options: FetchOptions = {}): Promise<T> {
try {
// 🔄 Apply request interceptors
let config = { ...options };
const url = `${this.baseUrl}${endpoint}`;
for (const interceptor of this.interceptors.onRequest || []) {
config = await interceptor(config, url);
}
// 🚀 Make the request
let response = await this.fetchWithTimeout(url, config, options.timeout);
// 📥 Apply response interceptors
for (const interceptor of this.interceptors.onResponse || []) {
response = await interceptor(response);
}
return await this.handleResponse<T>(response);
} catch (error) {
// 🚨 Apply error interceptors
let processedError = error as Error;
for (const interceptor of this.interceptors.onError || []) {
processedError = await interceptor(processedError);
}
throw processedError;
}
}
}
// 🔑 Authentication interceptor
const authInterceptor: RequestInterceptor = (config, url) => {
const token = localStorage.getItem('authToken');
if (token && !url.includes('/auth/')) {
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${token}`,
},
};
}
return config;
};
// 📊 Logging interceptor
const loggingInterceptor: RequestInterceptor = (config, url) => {
console.log(`🚀 ${config.method || 'GET'} ${url}`, {
headers: config.headers,
body: config.body,
});
return config;
};
// 🔄 Token refresh interceptor
const tokenRefreshInterceptor: ResponseInterceptor = async (response) => {
if (response.status === 401) {
console.log('🔄 Attempting to refresh token...');
try {
const refreshToken = localStorage.getItem('refreshToken');
const refreshResponse = await fetch('/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
});
if (refreshResponse.ok) {
const { accessToken } = await refreshResponse.json();
localStorage.setItem('authToken', accessToken);
console.log('✅ Token refreshed successfully');
}
} catch (error) {
console.error('❌ Token refresh failed:', error);
// Redirect to login or handle auth failure
window.location.href = '/login';
}
}
return response;
};
// 🛠️ Setup interceptors
const apiClient = new InterceptedFetch('/api');
apiClient.addRequestInterceptor(authInterceptor);
apiClient.addRequestInterceptor(loggingInterceptor);
apiClient.addResponseInterceptor(tokenRefreshInterceptor);
🔄 Retry Logic and Circuit Breaker
Implement robust retry mechanisms for handling transient failures:
// 🔄 Advanced retry logic with exponential backoff
interface RetryConfig {
maxAttempts: number;
baseDelay: number;
maxDelay: number;
backoffFactor: number;
retryCondition?: (error: Error) => boolean;
}
interface CircuitBreakerConfig {
failureThreshold: number;
recoveryTimeout: number;
monitoringPeriod: number;
}
enum CircuitState {
CLOSED = 'closed',
OPEN = 'open',
HALF_OPEN = 'half_open',
}
class CircuitBreaker {
private state = CircuitState.CLOSED;
private failureCount = 0;
private lastFailureTime = 0;
private successCount = 0;
constructor(private config: CircuitBreakerConfig) {}
async execute<T>(operation: () => Promise<T>): Promise<T> {
if (this.state === CircuitState.OPEN) {
if (this.shouldAttemptReset()) {
this.state = CircuitState.HALF_OPEN;
console.log('🔄 Circuit breaker transitioning to HALF_OPEN');
} else {
throw new Error('🚫 Circuit breaker is OPEN');
}
}
try {
const result = await operation();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private shouldAttemptReset(): boolean {
return Date.now() - this.lastFailureTime >= this.config.recoveryTimeout;
}
private onSuccess(): void {
this.failureCount = 0;
if (this.state === CircuitState.HALF_OPEN) {
this.successCount++;
if (this.successCount >= 3) { // Require 3 successes to close
this.state = CircuitState.CLOSED;
this.successCount = 0;
console.log('✅ Circuit breaker CLOSED');
}
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.config.failureThreshold) {
this.state = CircuitState.OPEN;
console.log('🚫 Circuit breaker OPENED');
}
}
get currentState(): CircuitState {
return this.state;
}
}
class ResilientFetch extends InterceptedFetch {
private circuitBreaker: CircuitBreaker;
constructor(
baseUrl: string = '',
options: RequestInit = {},
circuitBreakerConfig: CircuitBreakerConfig = {
failureThreshold: 5,
recoveryTimeout: 30000,
monitoringPeriod: 60000,
}
) {
super(baseUrl, options);
this.circuitBreaker = new CircuitBreaker(circuitBreakerConfig);
}
async requestWithRetry<T>(
endpoint: string,
options: FetchOptions & { retry?: RetryConfig } = {}
): Promise<T> {
const retryConfig: RetryConfig = {
maxAttempts: 3,
baseDelay: 1000,
maxDelay: 10000,
backoffFactor: 2,
retryCondition: (error) => {
if (error instanceof HttpError) {
// Retry on 5xx server errors and specific 4xx errors
return error.isServerError || error.status === 429; // Rate limited
}
return true; // Retry network errors
},
...options.retry,
};
return this.circuitBreaker.execute(async () => {
return this.retryOperation(
() => super.request<T>(endpoint, options),
retryConfig
);
});
}
private async retryOperation<T>(
operation: () => Promise<T>,
config: RetryConfig
): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
console.log(`🔄 Attempt ${attempt}/${config.maxAttempts}`);
return await operation();
} catch (error) {
lastError = error as Error;
if (
attempt === config.maxAttempts ||
!config.retryCondition?.(lastError)
) {
throw lastError;
}
const delay = Math.min(
config.baseDelay * Math.pow(config.backoffFactor, attempt - 1),
config.maxDelay
);
console.log(`⏱️ Retrying in ${delay}ms... (${lastError.message})`);
await this.sleep(delay);
}
}
throw lastError!;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// 🎮 Usage example with retry and circuit breaker
const resilientApi = new ResilientFetch('/api');
const getUserWithRetry = async (id: number): Promise<User> => {
return resilientApi.requestWithRetry<User>(`/users/${id}`, {
method: 'GET',
retry: {
maxAttempts: 5,
baseDelay: 500,
retryCondition: (error) => {
// Don't retry on 404 or 403 errors
if (error instanceof HttpError) {
return ![404, 403].includes(error.status);
}
return true;
},
},
});
};
🎯 Type-Safe API Client Patterns
🏗️ Generic API Resource Client
Create a generic client that works with any REST resource:
// 🎨 Generic resource client
interface ResourceClient<T, TCreate = Omit<T, 'id'>, TUpdate = Partial<TCreate>> {
getAll(params?: QueryParams): Promise<PaginatedResponse<T>>;
getById(id: string | number): Promise<T>;
create(data: TCreate): Promise<T>;
update(id: string | number, data: TUpdate): Promise<T>;
delete(id: string | number): Promise<void>;
}
class BaseResourceClient<T, TCreate = Omit<T, 'id'>, TUpdate = Partial<TCreate>>
implements ResourceClient<T, TCreate, TUpdate> {
constructor(
private client: ResilientFetch,
private endpoint: string
) {}
async getAll(params: QueryParams = {}): Promise<PaginatedResponse<T>> {
const url = this.buildUrlWithParams(this.endpoint, params);
return this.client.requestWithRetry<PaginatedResponse<T>>(url);
}
async getById(id: string | number): Promise<T> {
return this.client.requestWithRetry<T>(`${this.endpoint}/${id}`);
}
async create(data: TCreate): Promise<T> {
return this.client.requestWithRetry<T>(this.endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
async update(id: string | number, data: TUpdate): Promise<T> {
return this.client.requestWithRetry<T>(`${this.endpoint}/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
async delete(id: string | number): Promise<void> {
await this.client.requestWithRetry<void>(`${this.endpoint}/${id}`, {
method: 'DELETE',
});
}
private buildUrlWithParams(endpoint: string, params: QueryParams): string {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${endpoint}?${queryString}` : endpoint;
}
}
// 🎯 Specific resource clients
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'moderator';
isActive: boolean;
createdAt: string;
updatedAt: string;
}
interface CreateUser {
name: string;
email: string;
password: string;
role?: 'user' | 'moderator';
}
interface UpdateUser {
name?: string;
email?: string;
role?: 'admin' | 'user' | 'moderator';
isActive?: boolean;
}
class UserClient extends BaseResourceClient<User, CreateUser, UpdateUser> {
constructor(client: ResilientFetch) {
super(client, '/users');
}
// 🔍 Additional user-specific methods
async getByEmail(email: string): Promise<User | null> {
try {
return await this.client.requestWithRetry<User>(`/users/by-email/${email}`);
} catch (error) {
if (error instanceof HttpError && error.status === 404) {
return null;
}
throw error;
}
}
async changePassword(id: number, newPassword: string): Promise<void> {
await this.client.requestWithRetry<void>(`/users/${id}/password`, {
method: 'POST',
body: JSON.stringify({ password: newPassword }),
});
}
async toggleStatus(id: number): Promise<User> {
return this.client.requestWithRetry<User>(`/users/${id}/toggle-status`, {
method: 'POST',
});
}
async searchUsers(query: string, limit: number = 20): Promise<User[]> {
const response = await this.getAll({
search: query,
limit,
});
return response.items;
}
}
// 📝 Posts resource
interface Post {
id: number;
title: string;
content: string;
authorId: number;
published: boolean;
tags: string[];
createdAt: string;
updatedAt: string;
}
interface CreatePost {
title: string;
content: string;
authorId: number;
tags?: string[];
}
class PostClient extends BaseResourceClient<Post, CreatePost> {
constructor(client: ResilientFetch) {
super(client, '/posts');
}
async getByAuthor(authorId: number): Promise<Post[]> {
const response = await this.getAll({ authorId });
return response.items;
}
async publish(id: number): Promise<Post> {
return this.client.requestWithRetry<Post>(`/posts/${id}/publish`, {
method: 'POST',
});
}
async getByTags(tags: string[]): Promise<Post[]> {
const response = await this.getAll({
tags: tags.join(','),
});
return response.items;
}
}
// 🏗️ API service composition
class ApiService {
private client: ResilientFetch;
public users: UserClient;
public posts: PostClient;
constructor(baseUrl: string) {
this.client = new ResilientFetch(baseUrl);
// 🔧 Setup common interceptors
this.setupInterceptors();
// 🎯 Initialize resource clients
this.users = new UserClient(this.client);
this.posts = new PostClient(this.client);
}
private setupInterceptors(): void {
// 🔑 Auth interceptor
this.client.addRequestInterceptor((config, url) => {
const token = localStorage.getItem('authToken');
if (token) {
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${token}`,
},
};
}
return config;
});
// 📊 Request ID for tracing
this.client.addRequestInterceptor((config) => {
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return {
...config,
headers: {
...config.headers,
'X-Request-ID': requestId,
},
};
});
}
}
// 🚀 Usage
const api = new ApiService('https://api.example.com');
// 👤 User operations
const createNewUser = async (): Promise<void> => {
try {
const user = await api.users.create({
name: 'John Doe',
email: '[email protected]',
password: 'securePassword123',
role: 'user',
});
console.log('✅ User created:', user);
// 📝 Create a post for the user
const post = await api.posts.create({
title: 'My First Post',
content: 'Hello, World!',
authorId: user.id,
tags: ['intro', 'typescript'],
});
console.log('✅ Post created:', post);
} catch (error) {
console.error('💥 Operation failed:', error);
}
};
🧪 Testing HTTP Clients
🎭 Mocking Fetch for Tests
Create comprehensive tests for your HTTP clients:
// 🧪 Test utilities for Fetch API
interface MockResponse {
ok: boolean;
status: number;
statusText: string;
json: () => Promise<any>;
text: () => Promise<string>;
blob: () => Promise<Blob>;
headers: Headers;
}
class FetchMock {
private static originalFetch: typeof global.fetch;
private static mockResponses: Map<string, MockResponse[]> = new Map();
static setup(): void {
this.originalFetch = global.fetch;
global.fetch = jest.fn(this.mockImplementation.bind(this));
}
static teardown(): void {
global.fetch = this.originalFetch;
this.mockResponses.clear();
}
static mockResponse(
url: string | RegExp,
response: Partial<MockResponse>,
options: { once?: boolean; method?: string } = {}
): void {
const mockResponse: MockResponse = {
ok: response.ok ?? true,
status: response.status ?? 200,
statusText: response.statusText ?? 'OK',
json: response.json ?? (() => Promise.resolve({})),
text: response.text ?? (() => Promise.resolve('')),
blob: response.blob ?? (() => Promise.resolve(new Blob())),
headers: response.headers ?? new Headers(),
};
const key = url instanceof RegExp ? url.source : url;
if (!this.mockResponses.has(key)) {
this.mockResponses.set(key, []);
}
this.mockResponses.get(key)!.push(mockResponse);
}
private static async mockImplementation(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const url = input instanceof Request ? input.url : input.toString();
const method = init?.method ?? 'GET';
// 🔍 Find matching mock
for (const [pattern, responses] of this.mockResponses.entries()) {
const isMatch = pattern instanceof RegExp
? new RegExp(pattern).test(url)
: url.includes(pattern);
if (isMatch && responses.length > 0) {
const response = responses.shift()!;
return response as unknown as Response;
}
}
throw new Error(`🚫 No mock found for ${method} ${url}`);
}
static getRequestHistory(): Array<{ url: string; options?: RequestInit }> {
return (global.fetch as jest.Mock).mock.calls.map(([url, options]) => ({
url: url instanceof Request ? url.url : url.toString(),
options,
}));
}
}
// 🧪 Test suite
describe('ApiService', () => {
let api: ApiService;
beforeEach(() => {
FetchMock.setup();
api = new ApiService('https://api.test.com');
});
afterEach(() => {
FetchMock.teardown();
});
describe('UserClient', () => {
it('should fetch user by ID', async () => {
// 🎭 Setup mock
const mockUser: User = {
id: 1,
name: 'John Doe',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
};
FetchMock.mockResponse('/users/1', {
ok: true,
status: 200,
json: () => Promise.resolve(mockUser),
});
// 🎯 Execute test
const user = await api.users.getById(1);
// ✅ Assertions
expect(user).toEqual(mockUser);
const requests = FetchMock.getRequestHistory();
expect(requests).toHaveLength(1);
expect(requests[0].url).toContain('/users/1');
});
it('should handle user not found', async () => {
// 🎭 Setup mock for 404
FetchMock.mockResponse('/users/999', {
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve({ message: 'User not found' }),
});
// 🎯 Execute and expect error
await expect(api.users.getById(999)).rejects.toThrow('HTTP 404: Not Found');
});
it('should create user successfully', async () => {
// 🎭 Setup mocks
const createData: CreateUser = {
name: 'Jane Doe',
email: '[email protected]',
password: 'password123',
role: 'user',
};
const mockCreatedUser: User = {
id: 2,
...createData,
isActive: true,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
};
FetchMock.mockResponse('/users', {
ok: true,
status: 201,
json: () => Promise.resolve(mockCreatedUser),
});
// 🎯 Execute test
const user = await api.users.create(createData);
// ✅ Assertions
expect(user).toEqual(mockCreatedUser);
const requests = FetchMock.getRequestHistory();
expect(requests[0].options?.method).toBe('POST');
expect(requests[0].options?.body).toBe(JSON.stringify(createData));
});
it('should retry on server error', async () => {
// 🎭 Setup mocks - first fails, second succeeds
FetchMock.mockResponse('/users/1', {
ok: false,
status: 500,
statusText: 'Internal Server Error',
});
FetchMock.mockResponse('/users/1', {
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'John' }),
});
// 🎯 Execute test
const user = await api.users.getById(1);
// ✅ Assertions
expect(user.id).toBe(1);
expect(FetchMock.getRequestHistory()).toHaveLength(2);
});
});
describe('Error Handling', () => {
it('should handle network errors', async () => {
// 🎭 Mock network failure
FetchMock.mockResponse('/users/1', {
ok: false,
status: 0,
statusText: 'Network Error',
});
// 🎯 Execute and expect error
await expect(api.users.getById(1)).rejects.toThrow();
});
it('should handle timeout', async () => {
// 🎭 Mock slow response
FetchMock.mockResponse('/users/1', {
ok: true,
status: 200,
json: () => new Promise(resolve =>
setTimeout(() => resolve({}), 15000)
),
});
// 🎯 Execute with short timeout
await expect(
api.users.getById(1)
).rejects.toThrow('Request timeout');
}, 20000);
});
});
// 🎭 Integration test utilities
class TestServer {
private handlers: Map<string, (req: any) => any> = new Map();
register(method: string, path: string, handler: (req: any) => any): void {
this.handlers.set(`${method}:${path}`, handler);
}
async handleRequest(method: string, path: string, body?: any): Promise<any> {
const handler = this.handlers.get(`${method}:${path}`);
if (!handler) {
throw new Error(`No handler for ${method} ${path}`);
}
return handler({ method, path, body });
}
}
// 🧪 Integration test example
describe('User Management Integration', () => {
let server: TestServer;
let api: ApiService;
beforeEach(() => {
server = new TestServer();
api = new ApiService('http://localhost:3000');
// 🎭 Setup test server handlers
server.register('GET', '/users/1', () => ({
id: 1,
name: 'Test User',
email: '[email protected]',
role: 'user',
isActive: true,
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
}));
server.register('POST', '/users', (req) => ({
id: 2,
...req.body,
isActive: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
});
it('should perform complete user lifecycle', async () => {
// 🎯 Create user
const createData: CreateUser = {
name: 'Integration Test User',
email: '[email protected]',
password: 'testpass123',
};
const createdUser = await api.users.create(createData);
expect(createdUser.name).toBe(createData.name);
// 🔄 Update user
const updatedUser = await api.users.update(createdUser.id, {
name: 'Updated Name',
});
expect(updatedUser.name).toBe('Updated Name');
// 🔍 Fetch user
const fetchedUser = await api.users.getById(createdUser.id);
expect(fetchedUser.id).toBe(createdUser.id);
});
});
🎉 Practical Examples and Use Cases
🛒 E-commerce API Client
A real-world example of a complete e-commerce API client:
// 🛒 E-commerce API types
interface Product {
id: string;
name: string;
description: string;
price: number;
currency: string;
category: string;
inStock: boolean;
imageUrls: string[];
tags: string[];
}
interface CartItem {
productId: string;
quantity: number;
price: number;
}
interface Cart {
id: string;
userId: string;
items: CartItem[];
total: number;
currency: string;
createdAt: string;
updatedAt: string;
}
interface Order {
id: string;
userId: string;
items: CartItem[];
total: number;
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
shippingAddress: Address;
paymentMethod: PaymentMethod;
createdAt: string;
}
interface Address {
street: string;
city: string;
state: string;
zipCode: string;
country: string;
}
interface PaymentMethod {
type: 'credit_card' | 'paypal' | 'apple_pay';
last4?: string;
expiryMonth?: number;
expiryYear?: number;
}
// 🏪 E-commerce API client
class ECommerceApi {
private client: ResilientFetch;
constructor(baseUrl: string) {
this.client = new ResilientFetch(baseUrl);
this.setupInterceptors();
}
// 🛍️ Product operations
async getProducts(filters: {
category?: string;
minPrice?: number;
maxPrice?: number;
inStock?: boolean;
search?: string;
page?: number;
limit?: number;
} = {}): Promise<PaginatedResponse<Product>> {
return this.client.requestWithRetry<PaginatedResponse<Product>>('/products', {
method: 'GET',
retry: { maxAttempts: 3 },
});
}
async getProduct(id: string): Promise<Product> {
return this.client.requestWithRetry<Product>(`/products/${id}`);
}
async searchProducts(query: string): Promise<Product[]> {
const response = await this.client.requestWithRetry<PaginatedResponse<Product>>(
`/products/search?q=${encodeURIComponent(query)}`
);
return response.items;
}
// 🛒 Cart operations
async getCart(userId: string): Promise<Cart> {
return this.client.requestWithRetry<Cart>(`/users/${userId}/cart`);
}
async addToCart(userId: string, productId: string, quantity: number): Promise<Cart> {
return this.client.requestWithRetry<Cart>(`/users/${userId}/cart/items`, {
method: 'POST',
body: JSON.stringify({ productId, quantity }),
});
}
async updateCartItem(
userId: string,
productId: string,
quantity: number
): Promise<Cart> {
return this.client.requestWithRetry<Cart>(
`/users/${userId}/cart/items/${productId}`,
{
method: 'PUT',
body: JSON.stringify({ quantity }),
}
);
}
async removeFromCart(userId: string, productId: string): Promise<Cart> {
return this.client.requestWithRetry<Cart>(
`/users/${userId}/cart/items/${productId}`,
{ method: 'DELETE' }
);
}
async clearCart(userId: string): Promise<void> {
await this.client.requestWithRetry<void>(`/users/${userId}/cart`, {
method: 'DELETE',
});
}
// 📦 Order operations
async createOrder(
userId: string,
shippingAddress: Address,
paymentMethod: PaymentMethod
): Promise<Order> {
return this.client.requestWithRetry<Order>(`/users/${userId}/orders`, {
method: 'POST',
body: JSON.stringify({ shippingAddress, paymentMethod }),
});
}
async getOrders(userId: string): Promise<Order[]> {
const response = await this.client.requestWithRetry<PaginatedResponse<Order>>(
`/users/${userId}/orders`
);
return response.items;
}
async getOrder(userId: string, orderId: string): Promise<Order> {
return this.client.requestWithRetry<Order>(`/users/${userId}/orders/${orderId}`);
}
async cancelOrder(userId: string, orderId: string): Promise<Order> {
return this.client.requestWithRetry<Order>(
`/users/${userId}/orders/${orderId}/cancel`,
{ method: 'POST' }
);
}
// 💳 Payment operations
async processPayment(
orderId: string,
paymentDetails: {
amount: number;
currency: string;
paymentMethodId: string;
}
): Promise<{ success: boolean; transactionId?: string; error?: string }> {
return this.client.requestWithRetry(
`/orders/${orderId}/payment`,
{
method: 'POST',
body: JSON.stringify(paymentDetails),
timeout: 30000, // Longer timeout for payment processing
}
);
}
private setupInterceptors(): void {
// 🔑 Authentication
this.client.addRequestInterceptor((config) => {
const token = localStorage.getItem('authToken');
if (token) {
return {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${token}`,
},
};
}
return config;
});
// 📊 Request tracking
this.client.addRequestInterceptor((config, url) => {
console.log(`🛒 E-commerce API: ${config.method || 'GET'} ${url}`);
return config;
});
// 🚨 Error handling
this.client.addErrorInterceptor((error) => {
if (error instanceof HttpError) {
switch (error.status) {
case 401:
// Redirect to login
window.location.href = '/login';
break;
case 402:
// Payment required
console.error('💳 Payment required:', error.data);
break;
case 409:
// Conflict - item out of stock
console.error('📦 Item conflict:', error.data);
break;
}
}
return error;
});
}
}
// 🎮 Shopping cart manager
class ShoppingCartManager {
private cartCache: Cart | null = null;
private syncPending = false;
constructor(
private api: ECommerceApi,
private userId: string
) {}
async getCart(): Promise<Cart> {
if (this.cartCache && !this.syncPending) {
return this.cartCache;
}
try {
this.cartCache = await this.api.getCart(this.userId);
this.syncPending = false;
return this.cartCache;
} catch (error) {
console.error('🛒 Failed to fetch cart:', error);
throw error;
}
}
async addItem(productId: string, quantity: number = 1): Promise<Cart> {
try {
console.log(`➕ Adding ${quantity}x ${productId} to cart`);
this.cartCache = await this.api.addToCart(this.userId, productId, quantity);
this.syncPending = false;
// 🎉 Show success notification
this.showNotification('✅ Item added to cart!');
return this.cartCache;
} catch (error) {
console.error('❌ Failed to add item to cart:', error);
throw error;
}
}
async updateQuantity(productId: string, quantity: number): Promise<Cart> {
if (quantity <= 0) {
return this.removeItem(productId);
}
try {
this.cartCache = await this.api.updateCartItem(this.userId, productId, quantity);
this.syncPending = false;
return this.cartCache;
} catch (error) {
console.error('🔄 Failed to update cart item:', error);
throw error;
}
}
async removeItem(productId: string): Promise<Cart> {
try {
console.log(`🗑️ Removing ${productId} from cart`);
this.cartCache = await this.api.removeFromCart(this.userId, productId);
this.syncPending = false;
this.showNotification('🗑️ Item removed from cart');
return this.cartCache;
} catch (error) {
console.error('❌ Failed to remove item from cart:', error);
throw error;
}
}
async checkout(
shippingAddress: Address,
paymentMethod: PaymentMethod
): Promise<Order> {
try {
console.log('💳 Processing checkout...');
const order = await this.api.createOrder(
this.userId,
shippingAddress,
paymentMethod
);
// 🎉 Clear cart after successful order
this.cartCache = null;
await this.api.clearCart(this.userId);
this.showNotification('🎉 Order placed successfully!');
return order;
} catch (error) {
console.error('💥 Checkout failed:', error);
throw error;
}
}
private showNotification(message: string): void {
// Implementation depends on your notification system
console.log(`🔔 ${message}`);
}
get itemCount(): number {
return this.cartCache?.items.reduce((sum, item) => sum + item.quantity, 0) || 0;
}
get total(): number {
return this.cartCache?.total || 0;
}
}
// 🎯 Usage example
const ecommerceApi = new ECommerceApi('https://api.mystore.com');
const cartManager = new ShoppingCartManager(ecommerceApi, 'user_123');
// 🛍️ Shopping flow
const shopExample = async (): Promise<void> => {
try {
// 🔍 Search for products
const products = await ecommerceApi.searchProducts('laptop');
console.log(`🔍 Found ${products.length} laptops`);
// ➕ Add product to cart
const cart = await cartManager.addItem(products[0].id, 1);
console.log(`🛒 Cart now has ${cartManager.itemCount} items`);
// 💳 Checkout
const order = await cartManager.checkout(
{
street: '123 Main St',
city: 'Anytown',
state: 'CA',
zipCode: '12345',
country: 'US',
},
{
type: 'credit_card',
last4: '1234',
expiryMonth: 12,
expiryYear: 2025,
}
);
console.log(`🎉 Order ${order.id} placed successfully!`);
} catch (error) {
console.error('💥 Shopping failed:', error);
}
};
🎯 Conclusion
Congratulations! 🎉 You’ve mastered the Fetch API with TypeScript and built production-ready HTTP clients! You now have the skills to:
- 🌐 Master HTTP requests with type-safe Fetch API patterns
- 🔧 Build robust clients with retry logic, circuit breakers, and error handling
- 🎯 Create reusable abstractions with generic resource clients and interceptors
- 🧪 Test HTTP code with comprehensive mocking and integration tests
The Fetch API combined with TypeScript’s type safety provides everything you need to build reliable, maintainable networking code. From simple GET requests to complex e-commerce systems, you’re now equipped to handle any HTTP communication challenge!
Keep practicing with different API patterns, explore GraphQL clients, and remember that great networking code is the foundation of exceptional user experiences. The web is your oyster! 🌐✨