From Zero to AI

Lesson 4.4: Fetch API

Duration: 60 minutes

Learning Objectives

By the end of this lesson, you will be able to:

  1. Use the Fetch API to make HTTP requests
  2. Handle responses and parse different data formats
  3. Configure fetch with headers, body, and options
  4. Handle errors properly when using fetch
  5. Implement timeout and abort functionality
  6. Type fetch requests and responses with TypeScript

Introduction

The Fetch API is the modern way to make HTTP requests in JavaScript. It is built into browsers and Node.js (v18+), uses Promises, and provides a cleaner interface than the older XMLHttpRequest.

// The simplest fetch call
const response = await fetch('https://api.example.com/data');
const data = await response.json();

Basic Fetch Usage

Making a GET Request

// Simple GET request
const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
const post = await response.json();

console.log(post.title);

The Response Object

Fetch returns a Response object with several useful properties:

const response = await fetch('https://api.example.com/users');

// Status information
console.log(response.status); // 200
console.log(response.statusText); // "OK"
console.log(response.ok); // true (status 200-299)

// Headers
console.log(response.headers.get('Content-Type'));

// URL (after any redirects)
console.log(response.url);

// Was the request redirected?
console.log(response.redirected);

// Response type
console.log(response.type); // "basic", "cors", etc.

Reading Response Body

The response body can only be read once. Choose the appropriate method:

// Parse as JSON
const json = await response.json();

// Parse as text
const text = await response.text();

// Parse as binary (Blob)
const blob = await response.blob();

// Parse as ArrayBuffer
const buffer = await response.arrayBuffer();

// Parse as FormData
const formData = await response.formData();

Important: You can only call one of these methods. The body stream is consumed after reading.

// WRONG - Second call will fail
const json = await response.json();
const text = await response.text(); // Error: Body already consumed

// If you need to read twice, clone first
const clone = response.clone();
const json = await response.json();
const text = await clone.text();

Configuring Fetch Requests

Fetch Options

const response = await fetch(url, {
  method: 'POST', // HTTP method
  headers: {
    // Request headers
    'Content-Type': 'application/json',
    Authorization: 'Bearer token',
  },
  body: JSON.stringify(data), // Request body
  mode: 'cors', // CORS mode
  credentials: 'include', // Cookie handling
  cache: 'no-cache', // Cache mode
  redirect: 'follow', // Redirect handling
  signal: abortController.signal, // For cancellation
});

Making POST Requests

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

interface CreateUserData {
  name: string;
  email: string;
}

async function createUser(userData: CreateUserData): Promise<User> {
  const response = await fetch('https://api.example.com/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

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

  return response.json();
}

// Usage
const newUser = await createUser({
  name: 'John Doe',
  email: 'john@example.com',
});

Making PUT and PATCH Requests

// PUT - Replace entire resource
async function replaceUser(id: number, userData: CreateUserData): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'PUT',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(userData),
  });

  return response.json();
}

// PATCH - Partial update
async function updateUserEmail(id: number, email: string): Promise<User> {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email }),
  });

  return response.json();
}

Making DELETE Requests

async function deleteUser(id: number): Promise<void> {
  const response = await fetch(`https://api.example.com/users/${id}`, {
    method: 'DELETE',
  });

  if (!response.ok) {
    throw new Error(`Failed to delete user: ${response.status}`);
  }
}

Error Handling

Understanding Fetch Errors

Critical: Fetch only rejects on network errors, not HTTP errors like 404 or 500!

// This does NOT throw for 404 or 500
const response = await fetch('https://api.example.com/not-found');
// response.ok will be false, but no error thrown

// Network error (no internet, DNS failure) WILL throw
try {
  await fetch('https://this-does-not-exist.example');
} catch (error) {
  console.log('Network error:', error);
}

Proper Error Handling Pattern

async function fetchData<T>(url: string): Promise<T> {
  let response: Response;

  try {
    response = await fetch(url);
  } catch (error) {
    // Network error
    throw new Error(`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`);
  }

  if (!response.ok) {
    // HTTP error (4xx, 5xx)
    throw new Error(`HTTP error: ${response.status} ${response.statusText}`);
  }

  return response.json();
}

Detailed Error Handling

class ApiError extends Error {
  constructor(
    public status: number,
    public statusText: string,
    public body?: unknown
  ) {
    super(`API Error: ${status} ${statusText}`);
    this.name = 'ApiError';
  }
}

async function fetchWithErrorHandling<T>(url: string, options?: RequestInit): Promise<T> {
  let response: Response;

  try {
    response = await fetch(url, options);
  } catch (error) {
    if (error instanceof TypeError) {
      throw new Error('Network error: Please check your internet connection');
    }
    throw error;
  }

  if (!response.ok) {
    let errorBody: unknown;

    try {
      errorBody = await response.json();
    } catch {
      // Response body is not JSON
    }

    throw new ApiError(response.status, response.statusText, errorBody);
  }

  return response.json();
}

