TypeScript Tutorial: Creating a Simple Data Fetching App

In today’s interconnected world, applications frequently need to fetch data from external sources. Whether it’s retrieving product information from an API, displaying weather updates, or populating a list of blog posts, data fetching is a fundamental skill for any web developer. This tutorial will guide you through building a simple data-fetching application using TypeScript, focusing on clarity, practicality, and best practices. We’ll cover the core concepts, from making HTTP requests to handling responses and displaying data in a user-friendly manner. This tutorial is designed for beginners and intermediate developers, providing a solid foundation for tackling more complex data-driven applications.

Why Data Fetching Matters

Imagine a website that doesn’t update its content, or an app that can’t access real-time information. These applications would quickly become obsolete. Data fetching is the engine that drives dynamic content, enabling applications to:

  • Display up-to-date information.
  • Interact with external services and APIs.
  • Provide a rich, interactive user experience.

Understanding how to fetch and process data is therefore crucial for creating modern, functional web applications. TypeScript, with its strong typing and modern features, provides an excellent environment for building robust and maintainable data-fetching applications.

Setting Up Your TypeScript Project

Before we dive into the code, let’s set up a basic TypeScript project. If you’re new to TypeScript, don’t worry! We’ll go through each step.

  1. Initialize a new project: Open your terminal or command prompt and create a new directory for your project. Navigate into the directory and run npm init -y. This command creates a package.json file with default settings.
  2. Install TypeScript: Install TypeScript globally or locally. For a local installation, run npm install --save-dev typescript.
  3. Create a TypeScript configuration file: Run npx tsc --init. This command creates a tsconfig.json file, which configures the TypeScript compiler. You can customize this file to suit your project’s needs. For our project, the default settings are fine, but you might want to change the outDir to specify where the compiled JavaScript files will be placed.
  4. Create your TypeScript file: Create a file, for example, app.ts, in your project directory. This is where we’ll write our TypeScript code.

Your project structure should now look something like this:

my-data-fetching-app/
├── node_modules/
├── app.ts
├── package.json
├── package-lock.json
└── tsconfig.json

Making HTTP Requests with fetch

The fetch API is a modern interface for making HTTP requests in JavaScript and TypeScript. It’s built into most modern browsers and provides a simple, Promise-based way to fetch data. Let’s look at a basic example:


// app.ts
async function fetchData() {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');

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

    const data = await response.json();
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchData();

Let’s break down this code:

  • async function fetchData(): We define an asynchronous function to handle our data fetching. The async keyword allows us to use await within the function.
  • fetch('https://jsonplaceholder.typicode.com/todos/1'): The fetch function initiates the request. We’re fetching data from a free, public API (JSONPlaceholder) that provides mock data for testing. The URL specifies the endpoint we’re requesting from.
  • await: The await keyword pauses the execution of the fetchData function until the promise returned by fetch resolves (either successfully or with an error).
  • response.ok: This property checks if the HTTP response status code is in the range 200-299, indicating a successful request. If the request fails (e.g., a 404 Not Found error), response.ok will be false.
  • response.json(): This method parses the response body as JSON. It also returns a promise, so we use await again.
  • console.log(data): We log the parsed JSON data to the console.
  • try...catch: We use a try...catch block to handle potential errors during the fetch process, such as network issues or invalid URLs.

To run this code, you’ll need to compile it to JavaScript using the TypeScript compiler. Open your terminal and navigate to your project directory. Then, run tsc app.ts. This will create a app.js file (or in the directory you specified in tsconfig.json). You can then run the JavaScript file using Node.js (node app.js) or by including it in an HTML file.

Typing the Data

One of the main advantages of TypeScript is its ability to provide static typing. Let’s define an interface to represent the data we’re fetching from the JSONPlaceholder API. This will help us catch errors early and improve code readability.


// app.ts
interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

async function fetchData(): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');

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

    const data: Todo = await response.json(); // Type assertion
    console.log(data);
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchData();

In this updated code:

  • interface Todo: We define an interface called Todo that specifies the structure of the data we expect to receive from the API. It includes properties like userId, id, title, and completed, along with their respective types.
  • const data: Todo = await response.json(): We use a type assertion (: Todo) to tell TypeScript that the data variable should be of type Todo. This allows TypeScript to check if the data we receive from the API matches the expected structure. If the API returns data that doesn’t conform to the Todo interface, TypeScript will flag an error during compilation.
  • Promise<void>: The function signature is updated to reflect that fetchData returns a promise that resolves to void.

By using interfaces, we can ensure that our code is type-safe and more robust, reducing the likelihood of runtime errors.

Handling Arrays of Data

Often, APIs return arrays of data. Let’s modify our code to fetch an array of todos and display them. We’ll also update our Todo interface to reflect the data structure.


// app.ts
interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

async function fetchTodos(): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');

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

    const todos: Todo[] = await response.json(); // Type assertion for an array
    todos.forEach(todo => {
      console.log(`Title: ${todo.title}, Completed: ${todo.completed}`);
    });
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchTodos();

Key changes:

  • fetch('https://jsonplaceholder.typicode.com/todos'): We change the URL to fetch the entire list of todos.
  • const todos: Todo[] = await response.json(): We declare todos as an array of Todo objects using the type annotation Todo[].
  • todos.forEach(...): We iterate over the array of todos and log each todo’s title and completion status.

Displaying Data in HTML

Let’s take it a step further and display the fetched data in an HTML page. First, create an HTML file (e.g., index.html) in your project directory:


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Data Fetching App</title>
</head>
<body>
  <h2>Todos</h2>
  <ul id="todo-list"></ul>
  <script src="app.js"></script>  <!-- Assuming app.js is the compiled JavaScript file -->
</body>
</html>

Now, modify your TypeScript code to update the HTML:


// app.ts
interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

async function fetchTodos(): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');

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

    const todos: Todo[] = await response.json();
    const todoList = document.getElementById('todo-list');

    if (todoList) {
      todos.forEach(todo => {
        const listItem = document.createElement('li');
        listItem.textContent = `${todo.title} (${todo.completed ? 'Completed' : 'Pending'})`;
        todoList.appendChild(listItem);
      });
    }
  } catch (error) {
    console.error('Fetch error:', error);
  }
}

