LearnAI ToolsCareerPractice BuildsPlayContact
TypeScriptIntermediate~2.5 hours

Typed API Client

Write a generic fetch wrapper that returns fully typed data for any endpoint shape.

GenericsInterfacesAsync Functions

Overview

Every real app eventually calls a JSON API, and in plain JavaScript, `fetch(url).then(r => r.json())` hands you back a value of type `any` — nothing stops you from typing `user.emial` instead of `user.email` and only finding out at runtime. This project fixes that with a single generic function: one `fetchJson<T>` that any part of the app can call with any shape of endpoint, and get a fully typed value back with real autocomplete on every field.

Generics are the mechanism that makes one function work for many shapes without losing type safety. `T` in `fetchJson<T>(url: string): Promise<T>` is a placeholder — it is filled in at the *call site* (`fetchJson<User>(...)`, `fetchJson<Post[]>(...)`), and the compiler substitutes it everywhere `T` appears, so the same three-line function is reused for every endpoint in the app instead of writing a near-duplicate wrapper per response shape.

What You'll Build
  • Interfaces describing two real response shapes from a public test API: `User` and `Post`.
  • A generic `fetchJson<T>(url: string): Promise<T>` wrapper reused for every request the app makes.
  • A `Result<T, E>` discriminated union that represents "succeeded with data" or "failed with a typed error" without ever throwing across function boundaries.
  • A typed `ApiError` class carrying a real HTTP status code, instead of throwing plain strings.
  • A `fetchResult<T>` helper that combines the generic fetcher with the `Result` type for safe, typed error handling end to end.

Prerequisites

  • The Fetch API and `async`/`await` in plain JavaScript, including `try`/`catch`.
  • Interfaces and basic type annotations from a first TypeScript project (see the Typed Task Manager project).
  • What a generic type parameter looks like syntactically, e.g. `Array<string>` or `Promise<number>` — you'll write your own `<T>` here for the first time.
  • Classes well enough to read `class ApiError extends Error { ... }` — a deeper look at classes comes in the Inventory System project.

Project Structure

This project is a single `api.ts` module plus a small `index.html`/`styles.css` shell that displays whatever the client fetches. `api.ts` has no DOM code in it at all — it only knows how to talk to endpoints and return typed data or a typed error. `app.ts` (introduced in Step 5) is the only file that imports from `api.ts` and touches the DOM, which is a separation you will see in almost every real TypeScript codebase: a data layer with no UI concerns, and a UI layer that consumes it.

The requests in this tutorial hit JSONPlaceholder, a free fake REST API used specifically for this kind of practice — no key or account needed. Because the interactive demo at the end of this page runs inside a sandboxed preview that cannot reliably reach an external API, that demo uses local sample data shaped exactly like a real JSONPlaceholder response instead; the `fetch` code shown in every step is the real, working implementation you would run in an actual browser.

<!-- Root container for the API client demo -->
<div class="client-app">
<h1>Typed API Client</h1>
<button id="load-btn" type="button">Load User #1</button>
<p id="loading" class="hidden">Loading...</p>
<p id="error" class="hidden"></p>
<div id="result" class="hidden">
<h2 id="user-name"></h2>
<p id="user-email"></p>
<ul id="post-list"></ul>
</div>
</div>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.client-app {
width: 100%;
max-width: 460px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.client-app h1 { margin-bottom: 16px; font-size: 1.5rem; }
#load-btn {
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
margin-bottom: 16px;
}
#loading { color: #9aa4b2; }
#error {
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
border-radius: 6px;
padding: 12px;
}
#result { background: #1a1d23; border-radius: 8px; padding: 16px; }
#user-name { font-size: 1.2rem; margin-bottom: 4px; }
#user-email { color: #9aa4b2; margin-bottom: 12px; }
#post-list { list-style: none; }
#post-list li {
padding: 8px 0;
border-top: 1px solid #3a3f4b;
}
.hidden { display: none; }

Step 1: Define Response Interfaces

Before writing any fetch logic, describe exactly what the two endpoints this app calls actually return. These interfaces are written by hand here to match JSONPlaceholder's real response shape — in a larger project they are often generated from an OpenAPI spec, but the idea is identical either way: one interface per response shape, reused everywhere that data flows.

// Matches the shape of a single object from
// https://jsonplaceholder.typicode.com/users/{id}
interface User {
id: number;
name: string;
email: string;
}
// Matches the shape of a single object from
// https://jsonplaceholder.typicode.com/posts?userId={id}
interface Post {
id: number;
userId: number; // links a post back to the user that wrote it
title: string;
body: string;
}

Step 2: Model Success and Failure with a Result Type

Before writing the fetcher itself, define how it will report failure. A `Result<T, E>` discriminated union represents either outcome explicitly in the return type, instead of relying on a caller remembering to wrap every call in `try`/`catch` — the `ok` field is the discriminant TypeScript narrows on.