// Usage with error handling
async function getUser(id: number): Promise<User> {
  try {
    return await fetchWithErrorHandling<User>(`https://api.example.com/users/${id}`);
  } catch (error) {
    if (error instanceof ApiError) {
      if (error.status === 404) {
        throw new Error(`User ${id} not found`);
      }
      if (error.status === 401) {
        throw new Error('Please log in to view this user');
      }
    }
    throw error;
  }
}

Timeout and Cancellation

Using AbortController

Fetch supports cancellation through AbortController:

async function fetchWithTimeout<T>(url: string, timeoutMs: number): Promise<T> {
  const controller = new AbortController();

  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeoutMs);

  try {
    const response = await fetch(url, {
      signal: controller.signal,
    });

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

    return response.json();
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      throw new Error(`Request timed out after ${timeoutMs}ms`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Usage
try {
  const data = await fetchWithTimeout('https://api.example.com/slow', 5000);
} catch (error) {
  console.log(error.message); // "Request timed out after 5000ms"
}

Manual Cancellation

const controller = new AbortController();

// Start a long request
const fetchPromise = fetch('https://api.example.com/large-data', {
  signal: controller.signal,
});

// Cancel it after user action
document.getElementById('cancelBtn')?.addEventListener('click', () => {
  controller.abort();
});

try {
  const response = await fetchPromise;
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Request was cancelled');
  }
}

AbortSignal.timeout() (Modern Approach)

// Simpler timeout (Node.js 18+ and modern browsers)
const response = await fetch('https://api.example.com/data', {
  signal: AbortSignal.timeout(5000), // 5 second timeout
});

TypeScript with Fetch

Typing Responses

interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

// Option 1: Type assertion (simple but less safe)
const response = await fetch("https://api.example.com/posts/1");
const post = await response.json() as Post;

// Option 2: Generic function (better)
async function fetchJson<T>(url: string): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP error: ${response.status}`);
  }
  return response.json() as Promise<T>;
}

const post = await fetchJson<Post>("https://api.example.com/posts/1");

// Option 3: Runtime validation (safest - covered in Module 5)
import { z } from "zod";

const PostSchema = z.object({
  id: z.number(),
  userId: z.number(),
  title: z.string(),
  body: z.string()
});

async function fetchPost(id: number): Promise<Post> {
  const response = await fetch(`https://api.example.com/posts/${id}`);
  const data = await response.json();
  return PostSchema.parse(data); // Validates at runtime
}

Creating a Typed API Client

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

interface CreateUserData {
  name: string;
  email: string;
}

interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

class ApiClient {
  constructor(private baseUrl: string) {}

  private async request<T>(endpoint: string, options?: RequestInit): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`;

    const response = await fetch(url, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
    });

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

    return response.json();
  }

  // Users
  async getUsers(): Promise<User[]> {
    return this.request<User[]>('/users');
  }

  async getUser(id: number): Promise<User> {
    return this.request<User>(`/users/${id}`);
  }

  async createUser(data: CreateUserData): Promise<User> {
    return this.request<User>('/users', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }

  // Posts
  async getPosts(): Promise<Post[]> {
    return this.request<Post[]>('/posts');
  }

  async getUserPosts(userId: number): Promise<Post[]> {
    return this.request<Post[]>(`/users/${userId}/posts`);
  }
}

// Usage
const api = new ApiClient('https://jsonplaceholder.typicode.com');

const users = await api.getUsers();
const user = await api.getUser(1);
const posts = await api.getUserPosts(1);

Common Patterns

Retry Logic

async function fetchWithRetry<T>(
  url: string,
  options?: RequestInit,
  maxRetries: number = 3,
  delayMs: number = 1000
): Promise<T> {
  let lastError: Error | undefined;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

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

      return response.json();
    } catch (error) {
      lastError = error instanceof Error ? error : new Error(String(error));

      console.log(`Attempt ${attempt + 1} failed: ${lastError.message}`);

      if (attempt < maxRetries - 1) {
        // Wait before retrying (exponential backoff)
        const delay = delayMs * Math.pow(2, attempt);
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }

  throw new Error(`Failed after ${maxRetries} attempts: ${lastError?.message}`);
}

Parallel Requests

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

interface Post {
  id: number;
  title: string;
}

async function fetchUserData(userId: number): Promise<{ user: User; posts: Post[] }> {
  // Fetch user and posts in parallel
  const [userResponse, postsResponse] = await Promise.all([
    fetch(`https://api.example.com/users/${userId}`),
    fetch(`https://api.example.com/users/${userId}/posts`),
  ]);

  if (!userResponse.ok || !postsResponse.ok) {
    throw new Error('Failed to fetch user data');
  }

  const [user, posts] = await Promise.all([
    userResponse.json() as Promise<User>,
    postsResponse.json() as Promise<Post[]>,
  ]);

  return { user, posts };
}

