TypeScript with Web APIs
Working with web APIs becomes much safer and more predictable when using TypeScript. By adding type definitions to API requests and responses, you can catch potential errors during development rather than in production. TypeScript helps ensure your application properly handles API data structures while providing excellent code completion and documentation right in your editor.
Fetch API with Type Safety
The Fetch API is modern JavaScript's way to make HTTP requests, and TypeScript makes it even better:
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data as User;
}
Creating a Reusable API Client
Instead of writing fetch calls everywhere, create a typed API client:
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`);
return this.handleResponse<T>(response);
}
private async handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`);
}
return response.json() as Promise<T>;
}
}
// Usage
const api = new ApiClient('https://api.example.com');
const user = await api.get<User>('/users/1');
Defining API Response Types
Create comprehensive types for your API responses:
interface ApiResponse<T> {
data: T;
status: number;
timestamp: string;
pagination?: {
page: number;
totalPages: number;
};
}
interface Product {
id: string;
name: string;
price: number;
category: string;
}
async function fetchProducts(): Promise<ApiResponse<Product[]>> {
const response = await fetch('https://api.example.com/products');
return response.json();
}
Handling Different Response Types
Many APIs return different shapes for success and error responses:
type ApiResult<T, E = ErrorResponse> =
| { success: true; data: T }
| { success: false; error: E };
interface ErrorResponse {
message: string;
code: number;
details?: string[];
}
async function safeFetch<T>(url: string): Promise<ApiResult<T>> {
try {
const response = await fetch(url);
const data = await response.json();
if (!response.ok) {
return {
success: false,
error: data as ErrorResponse
};
}
return {
success: true,
data: data as T
};
} catch (error) {
return {
success: false,
error: {
message: 'Network error',
code: 0
}
};
}
}
POST Requests with Type Safety
Ensure your request bodies match what the API expects:
interface CreateUserRequest {
name: string;
email: string;
password: string;
}
async function createUser(user: CreateUserRequest): Promise<User> {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(user),
});
return response.json();
}
Handling Paginated Responses
Many APIs return paginated data that needs special typing:
interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
perPage: number;
hasNext: boolean;
}
async function fetchPaginatedProducts(
page: number = 1,
limit: number = 10
): Promise<PaginatedResponse<Product>> {
const response = await fetch(
`https://api.example.com/products?page=${page}&limit=${limit}`
);
return response.json();
}
Transforming API Data
Sometimes you need to transform API data before using it:
interface RawProduct {
id: string;
product_name: string;
product_price: number;
product_category: string;
}
interface TransformedProduct {
id: string;
name: string;
price: number;
category: string;
}
function transformProduct(raw: RawProduct): TransformedProduct {
return {
id: raw.id,
name: raw.product_name,
price: raw.product_price,
category: raw.product_category,
};
}
async function getProducts(): Promise<TransformedProduct[]> {
const response = await fetch('https://api.example.com/products');
const rawProducts: RawProduct[] = await response.json();
return rawProducts.map(transformProduct);
}
Error Handling Strategies
Create a consistent error handling approach:
class ApiError extends Error {
constructor(
public message: string,
public statusCode?: number,
public details?: any
) {
super(message);
Object.setPrototypeOf(this, new.target.prototype);
}
}
async function fetchWithErrorHandling<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
let errorDetails;
try {
errorDetails = await response.json();
} catch {
errorDetails = { message: response.statusText };
}
throw new ApiError(
'API request failed',
response.status,
errorDetails
);
}
return response.json();
}
Working with Headers
TypeScript can help manage headers properly:
interface ApiHeaders {
'Content-Type': string;
Authorization?: string;
'X-Request-ID'?: string;
}
async function fetchWithHeaders<T>(
url: string,
headers: ApiHeaders
): Promise<T> {
const response = await fetch(url, { headers });
return response.json();
}
// Usage
await fetchWithHeaders<User[]>('https://api.example.com/users', {
'Content-Type': 'application/json',
Authorization: 'Bearer token123'
});
Mocking APIs for Testing
Create type-safe mocks for testing:
const mockUsers: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' }
];
function mockFetch<T>(mockData: T): Promise<T> {
return new Promise(resolve =>
setTimeout(() => resolve(mockData), 100)
);
}
// Usage in tests
const users = await mockFetch<User[]>(mockUsers);
Using Zod for Runtime Validation
Combine TypeScript with runtime validation:
import { z } from 'zod';
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
type User = z.infer<typeof UserSchema>;
async function fetchUserWithValidation(id: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
const data = await response.json();
return UserSchema.parse(data);
}
Caching API Responses
Implement type-safe caching:
class ApiCache {
private cache = new Map<string, { data: any; timestamp: number }>();
private ttl: number;
constructor(ttl: number = 300000) { // 5 minutes default
this.ttl = ttl;
}
async get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.ttl) {
return cached.data as T;
}
const data = await fetcher();
this.cache.set(key, { data, timestamp: Date.now() });
return data;
}
}
// Usage
const cache = new ApiCache();
const user = await cache.get<User>(
'user-1',
() => fetchUser(1)
);
By applying these TypeScript patterns when working with web APIs, you'll create more robust applications that handle data safely and predictably. The key is to define clear types for your API contracts and use them consistently throughout your application.