// A discriminated union with two generic parameters: T for the success
// payload's shape, E for the error's shape. The "ok" field is the
// discriminant — after checking "if (result.ok)", TypeScript knows
// result.data exists; in the else branch it knows result.error exists,
// with no optional chaining needed on either side
type Result<T, E> =
| { ok: true; data: T }
| { ok: false; error: E };
// A dedicated error class (rather than throwing plain strings) lets calling
// code check "error instanceof ApiError" and read a real numeric status
class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message); // Error's own constructor sets up .message and the stack trace
this.name = 'ApiError'; // shows up in stack traces and console.error output instead of the generic "Error"
this.status = status;
}
}

Step 3: Write a Generic fetchJson Function

This is the heart of the project. `<T>` declares a generic type parameter right after the function name — think of it as a parameter for a *type*, the same way `(url: string)` is a parameter for a *value*. Whatever type you pass in angle brackets at the call site becomes `T` for that one call, and flows through to the returned `Promise<T>`.

// <T> declares a generic type parameter. It isn't filled in here — the
// caller supplies it, e.g. fetchJson<User>(url) makes T = User for that call,
// and the function's return type becomes Promise<User> just for that call
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
// Throwing a typed ApiError (not a plain string) is what lets the
// fetchResult wrapper in Step 4 recover a real status code from the catch block
throw new ApiError('Request failed with status ' + response.status, response.status);
}
// response.json() itself returns Promise<any> — the "as T" here is what
// lets every *caller* of fetchJson get a properly typed result back,
// instead of every call site needing its own cast
const data = (await response.json()) as T;
return data;
}
Generics Are a Compile-Time Promise, Not a Runtime Check

`fetchJson<T>` does not actually verify at runtime that the JSON coming back matches `T` — the `as T` cast is you telling the compiler "trust me, I know what this endpoint returns," not a real validation. For data from a source you don't fully control, pair this pattern with a runtime validation library (like Zod) that both checks the shape at runtime and infers the matching TypeScript type from the same schema.

Step 4: Wrap fetchJson in a Typed fetchResult Helper

`fetchResult<T>` combines Step 2's `Result` type with Step 3's generic fetcher: it calls `fetchJson<T>`, and converts either a clean success or any thrown error into the same `Result<T, ApiError>` shape, so callers never need a `try`/`catch` of their own.

// T flows straight through from the caller into fetchJson<T> below — this
// function adds error handling on top without narrowing or widening the type
async function fetchResult<T>(url: string): Promise<Result<T, ApiError>> {
try {
const data = await fetchJson<T>(url);
return { ok: true, data: data };
} catch (err) {
if (err instanceof ApiError) {
return { ok: false, error: err };
}
// A non-ApiError failure (e.g. the network dropped entirely, so fetch()
// itself rejected) still gets wrapped in the same ApiError shape, so
// every caller only ever has to handle one error type
const message = err instanceof Error ? err.message : 'Unknown network error';
return { ok: false, error: new ApiError(message, 0) };
}
}

Step 5: Use the Client Against Real Endpoints

This is where the payoff shows up: `fetchResult<User>(...)` returns `Promise<Result<User, ApiError>>`, and after the `if (!userResult.ok)` check narrows away the failure branch, `userResult.data` is a fully typed `User` — `.name`, `.email`, and `.id` all autocomplete in an editor, with no cast written anywhere in this function.

const USERS_URL = 'https://jsonplaceholder.typicode.com/users/';
const POSTS_URL = 'https://jsonplaceholder.typicode.com/posts?userId=';
async function loadUserAndPosts(userId: number): Promise<void> {
setView('loading');
// <User> is the only type annotation needed anywhere in this function —
// it's what makes userResult.data a User instead of "any" below
const userResult = await fetchResult<User>(USERS_URL + userId);
if (!userResult.ok) {
// In this branch, TypeScript knows userResult.error exists and is an ApiError
showError(userResult.error.message);
return;
}
// In this branch, TypeScript knows userResult.data exists and is a full User
const user = userResult.data;
// <Post[]> makes postsResult.data a Post[] the same way
const postsResult = await fetchResult<Post[]>(POSTS_URL + userId);
if (!postsResult.ok) {
showError(postsResult.error.message);
return;
}
renderProfile(user, postsResult.data);
}

Step 6: Render Results to the DOM

`renderProfile` and `showError` are the only two functions in this project that touch the DOM. Because `user: User` and `posts: Post[]` are fully typed parameters, every property accessed inside `renderProfile` is checked against the interfaces from Step 1.