Request with Loading State

interface FetchState<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}

async function fetchWithState<T>(
  url: string,
  onStateChange: (state: FetchState<T>) => void
): Promise<T> {
  onStateChange({ data: null, loading: true, error: null });

  try {
    const response = await fetch(url);

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

    const data = (await response.json()) as T;
    onStateChange({ data, loading: false, error: null });

    return data;
  } catch (error) {
    const err = error instanceof Error ? error : new Error(String(error));
    onStateChange({ data: null, loading: false, error: err });
    throw err;
  }
}

// Usage
let state: FetchState<User[]> = { data: null, loading: false, error: null };

await fetchWithState<User[]>('https://api.example.com/users', (newState) => {
  state = newState;
  console.log('State updated:', state);
});

Exercises

Exercise 1: Basic Fetch

Fetch a list of posts from JSONPlaceholder and display the first 5 titles:

async function displayFirstFivePosts(): Promise<void> {}

displayFirstFivePosts();
Solution
interface Post {
  id: number;
  userId: number;
  title: string;
  body: string;
}

async function displayFirstFivePosts(): Promise<void> {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');

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

  const posts: Post[] = await response.json();

  posts.slice(0, 5).forEach((post, index) => {
    console.log(`${index + 1}. ${post.title}`);
  });
}

displayFirstFivePosts();

Exercise 2: POST Request

Create a function to add a new comment to a post:

interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

interface CreateCommentData {
  postId: number;
  name: string;
  email: string;
  body: string;
}

async function createComment(data: CreateCommentData): Promise<Comment> {}
Solution
interface Comment {
  id: number;
  postId: number;
  name: string;
  email: string;
  body: string;
}

interface CreateCommentData {
  postId: number;
  name: string;
  email: string;
  body: string;
}

async function createComment(data: CreateCommentData): Promise<Comment> {
  const response = await fetch('https://jsonplaceholder.typicode.com/comments', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(data),
  });

  if (!response.ok) {
    throw new Error(`Failed to create comment: ${response.status}`);
  }

  return response.json();
}

// Test
const newComment = await createComment({
  postId: 1,
  name: 'My Comment',
  email: 'test@example.com',
  body: 'This is a great post!',
});

console.log('Created comment:', newComment);

Exercise 3: Error Handling

Create a function that fetches a user and handles different error scenarios:

async function getUserSafe(id: number): Promise<User | null> {
  // Return user if found
  // Return null if 404
  // Throw error for other failures
}
Solution
interface User {
  id: number;
  name: string;
  email: string;
}

async function getUserSafe(id: number): Promise<User | null> {
  let response: Response;

  try {
    response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`);
  } catch (error) {
    throw new Error('Network error: Unable to connect to server');
  }

  if (response.status === 404) {
    return null; // User not found
  }

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

  return response.json();
}

// Test
const user = await getUserSafe(1);
if (user) {
  console.log('Found user:', user.name);
} else {
  console.log('User not found');
}

const notFound = await getUserSafe(999);
console.log('User 999:', notFound); // null

Exercise 4: Fetch with Timeout

Implement a wrapper that adds timeout to any fetch request:

async function fetchWithTimeout(
  url: string,
  options?: RequestInit,
  timeoutMs?: number
): Promise<Response> {
  // Default timeout: 10 seconds
}
Solution
async function fetchWithTimeout(
  url: string,
  options?: RequestInit,
  timeoutMs: number = 10000
): Promise<Response> {
  const controller = new AbortController();

  const timeoutId = setTimeout(() => {
    controller.abort();
  }, timeoutMs);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal,
    });

    return response;
  } catch (error) {
    if (error instanceof Error && error.name === 'AbortError') {
      throw new Error(`Request timeout after ${timeoutMs}ms`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

// Test
try {
  const response = await fetchWithTimeout('https://jsonplaceholder.typicode.com/posts', {}, 5000);
  console.log('Status:', response.status);
} catch (error) {
  console.error('Error:', error);
}

Key Takeaways

  1. Fetch returns a Promise that resolves to a Response object
  2. response.ok is true for status codes 200-299
  3. Fetch only rejects on network errors, not HTTP errors (4xx, 5xx)
  4. Response body can only be read once - clone if needed
  5. Use JSON.stringify() for request body, response.json() for parsing
  6. AbortController enables timeout and cancellation
  7. Always check response.ok before processing the response
  8. Create typed wrapper functions for better TypeScript integration

Resources

Resource Type Level
MDN: Fetch API Documentation Beginner
MDN: Using Fetch Tutorial Beginner
MDN: AbortController Documentation Intermediate
JavaScript.info: Fetch Tutorial Beginner

Next Lesson

While Fetch is powerful, some developers prefer Axios for its cleaner API and additional features. Let us explore Axios as an alternative.

Continue to Lesson 4.5: Axios - Alternative to Fetch