Chapter 18 of 24 · TypeScript for Beginners — Complete Free Tutorial

Typing Async Code: Promises and Fetch

Almost every real program spends its time waiting: for a database, an API, a timeout. JavaScript models that waiting with promises, and TypeScript models promises with Promise<T> — a value that arrives later, typed all the way through. Chapter 18 of this 24-chapter series types async functions, tuple results from Promise.all, fetch calls, cancellation with AbortController, and error handling where the error is unknown.

Promise<T>: a value that arrives later

Promise<T> says “some T, eventually.” Awaiting it is the only way to get at T, and the compiler enforces that: property access on the promise itself is an error, not a surprise at runtime.

const delay = (ms: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, ms));
const cached: Promise<number> = Promise.resolve(42);
const failed: Promise<never> = Promise.reject(new Error('offline'));
console.log(await delay(10), await cached);
cached.title;   // compile error

Expected output: undefined 42, then error TS2339: Property 'title' does not exist on type 'Promise<number>'. Two habits pay off immediately: annotate the return type of anything returning a promise (: Promise<number>), and never write Promise<any> — it is any wearing a disguise. A promise that carries no value at all (only timing) is typed Promise<void>.

async functions always return Promise<T>

An async function's declared return type is the inner value, and TypeScript wraps it in Promise<…> for you. Write : User, not : Promise<User> — the async keyword is what performs the wrapping, and inside the body you return plain values.

interface User { id: string; name: string }
async function loadUser(id: string): Promise<User> {
  const user = await getJson<User>(`/api/users/${id}`);
  return user;              // a User, not a Promise<User>
}
const user1 = await loadUser('u1');     // User
const user2 = loadUser('u1');           // Promise<User>
user2.name;   // compile error: forgot await

Expected output: user1 is { id: 'u1', name: 'Ada' } while user2 is Promise<User>, and the property access fails with error TS2339: Property 'name' does not exist on type 'Promise<User>'. The missing-await mistake is the single most common async bug in TypeScript. If promises nest, Awaited<T> from chapter 13 unwraps every layer — which is exactly what await does at runtime.

Waiting for several: all and allSettled

Promise.all receives an array literal and TypeScript infers a tuple, so each position keeps its own type instead of collapsing into one union. Promise.allSettled never rejects; it returns a discriminated union per position, which you narrow on status.

const [me, lessons] = await Promise.all([
  getJson<User>('/api/me'),
  getJson<Lesson[]>('/api/lessons'),
]);
console.log(me.name, lessons.length);
const results = await Promise.allSettled([
  getJson<User>('/api/me'),
  getJson<Lesson[]>('/api/lessons'),
]);
for (const r of results) {
  if (r.status === 'fulfilled') console.log('ok', r.value);
  else console.log('failed', toMessage(r.reason));   // reason is unknown
}

Expected output: Ada 12, then ok { id: 'u1', name: 'Ada' }, ok [ { id: 'l1', title: 'Generics' } ] and failed GET /api/lessons failed: 503 Service Unavailable. Inside the if, r.value is typed; inside the else, r.reason is unknown — the platform's honest answer that anything at all can be thrown.

Typing fetch

The browser's fetch is fully typed already: options take RequestInit, the result is a Response, and headers live in a Headers-compatible record. The weak spot is res.json(), which returns Promise<any> because nobody can know the body's shape. Wrap it once, in a helper that also checks res.ok:

async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
  const res = await fetch(url, init);
  if (!res.ok) throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`);
  return (await res.json()) as T;
}
const lesson = await requestJson<Lesson>('/api/lessons/1', {
  headers: { Authorization: 'Bearer ...' },
});
console.log(lesson.title, lesson.minutes);

Expected output: Generics 40; a 404 response instead throws GET /api/lessons/404 failed: 404 Not Found. Checking res.ok is not optional: fetch only rejects on network failure, so a 404 or 500 still hands you a resolved promise whose body is an error page, and res.json() on HTML produces a baffling parse error. For the full tour of the API itself, see the fetch chapter of the JavaScript series.

Cancelling with AbortController

Requests that finish after they stopped mattering are a classic source of bugs. Pass an AbortSignal through RequestInit and abort it whenever the result becomes irrelevant — a new keystroke, a closed dialog, a deadline.

const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 3000);
try {
  const lesson = await requestJson<Lesson>('/api/lessons/slow', { signal: controller.signal });
  console.log('loaded', lesson.title);
} catch (err) {
  if (err instanceof DOMException && err.name === 'AbortError') {
    console.log('request aborted after 3s');
  } else {
    throw err;
  }
} finally {
  clearTimeout(timer);
}

Expected output: request aborted after 3s. An aborted fetch rejects with an error whose name is 'AbortError' (a DOMException in browsers), not a generic failure — which is why you check name rather than the message text. The finally block clears the timer so a successful request does not leave a stray abort() behind.

Typed errors and a complete getJson<T>

Under strict, the value in a catch clause is unknown, because anything can be thrown. That is not an inconvenience — it is the compiler asking you to narrow before use:

function toMessage(err: unknown): string {
  if (err instanceof Error) return err.message;
  if (typeof err === 'string') return err;
  return 'Unknown error';
}
try {
  throw new Error('boom');
} catch (err) {
  console.log(err.message);      // compile error
  console.log('caught:', toMessage(err));
}

Expected output: error TS18046: 'err' is of type 'unknown'. then caught: boom.

Putting every piece together gives the helper this series uses from here on: one generic function, an explicit options bag, a status check, and typed errors all the way to the caller.

interface GetJsonOptions {
  headers?: Record<string, string>;
  signal?: AbortSignal;
}
async function getJson<T>(url: string, options: GetJsonOptions = {}): Promise<T> {
  const res = await fetch(url, {
    headers: { Accept: 'application/json', ...options.headers },
    signal: options.signal,
  });
  if (!res.ok) throw new Error(`GET ${url} failed: ${res.status} ${res.statusText}`);
  return (await res.json()) as T;
}
interface Lesson { id: string; title: string; minutes: number }
try {
  const lesson = await getJson<Lesson>('/api/lessons/1');
  console.log(lesson.title, lesson.minutes);
  const lessons = await getJson<Lesson[]>('/api/lessons');
  console.log(lessons.length, lessons[0].title);
} catch (err) {
  console.log('failed:', toMessage(err));
}

Expected output:

Generics 40
12 TypeScript in 40 Minutes
failed: GET /api/lessons/404 failed: 404 Not Found

One honest caveat: as T is a promise, not a proof. The compiler believes the server; if the response is untrusted or shapes drift, add a runtime check (chapter 21 returns to this with result types). Typing promises well is mostly about making the happy path precise so the exceptional path stands out.

Common mistakes

  • Skipping await — you get a Promise<T> where you expected T; the TS2339 on the property access is your cue to add it.
  • Ignoring res.ok — fetch resolves on 4xx and 5xx, so an error page sails into res.json() and surfaces as a parse failure instead of an HTTP error.
  • Treating catch (err) as Error — under strict it is unknown; narrow with instanceof or a helper like toMessage.
  • Reading Promise.all results as one union — typing the array instead of letting the tuple infer gives you User | Lesson[] for every position; drop the annotation and let it infer.
  • Never passing a signal — stale responses keep arriving after the UI has moved on; aborting is two lines and removes a whole class of bugs.

Practice checklist

  1. Write delay<T>(ms: number, value: T): Promise<T>, await it, and check the inferred type by hovering the result.
  2. Call Promise.all with two differently typed promises, then annotate the array on purpose and watch the tuple degrade to a union.
  3. Use Promise.allSettled and narrow each result on status before touching value.
  4. Point getJson at a URL that 404s, catch the error, and print it through toMessage.
  5. Wire an AbortController with a 100 ms timeout against a slow endpoint and confirm the AbortError branch runs.

What's next?

You have crossed the halfway point of this 24-chapter series with the type system fully in hand. Next, the two halves meet: add types to real components in TypeScript with React.

Quiz: Typing Async Code: Promises and Fetch

5 questions · 70% to pass

Frequently Asked Questions

TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.

You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.

No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.

If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.

Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.

strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.

any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.

Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.

Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.

Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.