Modern web applications rely heavily on APIs and JSON data. TypeScript provides powerful tools for typing API responses, validating data at runtime, and building type-safe API clients. Let's master API integration with TypeScript!
Typing API Responses
Define interfaces for your API response structures to get type safety and autocomplete.
// Basic API response types
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
createdAt: string; // ISO date string from API
}
interface Product {
id: number;
name: string;
description: string;
price: number;
stock: number;
category: string;
}
interface Order {
id: number;
userId: number;
products: {
productId: number;
quantity: number;
price: number;
}[];
total: number;
status: 'pending' | 'processing' | 'shipped' | 'delivered';
createdAt: string;
}
// Generic API wrapper types
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
interface ApiError {
success: false;
error: string;
statusCode: number;
details?: any;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
// Basic fetch with typing
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const user: User = await response.json();
return user;
}
// Using generic wrapper
async function fetchUserWrapped(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const result: ApiResponse<User> = await response.json();
if (!result.success) {
throw new Error(result.message || 'Failed to fetch user');
}
return result.data;
}
// Paginated results
async function fetchProducts(page: number = 1): Promise<PaginatedResponse<Product>> {
const response = await fetch(`/api/products?page=${page}&pageSize=20`);
const data: PaginatedResponse<Product> = await response.json();
return data;
}
// Usage
async function loadUserData() {
try {
const user = await fetchUser(1);
console.log(`User: ${user.name}`);
console.log(`Role: ${user.role}`);
console.log(`Email: ${user.email}`);
const products = await fetchProducts(1);
console.log(`Loaded ${products.data.length} products`);
console.log(`Total pages: ${products.pagination.totalPages}`);
} catch (error) {
console.error('Error:', (error as Error).message);
}
}Building a REST API Client
// HTTP methods type
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
// Request configuration
interface RequestConfig {
method?: HttpMethod;
headers?: Record<string, string>;
body?: any;
params?: Record<string, string | number>;
timeout?: number;
}
// API client class
class ApiClient {
private baseUrl: string;
private defaultHeaders: Record<string, string>;
constructor(baseUrl: string, defaultHeaders: Record<string, string> = {}) {
this.baseUrl = baseUrl;
this.defaultHeaders = {
'Content-Type': 'application/json',
...defaultHeaders
};
}
// Set authentication token
setAuthToken(token: string): void {
this.defaultHeaders['Authorization'] = `Bearer ${token}`;
}
// Build URL with query parameters
private buildUrl(endpoint: string, params?: Record<string, string | number>): string {
const url = new URL(endpoint, this.baseUrl);
if (params) {
Object.entries(params).forEach(([key, value]) => {
url.searchParams.append(key, String(value));
});
}
return url.toString();
}
// Core request method
private async request<T>(
endpoint: string,
config: RequestConfig = {}
): Promise<T> {
const {
method = 'GET',
headers = {},
body,
params,
timeout = 30000
} = config;
const url = this.buildUrl(endpoint, params);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
method,
headers: { ...this.defaultHeaders, ...headers },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
const error: ApiError = await response.json();
throw new Error(error.error || `HTTP error! status: ${response.status}`);
}
const data: T = await response.json();
return data;
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
}
}
// Convenience methods
async get<T>(endpoint: string, params?: Record<string, string | number>): Promise<T> {
return this.request<T>(endpoint, { params });
}
async post<T>(endpoint: string, body: any): Promise<T> {
return this.request<T>(endpoint, { method: 'POST', body });
}
async put<T>(endpoint: string, body: any): Promise<T> {
return this.request<T>(endpoint, { method: 'PUT', body });
}
async patch<T>(endpoint: string, body: any): Promise<T> {
return this.request<T>(endpoint, { method: 'PATCH', body });
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
// Usage
const api = new ApiClient('https://api.example.com');
// Set token after login
api.setAuthToken('your-auth-token');
// GET request
const user = await api.get<User>('/users/1');
console.log(user.name);
// GET with query params
const products = await api.get<PaginatedResponse<Product>>('/products', {
page: 1,
category: 'electronics'
});
// POST request
interface CreateUserDto {
name: string;
email: string;
password: string;
}
const newUser = await api.post<User>('/users', {
name: 'Yusuf Ibrahim',
email: 'yusuf@example.com',
password: 'secure123'
});
// PUT request
const updatedUser = await api.put<User>(`/users/${user.id}`, {
name: 'Yusuf A. Ibrahim'
});
// PATCH request
const patchedUser = await api.patch<User>(`/users/${user.id}`, {
email: 'newemail@example.com'
});
// DELETE request
await api.delete(`/users/${user.id}`);
interface User {
id: number;
name: string;
email: string;
}
interface Product {
id: number;
name: string;
price: number;
}
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
total: number;
};
}
interface ApiError {
error: string;
}Runtime Data Validation
// Manual type guards
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string' &&
['admin', 'user', 'guest'].includes(obj.role)
);
}
function isProduct(obj: any): obj is Product {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.price === 'number' &&
typeof obj.stock === 'number'
);
}
// Validated fetch function
async function fetchValidatedUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error('Invalid user data structure');
}
return data;
}
// Array validation
function isUserArray(obj: any): obj is User[] {
return Array.isArray(obj) && obj.every(isUser);
}
async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
const data: unknown = await response.json();
if (!isUserArray(data)) {
throw new Error('Invalid users data structure');
}
return data;
}
// Generic validator
function createValidator<T>(
checker: (obj: any) => obj is T
): (data: unknown) => T {
return (data: unknown): T => {
if (!checker(data)) {
throw new Error('Validation failed');
}
return data;
};
}
// Usage
const validateUser = createValidator(isUser);
const validateProduct = createValidator(isProduct);
async function loadData() {
const response = await fetch('/api/user/1');
const data = await response.json();
try {
const user = validateUser(data);
console.log('Valid user:', user);
} catch (error) {
console.error('Invalid user data');
}
}
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
}
interface Product {
id: number;
name: string;
price: number;
stock: number;
}💡 Use Runtime Validation Libraries
For production apps, consider using libraries like zod, yup, or io-ts for robust runtime validation. They provide better error messages and are easier to maintain than manual type guards.
Using Axios with TypeScript
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios';
// Axios client with TypeScript
class AxiosApiClient {
private client: AxiosInstance;
constructor(baseURL: string) {
this.client = axios.create({
baseURL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor
this.client.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('token');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
this.client.interceptors.response.use(
(response) => response,
(error: AxiosError<ApiError>) => {
if (error.response) {
// Server responded with error
const apiError = error.response.data;
console.error('API Error:', apiError.error);
if (error.response.status === 401) {
// Handle unauthorized
console.log('Unauthorized - redirecting to login');
}
} else if (error.request) {
// Request made but no response
console.error('Network Error:', error.message);
} else {
// Something else happened
console.error('Error:', error.message);
}
return Promise.reject(error);
}
);
}
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(url, config);
return response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.post<T>(url, data, config);
return response.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.put<T>(url, data, config);
return response.data;
}
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.patch<T>(url, data, config);
return response.data;
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.delete<T>(url, config);
return response.data;
}
}
// Usage
const api = new AxiosApiClient('https://api.example.com');
// GET request
const user = await api.get<User>('/users/1');
console.log(user.name);
// POST request
const newUser = await api.post<User>('/users', {
name: 'Yusuf Ibrahim',
email: 'yusuf@example.com'
});
// Error handling
try {
const user = await api.get<User>('/users/999');
} catch (error) {
if (axios.isAxiosError(error)) {
console.error('Status:', error.response?.status);
console.error('Data:', error.response?.data);
}
}
interface User {
id: number;
name: string;
email: string;
}
interface ApiError {
error: string;
statusCode: number;
}Practical Examples
Example 1: Complete CRUD Operations
interface Product {
id: number;
name: string;
description: string;
price: number;
stock: number;
category: string;
createdAt: string;
updatedAt: string;
}
interface CreateProductDto {
name: string;
description: string;
price: number;
stock: number;
category: string;
}
interface UpdateProductDto {
name?: string;
description?: string;
price?: number;
stock?: number;
category?: string;
}
class ProductService {
private api: ApiClient;
constructor(apiClient: ApiClient) {
this.api = apiClient;
}
// Get all products
async getAll(params?: {
page?: number;
pageSize?: number;
category?: string;
search?: string;
}): Promise<PaginatedResponse<Product>> {
return this.api.get<PaginatedResponse<Product>>('/products', params);
}
// Get single product
async getById(id: number): Promise<Product> {
return this.api.get<Product>(`/products/${id}`);
}
// Create product
async create(data: CreateProductDto): Promise<Product> {
return this.api.post<Product>('/products', data);
}
// Update product
async update(id: number, data: UpdateProductDto): Promise<Product> {
return this.api.patch<Product>(`/products/${id}`, data);
}
// Delete product
async delete(id: number): Promise<void> {
return this.api.delete(`/products/${id}`);
}
// Search products
async search(query: string): Promise<Product[]> {
const response = await this.api.get<PaginatedResponse<Product>>('/products', {
search: query,
pageSize: 50
});
return response.data;
}
// Get by category
async getByCategory(category: string): Promise<Product[]> {
const response = await this.api.get<PaginatedResponse<Product>>('/products', {
category
});
return response.data;
}
// Update stock
async updateStock(id: number, quantity: number): Promise<Product> {
return this.api.patch<Product>(`/products/${id}`, { stock: quantity });
}
}
// Usage
const api = new ApiClient('https://api.example.com');
const productService = new ProductService(api);
async function manageProducts() {
try {
// Get all products
const { data: products, pagination } = await productService.getAll({
page: 1,
pageSize: 20
});
console.log(`Loaded ${products.length} products`);
console.log(`Total: ${pagination.total}`);
// Create new product
const newProduct = await productService.create({
name: 'Laptop',
description: 'High-performance laptop',
price: 450000,
stock: 10,
category: 'electronics'
});
console.log(`Created product: ${newProduct.name}`);
// Update product
const updated = await productService.update(newProduct.id, {
price: 425000,
stock: 15
});
console.log(`Updated price: ₦${updated.price}`);
// Search products
const searchResults = await productService.search('laptop');
console.log(`Found ${searchResults.length} matching products`);
// Get by category
const electronics = await productService.getByCategory('electronics');
console.log(`Electronics: ${electronics.length} items`);
// Delete product
await productService.delete(newProduct.id);
console.log('Product deleted');
} catch (error) {
console.error('Error:', (error as Error).message);
}
}
manageProducts();
interface PaginatedResponse<T> {
data: T[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
class ApiClient {
async get<T>(endpoint: string, params?: any): Promise<T> {
return {} as T;
}
async post<T>(endpoint: string, body: any): Promise<T> {
return {} as T;
}
async patch<T>(endpoint: string, body: any): Promise<T> {
return {} as T;
}
async delete<T>(endpoint: string): Promise<T> {
return {} as T;
}
}Example 2: Authentication Service
interface LoginCredentials {
email: string;
password: string;
}
interface RegisterData {
name: string;
email: string;
password: string;
}
interface AuthResponse {
user: User;
token: string;
refreshToken: string;
}
interface RefreshTokenResponse {
token: string;
refreshToken: string;
}
class AuthService {
private api: ApiClient;
private tokenKey = 'auth_token';
private refreshTokenKey = 'refresh_token';
constructor(apiClient: ApiClient) {
this.api = apiClient;
}
async login(credentials: LoginCredentials): Promise<AuthResponse> {
const response = await this.api.post<AuthResponse>('/auth/login', credentials);
// Store tokens
this.setTokens(response.token, response.refreshToken);
// Set token in API client
this.api.setAuthToken(response.token);
return response;
}
async register(data: RegisterData): Promise<AuthResponse> {
const response = await this.api.post<AuthResponse>('/auth/register', data);
this.setTokens(response.token, response.refreshToken);
this.api.setAuthToken(response.token);
return response;
}
async logout(): Promise<void> {
try {
await this.api.post('/auth/logout', {});
} finally {
this.clearTokens();
}
}
async refreshToken(): Promise<string> {
const refreshToken = this.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await this.api.post<RefreshTokenResponse>('/auth/refresh', {
refreshToken
});
this.setTokens(response.token, response.refreshToken);
this.api.setAuthToken(response.token);
return response.token;
}
async getCurrentUser(): Promise<User> {
return this.api.get<User>('/auth/me');
}
async updatePassword(currentPassword: string, newPassword: string): Promise<void> {
await this.api.post('/auth/password', {
currentPassword,
newPassword
});
}
async resetPassword(email: string): Promise<void> {
await this.api.post('/auth/reset-password', { email });
}
private setTokens(token: string, refreshToken: string): void {
localStorage.setItem(this.tokenKey, token);
localStorage.setItem(this.refreshTokenKey, refreshToken);
}
private getRefreshToken(): string | null {
return localStorage.getItem(this.refreshTokenKey);
}
private clearTokens(): void {
localStorage.removeItem(this.tokenKey);
localStorage.removeItem(this.refreshTokenKey);
}
isAuthenticated(): boolean {
return !!localStorage.getItem(this.tokenKey);
}
}
// Usage
const api = new ApiClient('https://api.example.com');
const authService = new AuthService(api);
async function handleAuth() {
try {
// Login
const { user, token } = await authService.login({
email: 'yusuf@example.com',
password: 'secure123'
});
console.log(`Logged in as: ${user.name}`);
console.log(`Token: ${token}`);
// Get current user
const currentUser = await authService.getCurrentUser();
console.log(`Current user: ${currentUser.name}`);
// Check authentication
if (authService.isAuthenticated()) {
console.log('User is authenticated');
}
// Logout
await authService.logout();
console.log('Logged out successfully');
} catch (error) {
console.error('Auth error:', (error as Error).message);
}
}
interface User {
id: number;
name: string;
email: string;
}
class ApiClient {
setAuthToken(token: string): void {}
async get<T>(endpoint: string): Promise<T> { return {} as T; }
async post<T>(endpoint: string, body: any): Promise<T> { return {} as T; }
}Example 3: Real-time Updates with Server-Sent Events
interface ServerEvent<T> {
type: string;
data: T;
timestamp: string;
}
interface NotificationEvent {
id: number;
message: string;
type: 'info' | 'warning' | 'error';
read: boolean;
}
class SSEClient<T> {
private eventSource: EventSource | null = null;
private listeners = new Map<string, Set<(data: T) => void>>();
constructor(private url: string) {}
connect(): void {
if (this.eventSource) {
return; // Already connected
}
this.eventSource = new EventSource(this.url);
this.eventSource.onopen = () => {
console.log('SSE connection established');
};
this.eventSource.onerror = (error) => {
console.error('SSE error:', error);
this.disconnect();
};
this.eventSource.onmessage = (event) => {
try {
const serverEvent: ServerEvent<T> = JSON.parse(event.data);
this.emit(serverEvent.type, serverEvent.data);
} catch (error) {
console.error('Failed to parse SSE data:', error);
}
};
}
on(eventType: string, callback: (data: T) => void): void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(callback);
}
off(eventType: string, callback: (data: T) => void): void {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
callbacks.delete(callback);
}
}
private emit(eventType: string, data: T): void {
const callbacks = this.listeners.get(eventType);
if (callbacks) {
callbacks.forEach(callback => callback(data));
}
}
disconnect(): void {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
console.log('SSE connection closed');
}
}
}
// Usage
const sseClient = new SSEClient<NotificationEvent>('/api/notifications/stream');
// Add event listeners
sseClient.on('notification', (notification) => {
console.log(`New notification: ${notification.message}`);
if (notification.type === 'error') {
alert(notification.message);
}
});
sseClient.on('update', (data) => {
console.log('Data updated:', data);
});
// Connect to server
sseClient.connect();
// Later, disconnect
// sseClient.disconnect();Test Your Knowledge
Type Safety Check
Which API response typing is correct?
// Option A
interface ApiResponse {
data: any;
status: number;
}
async function fetchUser() {
const response = await fetch('/api/user');
const data: ApiResponse = await response.json();
return data.data;
}
// Option B
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(): Promise<User> {
const response = await fetch('/api/user');
const data: User = await response.json();
return data;
}
// Option C
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
async function fetchUser(): Promise<User> {
const response = await fetch('/api/user');
const result: ApiResponse<User> = await response.json();
if (!result.success) {
throw new Error(result.error);
}
return result.data;
}
// Option D
async function fetchUser() {
const response = await fetch('/api/user');
return response.json(); // No typing
}Common TypeScript Error
interface User {
id: number;
name: string;
email: string;
}
// Unsafe API call - no validation
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: User = await response.json();
// Just trusting the API response matches User interface!
return data;
}
// Using the function
const user = await fetchUser(1);
console.log(user.name.toUpperCase()); // ❌ Runtime error if API returns different structure!
// What if API returns:
// { userId: 1, fullName: "Yusuf", emailAddress: "yusuf@example.com" }
// TypeScript won't catch this - it trusts your type assertion!❌ TypeError: Cannot read property 'toUpperCase' of undefined (runtime error)
What's Wrong?
Just typing the response doesn't validate it! The API might return a different structure than your interface expects. TypeScript can't validate runtime JSON data.
interface User {
id: number;
name: string;
email: string;
}
// Type guard for runtime validation
function isUser(obj: any): obj is User {
return (
typeof obj === 'object' &&
obj !== null &&
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string'
);
}
// ✅ Safe API call with validation
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: unknown = await response.json();
// Validate at runtime
if (!isUser(data)) {
throw new Error('Invalid user data structure from API');
}
return data; // TypeScript knows this is User now
}
// ✅ Using the validated function
try {
const user = await fetchUser(1);
console.log(user.name.toUpperCase()); // ✅ Safe - validated
} catch (error) {
console.error('Failed to fetch user:', error);
}
// ✅ Using Zod for validation (recommended)
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 fetchUserWithZod(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
// Zod validates and throws if invalid
return UserSchema.parse(data);
}Solution: Validate API responses at runtime
Best Practices
- Always type API responses - create interfaces for all data structures
- Validate data at runtime - don't trust API responses blindly
- Handle errors properly - check response.ok and status codes
- Use generic wrappers for consistent API response structures
- Create dedicated service classes for each API resource
- Add request/response interceptors for auth and error handling
- Set timeouts to prevent hanging requests
- Type query parameters and request bodies - prevent typos
// ✅ Good: Type all API responses
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed');
return response.json();
}
// ✅ Good: Generic API wrapper
interface ApiResponse<T> {
success: boolean;
data: T;
error?: string;
}
async function apiGet<T>(url: string): Promise<T> {
const response = await fetch(url);
const result: ApiResponse<T> = await response.json();
if (!result.success) throw new Error(result.error);
return result.data;
}
// ✅ Good: Dedicated service classes
class UserService {
constructor(private api: ApiClient) {}
async getById(id: number): Promise<User> {
return this.api.get<User>(`/users/${id}`);
}
async create(data: CreateUserDto): Promise<User> {
return this.api.post<User>('/users', data);
}
}
// ✅ Good: Runtime validation
function isUser(obj: any): obj is User {
return (
typeof obj.id === 'number' &&
typeof obj.name === 'string' &&
typeof obj.email === 'string'
);
}
// ❌ Avoid: Using 'any' for responses
async function badFetch(): Promise<any> {
return fetch('/api/data').then(r => r.json());
}
// ❌ Avoid: No error handling
async function alsoBad() {
const response = await fetch('/api/data');
return response.json(); // What if it fails?
}
// ❌ Avoid: No validation
async function trustingTooMuch(): Promise<User> {
const data = await fetch('/api/user').then(r => r.json());
return data; // Blindly trusting API!
}
interface CreateUserDto {
name: string;
email: string;
}
class ApiClient {
async get<T>(url: string): Promise<T> { return {} as T; }
async post<T>(url: string, body: any): Promise<T> { return {} as T; }
}Key Takeaways
- Define interfaces for all API response structures
- Use generic types
<T>for flexible API clients - Validate data at runtime—TypeScript can't validate JSON
- Always check
response.okbefore parsing - Create dedicated service classes for each API resource
- Use interceptors for authentication and error handling
- Type query parameters and request bodies
- Handle timeouts and network errors gracefully
- Consider using Axios for advanced features
- Build reusable, type-safe API clients
What's Next?
Excellent work! You've mastered API integration and JSON handling with TypeScript. Next, we'll explore Type Declarations and DefinitelyTyped—how to use @types packages, create custom declaration files (.d.ts), and work with third-party libraries that don't have built-in TypeScript support!
Get ready to master type declarations!