const loadBtn = document.getElementById('load-btn') as HTMLButtonElement;
const loadingEl = document.getElementById('loading') as HTMLParagraphElement;
const errorEl = document.getElementById('error') as HTMLParagraphElement;
const resultEl = document.getElementById('result') as HTMLDivElement;
const userNameEl = document.getElementById('user-name') as HTMLHeadingElement;
const userEmailEl = document.getElementById('user-email') as HTMLParagraphElement;
const postListEl = document.getElementById('post-list') as HTMLUListElement;
function setView(view: 'loading' | 'error' | 'result'): void {
loadingEl.classList.toggle('hidden', view !== 'loading');
errorEl.classList.toggle('hidden', view !== 'error');
resultEl.classList.toggle('hidden', view !== 'result');
}
function showError(message: string): void {
errorEl.textContent = message;
setView('error');
}
function renderProfile(user: User, posts: Post[]): void {
userNameEl.textContent = user.name; // every field here is checked against the User interface
userEmailEl.textContent = user.email;
postListEl.innerHTML = '';
posts.forEach(function (post: Post) {
const li = document.createElement('li');
li.textContent = post.title;
postListEl.appendChild(li);
});
setView('result');
}
loadBtn.addEventListener('click', function (): void {
loadUserAndPosts(1);
});

Complete Code

Here is the complete `.ts` source, assembled in the order you would write it in a real `api.ts` + `app.ts` split. Since a browser can only run plain JavaScript, this needs to go through `tsc` first — the live demo below runs the type-erased output, using local sample data in place of the real network calls because the sandboxed preview cannot reach an external API.

<div class="client-app">
<h1>Typed API Client</h1>
<button id="load-btn" type="button">Load User #1</button>
<p id="loading" class="hidden">Loading...</p>
<p id="error" class="hidden"></p>
<div id="result" class="hidden">
<h2 id="user-name"></h2>
<p id="user-email"></p>
<ul id="post-list"></ul>
</div>
</div>
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
userId: number;
title: string;
body: string;
}
type Result<T, E> =
| { ok: true; data: T }
| { ok: false; error: E };
class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new ApiError('Request failed with status ' + response.status, response.status);
}
const data = (await response.json()) as T;
return data;
}
async function fetchResult<T>(url: string): Promise<Result<T, ApiError>> {
try {
const data = await fetchJson<T>(url);
return { ok: true, data: data };
} catch (err) {
if (err instanceof ApiError) {
return { ok: false, error: err };
}
const message = err instanceof Error ? err.message : 'Unknown network error';
return { ok: false, error: new ApiError(message, 0) };
}
}
const USERS_URL = 'https://jsonplaceholder.typicode.com/users/';
const POSTS_URL = 'https://jsonplaceholder.typicode.com/posts?userId=';
const loadBtn = document.getElementById('load-btn') as HTMLButtonElement;
const loadingEl = document.getElementById('loading') as HTMLParagraphElement;
const errorEl = document.getElementById('error') as HTMLParagraphElement;
const resultEl = document.getElementById('result') as HTMLDivElement;
const userNameEl = document.getElementById('user-name') as HTMLHeadingElement;
const userEmailEl = document.getElementById('user-email') as HTMLParagraphElement;
const postListEl = document.getElementById('post-list') as HTMLUListElement;
function setView(view: 'loading' | 'error' | 'result'): void {
loadingEl.classList.toggle('hidden', view !== 'loading');
errorEl.classList.toggle('hidden', view !== 'error');
resultEl.classList.toggle('hidden', view !== 'result');
}
function showError(message: string): void {
errorEl.textContent = message;
setView('error');
}
function renderProfile(user: User, posts: Post[]): void {
userNameEl.textContent = user.name;
userEmailEl.textContent = user.email;
postListEl.innerHTML = '';
posts.forEach(function (post: Post) {
const li = document.createElement('li');
li.textContent = post.title;
postListEl.appendChild(li);
});
setView('result');
}
async function loadUserAndPosts(userId: number): Promise<void> {
setView('loading');
const userResult = await fetchResult<User>(USERS_URL + userId);
if (!userResult.ok) {
showError(userResult.error.message);
return;
}
const user = userResult.data;
const postsResult = await fetchResult<Post[]>(POSTS_URL + userId);
if (!postsResult.ok) {
showError(postsResult.error.message);
return;
}
renderProfile(user, postsResult.data);
}
loadBtn.addEventListener('click', function (): void {
loadUserAndPosts(1);
});
Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a generic `postJson<T, B>(url: string, body: B): Promise<T>` for `POST` requests, with `B` typing the request body separately from `T` typing the response.
  • Add a simple in-memory cache typed as `Map<string, unknown>`, keyed by URL, so repeated calls to the same endpoint skip the network.
  • Add request timeouts using `AbortController`, and represent a timeout as a third `Result` variant or a dedicated `TimeoutError` subclass of `ApiError`.
  • Write a runtime-validating version using a library like Zod, so `fetchJson<T>` both checks the shape at runtime and infers `T` from the same schema.
  • Add retry logic that retries a failed `fetchResult` call up to 3 times with backoff, but only for 5xx status codes, not 4xx.

Summary

You built a generic `fetchJson<T>` wrapper reused across every endpoint in the app, and a `Result<T, E>` discriminated union that turns network failures into an explicit, typed return value instead of an exception a caller might forget to catch. This combination — generics for reusable typed data-fetching, discriminated unions for typed error handling — is close to how production TypeScript codebases structure their entire data layer.