How to Call REST APIs Using TypeScript?

Recently, while building a frontend project with TypeScript, I had to use a REST API to fetch user data and submit form inputs. Initially, I began with the basic Fetch API, but as the project evolved, I needed something more effective, particularly for handling errors and implementing reusable logic.

Then I used REST APIs to fetch data, send updates, or perform various operations. TypeScript’s strong typing makes API calls more reliable and maintainable compared to plain JavaScript.

In this blog, I’ll explain several approaches to calling REST APIs using TypeScript, from basic fetch requests to more advanced libraries.

Using the Fetch API in TypeScript

The Fetch API is built into modern browsers and provides a simple way to make HTTP requests. Let’s see how we can use it with TypeScript:

async function createUser(userData: Omit<User, 'id'>): Promise<User | null> {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(userData),
    });

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    const newUser: User = await response.json();
    return newUser;
  } catch (error) {
    if (error instanceof Error) {
      console.error('Error creating user:', error.message);
    } else {
      console.error('Unknown error:', error);
    }
    return null;
  }
}

//Usage
type User = {
  id: number;
  name: string;
  email: string;
};

const newUserData = {
  name: 'John Doe',
  email: 'john@example.com',
};

createUser(newUserData).then(user => {
  if (user) {
    console.log('User created:', user);
  }
});

Output:

Fetch API in TypeScript

The type annotation Promise<User[]> ensures that our function returns a promise resolving to an array of User objects. This helps catch type errors at compile time.

Check out: Check Types in TypeScript

Making POST Requests in TypeScript

Here’s how I typically implement a POST request when I need to create a new resource:

type User = {
  id: number;
  name: string;
  email: string;
};

async function createUser(userData: Omit<User, 'id'>): Promise<User | null> {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/users', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(userData),
    });

    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }

    const newUser: User = await response.json();
    return newUser;
  } catch (error) {
    if (error instanceof Error) {
      console.error('Error creating user:', error.message);
    } else {
      console.error('Unknown error:', error);
    }
    return null;
  }
}

// Usage
const newUserData: Omit<User, 'id'> = {
  name: 'John Doe',
  email: 'john@example.com',
};

createUser(newUserData).then(user => {
  if (user) {
    console.log('User created:', user);
  }
});

Output:

API calling in TypeScript

I’m using Omit<User, ‘id’> here to create a type that includes all properties of User except ‘id’, which is perfect for creating a new user (since the API will assign the ID).

Check out: TypeScript Type Casting

Using Axios with TypeScript

While Fetch is great, I often prefer Axios for its more intuitive API and built-in features. Here’s how to use Axios with TypeScript:

import axios from 'axios';

// Define the User type
type User = {
  id: number;
  name: string;
  email: string;
};

// Create a typed axios instance
const api = axios.create({
  baseURL: 'https://jsonplaceholder.typicode.com', // <- changed to working demo API
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
  }
});

// GET request
async function getUsers(): Promise<User[]> {
  try {
    const response = await api.get<User[]>('/users');
    return response.data;
  } catch (error) {
    console.error('Error fetching users:', error);
    return [];
  }
}

// POST request
async function createUser(userData: Omit<User, 'id'>): Promise<User | null> {
  try {
    const response = await api.post<User>('/users', userData);
    return response.data;
  } catch (error) {
    console.error('Error creating user:', error);
    return null;
  }
}

// Usage example
createUser({ name: 'Jane Doe', email: 'jane@example.com' }).then(user => {
  if (user) {
    console.log('User created:', user);
  }
});

Output:

Use Axios in TypeScript

I love how Axios allows you to specify the expected response type using generics. This makes the code much cleaner compared to the Fetch API.

Check out: Use Try Catch in TypeScript

Creating a Reusable API Client

For larger projects, I recommend creating a reusable API client class:

import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';

class ApiClient {
  private client: AxiosInstance;

  constructor(config?: AxiosRequestConfig) {
    this.client = axios.create({
      baseURL: 'https://jsonplaceholder.typicode.com', // use working test API
      timeout: 10000,
      ...config,
    });

    // Add request interceptor for auth tokens
    this.client.interceptors.request.use(config => {
      // Check if running in browser
      if (typeof window !== 'undefined') {
        const token = localStorage.getItem('token');
        if (token && config.headers) {
          config.headers.Authorization = `Bearer ${token}`;
        }
      }
      return config;
    });
  }

  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>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response: AxiosResponse<T> = await this.client.post(url, data, config);
    return response.data;
  }

  async put<T>(url: string, data?: any, 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;
  }
}