fetchTodos();

Explanation:

  • document.getElementById('todo-list'): We get a reference to the <ul> element with the ID “todo-list” in our HTML.
  • document.createElement('li'): We create a new list item (<li>) element for each todo.
  • listItem.textContent = ...: We set the text content of the list item to display the todo’s title and completion status.
  • todoList.appendChild(listItem): We append the list item to the <ul> element.

After compiling your TypeScript code (tsc app.ts) and opening index.html in a browser, you should see a list of todos displayed on the page.

Error Handling

Robust error handling is essential for any data-fetching application. Let’s look at some common error scenarios and how to handle them effectively.

Network Errors

Network errors can occur due to a variety of reasons, such as a lost internet connection or a server being down. The fetch API itself can throw an error if a network problem occurs during the initial request. We’ve already included a try...catch block to handle these potential errors. Let’s improve the error message to provide more context.


// app.ts
async function fetchTodos(): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');

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

    const todos: Todo[] = await response.json();
    const todoList = document.getElementById('todo-list');

    if (todoList) {
      todos.forEach(todo => {
        const listItem = document.createElement('li');
        listItem.textContent = `${todo.title} (${todo.completed ? 'Completed' : 'Pending'})`;
        todoList.appendChild(listItem);
      });
    }
  } catch (error) {
    if (error instanceof Error) {
      console.error('Fetch error:', error.message);  // More informative error message
    } else {
      console.error('An unexpected error occurred:', error);
    }
  }
}

In this example, we’ve improved the error message by including the response.statusText, which provides a more descriptive explanation of the error (e.g., “Not Found”, “Internal Server Error”). We’ve also added a check with instanceof Error to ensure we’re handling an actual error object. This helps to provide more informative error messages to the console.

API Errors

APIs can also return error responses with specific status codes (e.g., 400 Bad Request, 401 Unauthorized, 404 Not Found, 500 Internal Server Error). We can handle these errors by checking the response.status property.


// app.ts
async function fetchTodos(): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');

    if (!response.ok) {
      if (response.status === 404) {
        console.error('Resource not found.');
      } else {
        throw new Error(`HTTP error! Status: ${response.status} - ${response.statusText}`);
      }
    }

    const todos: Todo[] = await response.json();
    const todoList = document.getElementById('todo-list');

    if (todoList) {
      todos.forEach(todo => {
        const listItem = document.createElement('li');
        listItem.textContent = `${todo.title} (${todo.completed ? 'Completed' : 'Pending'})`;
        todoList.appendChild(listItem);
      });
    }
  } catch (error) {
    if (error instanceof Error) {
      console.error('Fetch error:', error.message);  // More informative error message
    } else {
      console.error('An unexpected error occurred:', error);
    }
  }
}

