TypeScript API Consumption
Modern web applications rarely exist in isolation. They typically communicate with backend services through APIs to fetch data, submit information, authenticate users, and more. TypeScript provides powerful tools that make API consumption safer and more developer-friendly compared to plain JavaScript.
Introduction
API consumption is a fundamental skill for web developers. With TypeScript, you can add type safety to your API calls, making your code more robust and easier to maintain. This guide will walk you through the process of consuming APIs with TypeScript, from basic HTTP requests to building reusable API services.
Understanding API Basics
Before diving into TypeScript-specific aspects, let's quickly review what APIs are.
What is an API?
An API (Application Programming Interface) defines how different software components should interact with each other. In web development, we typically work with HTTP/REST APIs that expose endpoints we can call to perform operations.
Common HTTP Methods
GET
: Retrieve dataPOST
: Create new dataPUT
: Update existing dataDELETE
: Remove dataPATCH
: Partially update data
Making HTTP Requests in TypeScript
TypeScript provides several ways to make HTTP requests:
1. Using the Fetch API
The Fetch API is built into modern browsers and is available in Node.js with minimal polyfills.
// Define interface for the response
interface User {
id: number;
name: string;
email: string;
}
async function getUser(userId: number): Promise<User> {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<User>;
}
// Usage
async function displayUserInfo() {
try {
const user = await getUser(123);
console.log(`User: ${user.name} (${user.email})`);
} catch (error) {
console.error('Failed to fetch user:', error);
}
}
2. Using Axios
Axios is a popular HTTP client library that works in both browser and Node.js environments. It provides a more consistent API and better defaults than the Fetch API.
First, you'll need to install Axios:
npm install axios
Then you can use it in your TypeScript code:
import axios from 'axios';
interface User {
id: number;
name: string;
email: string;
}
async function getUser(userId: number): Promise<User> {
const response = await axios.get<User>(`https://api.example.com/users/${userId}`);
return response.data; // Axios automatically parses JSON and returns the data
}
// Usage
async function displayUserInfo() {
try {
const user = await getUser(123);
console.log(`User: ${user.name} (${user.email})`);
} catch (error) {
console.error('Failed to fetch user:', error);
}
}
Type Definitions for API Data
One of TypeScript's greatest strengths is its ability to define and enforce types for your data. This is especially valuable when working with APIs.
Creating Interfaces for API Responses
// Define interfaces based on your API's response structure
interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest'; // Union type for restricted values
lastLogin?: Date; // Optional property
}
interface Post {
id: number;
userId: number;
title: string;
body: string;
tags: string[];
publishedAt: string; // ISO date string
}
// Using the interfaces
async function getUserPosts(userId: number): Promise<Post[]> {
const response = await fetch(`https://api.example.com/users/${userId}/posts`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return response.json() as Promise<Post[]>;
}
Handling Nested API Data
APIs often return nested data structures. TypeScript interfaces can model these relationships:
interface Comment {
id: number;
text: string;
author: string;
createdAt: string;
}
interface Post {
id: number;
title: string;
content: string;
comments: Comment[];
author: {
id: number;
name: string;
avatarUrl: string;
};
}
Error Handling in API Calls
Proper error handling is crucial when working with APIs, as many things can go wrong in network communications.
Error Response Types
// Define error response structure
interface ApiError {
status: number;
message: string;
details?: string;
}
async function fetchData<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
// Try to parse error as JSON
try {
const errorData = await response.json() as ApiError;
throw new Error(`API Error: ${errorData.message}`);
} catch (parseError) {
// If error parsing fails, fall back to status text
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
}
return response.json() as Promise<T>;
}
Using try/catch with Async/Await
async function getUserData(userId: number): Promise<User> {
try {
const user = await fetchData<User>(`https://api.example.com/users/${userId}`);
return user;
} catch (error) {
console.error('Error fetching user data:', error);
// You might want to show a user-friendly error message
throw new Error('Unable to load user data. Please try again later.');
}
}
Building Reusable API Services
For larger applications, it's a good practice to centralize your API calls in service classes.
Creating an API Service Class
// api.service.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
export class ApiService {
private api: AxiosInstance;
constructor(baseURL: string) {
this.api = axios.create({
baseURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Add request interceptor for authentication
this.api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
}
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.api.get(url, config);
return response.data;
}
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.api.post(url, data, config);
return response.data;
}
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.api.put(url, data, config);
return response.data;
}
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response: AxiosResponse<T> = await this.api.delete(url, config);
return response.data;
}
}
Using the API Service
// user.service.ts
import { ApiService } from './api.service';
interface User {
id: number;
name: string;
email: string;
}
export class UserService {
private apiService: ApiService;
constructor(baseURL: string) {
this.apiService = new ApiService(baseURL);
}
async getUsers(): Promise<User[]> {
return this.apiService.get<User[]>('/users');
}
async getUserById(id: number): Promise<User> {
return this.apiService.get<User>(`/users/${id}`);
}
async createUser(userData: Omit<User, 'id'>): Promise<User> {
return this.apiService.post<User>('/users', userData);
}
async updateUser(id: number, userData: Partial<User>): Promise<User> {
return this.apiService.put<User>(`/users/${id}`, userData);
}
async deleteUser(id: number): Promise<void> {
return this.apiService.delete<void>(`/users/${id}`);
}
}
// Usage example
const userService = new UserService('https://api.example.com');
async function manageUsers() {
// Get all users
const users = await userService.getUsers();
console.log('All users:', users);
// Create a new user
const newUser = await userService.createUser({
name: 'John Doe',
email: '[email protected]'
});
console.log('Created user:', newUser);
// Update user
const updatedUser = await userService.updateUser(newUser.id, {
name: 'John Smith'
});
console.log('Updated user:', updatedUser);
}
Working with Authentication
Most APIs require authentication. Here's how to handle it in TypeScript:
JWT Authentication
// auth.service.ts
import { ApiService } from './api.service';
interface LoginCredentials {
email: string;
password: string;
}
interface LoginResponse {
token: string;
user: {
id: number;
name: string;
email: string;
};
}
export class AuthService {
private apiService: ApiService;
private tokenKey = 'auth_token';
constructor(baseURL: string) {
this.apiService = new ApiService(baseURL);
}
async login(credentials: LoginCredentials): Promise<boolean> {
try {
const response = await this.apiService.post<LoginResponse>('/login', credentials);
this.saveToken(response.token);
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
}
logout(): void {
localStorage.removeItem(this.tokenKey);
}
isAuthenticated(): boolean {
return !!this.getToken();
}
getToken(): string | null {
return localStorage.getItem(this.tokenKey);
}
private saveToken(token: string): void {
localStorage.setItem(this.tokenKey, token);
}
}
Real-world Example: Building a Weather App
Let's tie everything together with a real-world example - a simple weather app that fetches data from a weather API.
// weather.types.ts
export interface WeatherResponse {
location: {
name: string;
country: string;
};
current: {
temp_c: number;
temp_f: number;
condition: {
text: string;
icon: string;
};
wind_kph: number;
humidity: number;
feelslike_c: number;
};
forecast: {
forecastday: ForecastDay[];
};
}
export interface ForecastDay {
date: string;
day: {
maxtemp_c: number;
mintemp_c: number;
condition: {
text: string;
icon: string;
};
};
}
// weather.service.ts
import { ApiService } from './api.service';
import { WeatherResponse } from './weather.types';
export class WeatherService {
private apiService: ApiService;
private apiKey: string;
constructor(baseURL: string, apiKey: string) {
this.apiService = new ApiService(baseURL);
this.apiKey = apiKey;
}
async getWeatherForCity(city: string): Promise<WeatherResponse> {
return this.apiService.get<WeatherResponse>('/forecast.json', {
params: {
key: this.apiKey,
q: city,
days: 3,
aqi: 'no',
alerts: 'no'
}
});
}
}
// weather-app.ts
import { WeatherService } from './weather.service';
class WeatherApp {
private weatherService: WeatherService;
private cityInput: HTMLInputElement;
private weatherDisplay: HTMLDivElement;
constructor() {
this.weatherService = new WeatherService(
'https://api.weatherapi.com/v1',
'YOUR_API_KEY'
);
this.cityInput = document.getElementById('city-input') as HTMLInputElement;
this.weatherDisplay = document.getElementById('weather-display') as HTMLDivElement;
this.setupEventListeners();
}
private setupEventListeners(): void {
const searchButton = document.getElementById('search-button');
if (searchButton) {
searchButton.addEventListener('click', () => this.fetchWeather());
}
this.cityInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.fetchWeather();
}
});
}
private async fetchWeather(): Promise<void> {
const city = this.cityInput.value.trim();
if (!city) return;
try {
const weatherData = await this.weatherService.getWeatherForCity(city);
this.displayWeather(weatherData);
} catch (error) {
console.error('Failed to fetch weather:', error);
this.weatherDisplay.innerHTML = `
<div class="error">
Failed to fetch weather data. Please try again.
</div>
`;
}
}
private displayWeather(data: WeatherResponse): void {
const current = data.current;
const location = data.location;
this.weatherDisplay.innerHTML = `
<div class="weather-card">
<h2>${location.name}, ${location.country}</h2>
<div class="current-weather">
<img src="${current.condition.icon}" alt="${current.condition.text}" />
<div class="temp">${current.temp_c}°C</div>
<div class="condition">${current.condition.text}</div>
</div>
<div class="details">
<div>Feels like: ${current.feelslike_c}°C</div>
<div>Wind: ${current.wind_kph} kph</div>
<div>Humidity: ${current.humidity}%</div>
</div>
<div class="forecast">
<h3>3-Day Forecast</h3>
<div class="forecast-days">
${data.forecast.forecastday.map(day => `
<div class="forecast-day">
<div>${new Date(day.date).toLocaleDateString('en-US', { weekday: 'short' })}</div>
<img src="${day.day.condition.icon}" alt="${day.day.condition.text}" />
<div>${day.day.mintemp_c}° - ${day.day.maxtemp_c}°</div>
</div>
`).join('')}
</div>
</div>
</div>
`;
}
}
// Initialize the app
document.addEventListener('DOMContentLoaded', () => {
new WeatherApp();
});
Best Practices for API Consumption in TypeScript
-
Define Type Interfaces: Always define interfaces for API request and response data.
-
Centralize API Logic: Create reusable services for API calls instead of scattering fetch/axios calls throughout your application.
-
Proper Error Handling: Always handle potential errors in your API calls, providing fallbacks and user-friendly messages.
-
Environment Variables: Store API keys and base URLs in environment variables, not hard-coded in your source.
-
Request Cancelation: Implement request cancelation for long-running requests, especially in UI components that might unmount before the request completes.
-
Caching: Consider implementing caching for frequently accessed and rarely changing data.
-
Rate Limiting: Be mindful of API rate limits and implement throttling if necessary.
Summary
In this guide, we've covered the essentials of consuming APIs with TypeScript:
- Making HTTP requests using Fetch API and Axios
- Creating type definitions for API data
- Handling errors properly
- Building reusable API services
- Implementing authentication
- Working with a real-world example
TypeScript significantly improves the developer experience when working with APIs by providing type safety, better IntelliSense, and clearer error messages. This leads to more robust applications with fewer runtime errors.
Further Resources
Exercises
-
Create a TypeScript interface for a TODO item from the JSONPlaceholder API and implement a service to fetch and manipulate TODOs.
-
Add error handling to the Weather App example that displays different error messages based on the API error code.
-
Extend the ApiService class to include request caching using a simple in-memory cache.
-
Create a service that uses TypeScript generics to fetch data from any endpoint with type safety.
-
Implement token refresh logic in the AuthService to automatically refresh expired JWT tokens.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)