TypeScript with REST APIs
TypeScript with REST APIs
TypeScript provides excellent support for working with REST APIs by enabling type-safe HTTP requests, typed responses, and structured error handling. In this lesson, we'll explore how to type API responses, use Axios and Fetch with TypeScript, and build robust API client patterns that catch errors at compile time rather than runtime.
Why Type Your API Layer?
Typing your API interactions provides several critical benefits:
- Type Safety: Catch API response structure mismatches before deployment
- Autocomplete: Get IntelliSense for all API response properties
- Refactoring: Confidently rename or restructure API models throughout your codebase
- Documentation: Types serve as living documentation of your API contracts
- Error Prevention: Eliminate undefined property access errors
Typing API Response Models
Start by defining TypeScript interfaces or types for your API response structures:
// User resource type
interface User {
id: number;
email: string;
username: string;
firstName: string;
lastName: string;
avatar?: string;
createdAt: string;
updatedAt: string;
}
// Paginated response wrapper
interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
totalPages: number;
};
links: {
first: string;
last: string;
prev: string | null;
next: string | null;
};
}
// API error structure
interface ApiError {
message: string;
errors?: Record<string, string[]>;
statusCode: number;
}
// Single resource response
interface SingleResponse<T> {
data: T;
message?: string;
}
// Success response without data
interface SuccessResponse {
message: string;
success: boolean;
}
types/api.ts file so they can be imported throughout your application. Keep these types in sync with your backend API documentation.
Typing Fetch API Requests
The native Fetch API can be typed for safe, predictable API calls:
// Generic fetch wrapper with type safety
async function fetchApi<T>(
url: string,
options?: RequestInit
): Promise<T> {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
...options,
});
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message || 'API request failed');
}
return response.json() as Promise<T>;
}
// Usage examples
async function getUser(id: number): Promise<User> {
const response = await fetchApi<SingleResponse<User>>(
`https://api.example.com/users/${id}`
);
return response.data;
}
async function getUsers(
page: number = 1
): Promise<PaginatedResponse<User>> {
return fetchApi<PaginatedResponse<User>>(
`https://api.example.com/users?page=${page}`
);
}
async function createUser(
userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>
): Promise<User> {
const response = await fetchApi<SingleResponse<User>>(
'https://api.example.com/users',
{
method: 'POST',
body: JSON.stringify(userData),
}
);
return response.data;
}
Omit and Pick to create input types from your response types. This ensures consistency between what you send and what you receive from the API.
Typing Axios Requests
Axios provides excellent TypeScript support out of the box. Here's how to leverage it effectively:
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
// Create typed Axios instance
class ApiClient {
private client: AxiosInstance;
constructor(baseURL: string) {
this.client = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
});
// Add request interceptor for auth tokens
this.client.interceptors.request.use(
(config) => {
const token = localStorage.getItem('authToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Add response interceptor for error handling
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized access
localStorage.removeItem('authToken');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
}
async get<T>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.get(url, config);
return response.data;
}
async post<T, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.post(
url,
data,
config
);
return response.data;
}
async put<T, D = any>(
url: string,
data?: D,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.put(
url,
data,
config
);
return response.data;
}
async delete<T>(
url: string,
config?: AxiosRequestConfig
): Promise<T> {
const response: AxiosResponse<T> = await this.client.delete(url, config);
return response.data;
}
}
// Export singleton instance
export const apiClient = new ApiClient('https://api.example.com');
Building API Service Layers
Organize your API calls into service classes for better structure and reusability:
class UserService {
private basePath = '/users';
async getAll(params?: {
page?: number;
perPage?: number;
search?: string;
}): Promise<PaginatedResponse<User>> {
return apiClient.get<PaginatedResponse<User>>(this.basePath, {
params,
});
}
async getById(id: number): Promise<User> {
const response = await apiClient.get<SingleResponse<User>>(
`${this.basePath}/${id}`
);
return response.data;
}
async create(
userData: Omit<User, 'id' | 'createdAt' | 'updatedAt'>
): Promise<User> {
const response = await apiClient.post<SingleResponse<User>>(
this.basePath,
userData
);
return response.data;
}
async update(
id: number,
userData: Partial<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>
): Promise<User> {
const response = await apiClient.put<SingleResponse<User>>(
`${this.basePath}/${id}`,
userData
);
return response.data;
}
async delete(id: number): Promise<SuccessResponse> {
return apiClient.delete<SuccessResponse>(`${this.basePath}/${id}`);
}
async uploadAvatar(id: number, file: File): Promise<User> {
const formData = new FormData();
formData.append('avatar', file);
const response = await apiClient.post<SingleResponse<User>>(
`${this.basePath}/${id}/avatar`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
}
}
export const userService = new UserService();
Advanced API Client Patterns
Implement sophisticated patterns for production-ready API clients:
interface RetryConfig {
maxRetries: number;
retryDelay: number;
retryOn: number[];
}
async function fetchWithRetry<T>(
url: string,
options?: RequestInit,
retryConfig: RetryConfig = {
maxRetries: 3,
retryDelay: 1000,
retryOn: [408, 429, 500, 502, 503, 504],
}
): Promise<T> {
let lastError: Error;
for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (
!response.ok &&
retryConfig.retryOn.includes(response.status) &&
attempt < retryConfig.maxRetries
) {
await new Promise((resolve) =>
setTimeout(resolve, retryConfig.retryDelay * (attempt + 1))
);
continue;
}
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.message);
}
return response.json() as Promise<T>;
} catch (error) {
lastError = error as Error;
if (attempt < retryConfig.maxRetries) {
await new Promise((resolve) =>
setTimeout(resolve, retryConfig.retryDelay * (attempt + 1))
);
}
}
}
throw lastError!;
}
class CancellableRequest<T> {
private controller: AbortController;
private promise: Promise<T>;
constructor(url: string, options?: RequestInit) {
this.controller = new AbortController();
this.promise = fetch(url, {
...options,
signal: this.controller.signal,
}).then((response) => {
if (!response.ok) {
throw new Error('Request failed');
}
return response.json() as Promise<T>;
});
}
cancel(): void {
this.controller.abort();
}
getPromise(): Promise<T> {
return this.promise;
}
}
// Usage
const request = new CancellableRequest<PaginatedResponse<User>>(
'https://api.example.com/users'
);
// Cancel if needed
setTimeout(() => request.cancel(), 5000);
try {
const data = await request.getPromise();
console.log(data);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
console.log('Request was cancelled');
}
}
AbortError exceptions that should be caught and handled appropriately rather than treated as actual API failures.
Type Guards for API Responses
Use type guards to validate API responses at runtime:
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'number' &&
typeof obj.email === 'string' &&
typeof obj.username === 'string' &&
typeof obj.firstName === 'string' &&
typeof obj.lastName === 'string' &&
typeof obj.createdAt === 'string' &&
typeof obj.updatedAt === 'string'
);
}
function isPaginatedResponse<T>(
obj: any,
itemGuard: (item: any) => item is T
): obj is PaginatedResponse<T> {
return (
typeof obj === 'object' &&
obj !== null &&
Array.isArray(obj.data) &&
obj.data.every(itemGuard) &&
typeof obj.meta === 'object' &&
typeof obj.meta.total === 'number'
);
}
// Usage with validation
async function getSafeUsers(): Promise<PaginatedResponse<User>> {
const data = await fetchApi<any>('https://api.example.com/users');
if (!isPaginatedResponse(data, isUser)) {
throw new Error('Invalid API response structure');
}
return data;
}
- Create a typed API client for a posts API with endpoints: GET /posts, GET /posts/:id, POST /posts, PUT /posts/:id, DELETE /posts/:id
- Define TypeScript interfaces for Post (with id, title, content, authorId, createdAt) and PaginatedPosts responses
- Implement a PostService class with type-safe methods for all CRUD operations
- Add error handling with custom ApiError types and retry logic for failed requests
- Create type guards to validate API responses at runtime
- Add request cancellation support using AbortController
Summary
In this lesson, you learned how to work with REST APIs in TypeScript by typing API responses, using Axios and Fetch with full type safety, and implementing robust API client patterns. You explored service layer architecture, request retry mechanisms, cancellation patterns, and runtime validation with type guards. These patterns enable you to build reliable, maintainable API integrations that catch errors at compile time and provide excellent developer experience through autocomplete and type checking.