In this example, we’ve added a check for a 404 status code and provided a specific error message. You can extend this approach to handle other error codes as needed, providing more informative feedback to the user.

Parsing Errors

Sometimes, the API might return data in an unexpected format, leading to parsing errors. The response.json() method can throw an error if it cannot parse the response body as JSON. This is another reason to use the try...catch block.


// app.ts
async function fetchTodos(): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');

    if (!response.ok) {
      if (response.status === 404) {
        console.error('Resource not found.');
      } else {
        throw new Error(`HTTP error! Status: ${response.status} - ${response.statusText}`);
      }
    }

    const todos: Todo[] = await response.json();
    const todoList = document.getElementById('todo-list');

    if (todoList) {
      todos.forEach(todo => {
        const listItem = document.createElement('li');
        listItem.textContent = `${todo.title} (${todo.completed ? 'Completed' : 'Pending'})`;
        todoList.appendChild(listItem);
      });
    }
  } catch (error) {
    if (error instanceof Error) {
      console.error('Fetch error:', error.message);  // More informative error message
    } else {
      console.error('An unexpected error occurred:', error);
    }
  }
}

While the try/catch block handles this, ensuring that the interface matches the response is the best way to prevent this issue.

Adding Loading Indicators

When fetching data, it’s good practice to provide visual feedback to the user, especially if the request takes a while. A loading indicator (e.g., a spinner) can improve the user experience.

Let’s add a simple loading indicator to our application.


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Data Fetching App</title>
  <style>
    #loading {
      display: none;
    }
    #loading.active {
      display: block;
    }
  </style>
</head>
<body>
  <h2>Todos</h2>
  <div id="loading">Loading...</div>  <!-- Loading indicator -->
  <ul id="todo-list"></ul>
  <script src="app.js"></script>
</body>
</html>

We’ve added a <div> element with the ID “loading” to display the loading indicator. We’ve also added some basic CSS to hide the loading indicator by default and show it when the “active” class is added.

Now, let’s modify the TypeScript code:


// app.ts
interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

async function fetchTodos(): Promise {
  const loadingIndicator = document.getElementById('loading');
  const todoList = document.getElementById('todo-list');

  if (loadingIndicator) {
    loadingIndicator.classList.add('active'); // Show loading indicator
  }

  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos');

    if (!response.ok) {
      if (response.status === 404) {
        console.error('Resource not found.');
      } else {
        throw new Error(`HTTP error! Status: ${response.status} - ${response.statusText}`);
      }
    }

    const todos: Todo[] = await response.json();

    if (todoList) {
      todos.forEach(todo => {
        const listItem = document.createElement('li');
        listItem.textContent = `${todo.title} (${todo.completed ? 'Completed' : 'Pending'})`;
        todoList.appendChild(listItem);
      });
    }
  } catch (error) {
    if (error instanceof Error) {
      console.error('Fetch error:', error.message);  // More informative error message
    } else {
      console.error('An unexpected error occurred:', error);
    }
  } finally {
    if (loadingIndicator) {
      loadingIndicator.classList.remove('active'); // Hide loading indicator
    }
  }
}

fetchTodos();

Key changes:

  • const loadingIndicator = document.getElementById('loading'): We get a reference to the loading indicator element.
  • loadingIndicator.classList.add('active'): We add the “active” class to the loading indicator before the fetch request, showing the loading indicator.
  • finally { ... }: We use a finally block to ensure that the loading indicator is hidden, whether the request succeeds or fails. The code inside the finally block always executes.
  • loadingIndicator.classList.remove('active'): We remove the “active” class, hiding the loading indicator, in the finally block.

Now, when you run the application, you’ll see the “Loading…” message while the data is being fetched. This significantly improves the user experience by providing visual feedback.

Making POST Requests

Besides fetching data, you often need to send data to an API, such as when creating a new resource. Let’s look at how to make a POST request using the fetch API.


// app.ts
interface NewTodo {
  userId: number;
  title: string;
  completed: boolean;
}