// Usage
const api = new ApiClient();

// Define a sample Product type
interface Product {
  id: number;
  title: string;
  price: number;
}

// Sample GET request (note: jsonplaceholder has /posts not /products, we'll use /posts)
api.get<Product[]>('/posts')
  .then(products => console.log('Products:', products.slice(0, 3))) // display first 3
  .catch(err => console.error('GET error:', err));

// Sample POST request (jsonplaceholder will accept and return a fake ID)
api.post<Product>('/posts', { title: 'iPhone 13', price: 999 })
  .then(product => console.log('Created Product:', product))
  .catch(err => console.error('POST error:', err));

Output:

Create Reusable API in TypeScript

This approach is extremely useful when you need to add authentication, logging, or error handling to all API calls consistently.

Check out: Handle Catch Error Types in TypeScript

Error Handling Best Practices

Proper error handling is crucial when working with APIs. Here’s how I typically structure my error handling:

interface ApiError {
  message: string;
  status?: number;
  data?: any;
}

interface User {
  id: number;
  name: string;
  email: string;
}

async function fetchData<T>(url: string): Promise<T> {
  try {
    const response = await fetch(url);

    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      const error: ApiError = {
        message: `API error: ${response.statusText}`,
        status: response.status,
        data: errorData
      };
      throw error;
    }

    return await response.json() as T;
  } catch (error: any) {
    if (error && typeof error === 'object' && 'status' in error) {
      const apiError = error as ApiError;
      console.error(`API Error ${apiError.status}:`, apiError.message);
    } else {
      console.error('Network error:', error);
    }
    throw error;
  }
}

// Usage with try-catch
async function loadUserData() {
  try {
    const user = await fetchData<User>('https://jsonplaceholder.typicode.com/users/1');
    console.log('User loaded:', user);
  } catch (error) {
    alert('Failed to load user data. Please try again later.');
  }
}

loadUserData();

Output:

Error handling in TypeScript API calling

This approach provides detailed error information while keeping the error handling consistent throughout your application.

Check out: Convert JSON to TypeScript Interface

Using React Query with TypeScript

For React applications, I’ve found React Query to be an excellent library for managing API calls. It handles caching, loading states, and retries out of the box:

import { useQuery, useMutation, QueryClient, QueryClientProvider } from 'react-query';
import axios from 'axios';

// Set up the client
const queryClient = new QueryClient();

// Component that uses React Query
function UsersComponent() {
  // Query for fetching users
  const { data: users, isLoading, error } = useQuery<User[], Error>(
    'users',
    async () => {
      const response = await axios.get<User[]>('https://api.example.com/users');
      return response.data;
    }
  );

  // Mutation for creating a user
  const createUserMutation = useMutation<User, Error, Omit<User, 'id'>>(
    async (newUser) => {
      const response = await axios.post<User>('https://api.example.com/users', newUser);
      return response.data;
    },
    {
      // Invalidate and refetch after mutation
      onSuccess: () => {
        queryClient.invalidateQueries('users');
      },
    }
  );

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users?.map(user => (
          <li key={user.id}>{user.name} ({user.email})</li>
        ))}
      </ul>
      <button
        onClick={() => {
          createUserMutation.mutate({
            name: 'New User',
            email: 'new@example.com'
          });
        }}
      >
        Add User
      </button>
    </div>
  );
}

// Wrap your app with the provider
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UsersComponent />
    </QueryClientProvider>
  );
}

React Query combined with TypeScript is a powerful combination for building robust React applications that interact with APIs.

I hope you found this guide helpful for implementing TypeScript code to call REST APIs. Using TypeScript’s static type-checking with API calls not only prevents runtime errors but also improves the developer experience with better autocomplete and documentation.

Whether you choose the built-in Fetch API, Axios, or React Query depends on your specific project needs. For simple applications, Fetch might be sufficient, while larger applications may benefit from the additional features of Axios or React Query.

Remember to always implement proper error handling and consider creating reusable API client abstractions to keep your codebase clean and maintainable.

51 Python Programs

51 PYTHON PROGRAMS PDF FREE

Download a FREE PDF (112 Pages) Containing 51 Useful Python Programs.

pyython developer roadmap

Aspiring to be a Python developer?

Download a FREE PDF on how to become a Python developer.

Let’s be friends

Be the first to know about sales and special discounts.