async function createTodo(newTodo: NewTodo): Promise {
  try {
    const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(newTodo)
    });

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

    const createdTodo = await response.json();
    console.log('Created todo:', createdTodo);
  } catch (error) {
    console.error('Create error:', error);
  }
}

// Example usage:
const newTodo: NewTodo = {
  userId: 1,
  title: 'Buy groceries',
  completed: false
};

createTodo(newTodo);

Explanation:

  • interface NewTodo: We define an interface for the data we’re sending to the API.
  • fetch('https://jsonplaceholder.typicode.com/todos', { ... }): We pass a second argument to fetch, an options object, to configure the request.
  • method: 'POST': We specify the HTTP method as “POST”.
  • headers: { 'Content-Type': 'application/json' }: We set the Content-Type header to “application/json” to indicate that we’re sending JSON data.
  • body: JSON.stringify(newTodo): We convert the newTodo object to a JSON string using JSON.stringify() and set it as the request body.
  • const createdTodo = await response.json(): We parse the response body, which likely contains the newly created todo object (returned by the API).

Common Mistakes and How to Fix Them

When working with data fetching in TypeScript, it’s easy to make mistakes. Here are some common pitfalls and how to avoid them:

  • Incorrect URL: Double-check the URL you’re using. Typos or incorrect endpoints are a frequent cause of errors. Use a tool like Postman or Insomnia to test the API endpoint separately.
  • Missing or Incorrect Headers: Make sure you’re setting the correct headers, especially the Content-Type header for POST and PUT requests.
  • Uncaught Errors: Always use try...catch blocks to handle potential errors. Log the error messages to the console for debugging.
  • Incorrect Type Definitions: Ensure your TypeScript interfaces accurately reflect the data structure returned by the API. Use the browser’s developer tools to inspect the API’s response and adjust the interface accordingly.
  • Asynchronous Operations: Remember that fetch is asynchronous. Use async/await or Promises to handle the asynchronous nature of the requests.
  • Cross-Origin Resource Sharing (CORS) Issues: If you’re fetching data from a different domain, you might encounter CORS issues. The server needs to have CORS enabled to allow requests from your domain.

Key Takeaways

  • Use fetch for making HTTP requests: It’s a modern and straightforward way to fetch data in JavaScript and TypeScript.
  • Leverage TypeScript interfaces for type safety: Define interfaces to represent the data structure and catch errors early.
  • Implement robust error handling: Use try...catch blocks to handle network errors, API errors, and parsing errors.
  • Provide a good user experience: Use loading indicators to provide visual feedback to the user.
  • Understand the basics of POST requests: Learn how to send data to an API.

FAQ

Here are some frequently asked questions about data fetching in TypeScript:

  1. What is the difference between fetch and XMLHttpRequest? fetch is a modern, Promise-based API for making HTTP requests, while XMLHttpRequest (XHR) is an older API. fetch is generally preferred for its cleaner syntax and ease of use.
  2. How do I handle CORS errors? CORS (Cross-Origin Resource Sharing) errors occur when a web page from one origin (domain, protocol, and port) tries to make a request to a resource on a different origin. The server needs to enable CORS by setting the appropriate headers (e.g., Access-Control-Allow-Origin). If you control the server, you can configure it to allow requests from your origin. If you don’t control the server, you might need to use a proxy server.
  3. How do I cancel a fetch request? You can use the AbortController API to cancel a fetch request. Create an AbortController instance, and pass its signal to the fetch options. When you call abort() on the controller, the fetch request will be cancelled.
  4. How do I handle authentication? Authentication usually involves sending an authentication token (e.g., a JWT) in the Authorization header of your requests. You’ll need to obtain the token (e.g., by logging in the user) and then include it in the headers of your fetch requests.
  5. Can I use fetch in Node.js? Yes, you can use fetch in Node.js, but you need to install a polyfill like node-fetch because the built-in fetch API is only available in modern browsers.

Building data-fetching applications in TypeScript is a fundamental skill for modern web development. By understanding the core concepts, utilizing TypeScript’s type system, and implementing robust error handling, you can create reliable and maintainable applications that interact with the wider world. Remember to practice regularly, experiment with different APIs, and gradually incorporate more advanced features as you become more comfortable. The journey of learning never truly ends, but with each new project, you’ll gain valuable experience and expand your capabilities. Embracing the power of data fetching will undoubtedly empower you to build more dynamic, engaging, and valuable web applications for users everywhere.