Typing promises and async functions

Asynchronous JavaScript is explained on asynchronous JavaScript: promises, async and await, and the event loop. TypeScript adds one thing: it knows what each promise will deliver. The Promise<T> type is the generic type of a promise that fulfills with a value of type T: Promise<number> delivers a number, Promise<void> delivers nothing useful. await on a Promise<T> gives a T, and an async function's return type is always written as a promise:

interface MeterReading {
  meterId: string;
  kWh: number;
}

// The return type of an async function is always a Promise.
async function loadReading(meterId: string): Promise<MeterReading> {
  return { meterId, kWh: 4.2 };
}

async function loadTariff(): Promise<number> {
  return 0.32;
}

// Promise.all keeps the type of each position.
const [reading, price] = await Promise.all([loadReading("E-1001"), loadTariff()]);
console.log(`${reading.meterId}: €${(reading.kWh * price).toFixed(2)}`);
E-1001: €1.34

Promise.all keeps track of each position: reading is a MeterReading and price a number. The examples on this page that use top-level await are ES modules (.mts); see top-level await. Writing the return type without Promise is an error that tells you the fix:

async function loadTariff(): number {
  return 0.32;
}
example.ts(1,30): error TS1064: The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<number>'?

Forgotten await

In JavaScript, forgetting await gives you a promise where you expected a value, and the program continues with nonsense such as NaN or [object Promise]. In TypeScript, a Promise<number> is not a number, so most of these mistakes no longer compile:

async function loadTariff(): Promise<number> {
  return 0.32;
}

async function dayCost(kWh: number): Promise<number> {
  const price: number = loadTariff();
  return kWh * price;
}
example.ts(6,9): error TS2322: Type 'Promise<number>' is not assignable to type 'number'.

A promise in an if is caught too, because an object is always truthy:

async function meterOnline(meterId: string): Promise<boolean> {
  return meterId === "E-1001";
}

async function check(): Promise<void> {
  if (meterOnline("E-1002")) {
    console.log("online");
  }
}
check();
example.ts(6,7): error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined.

What the compiler does not catch is a call whose promise is ignored completely: a line such as saveReading(r); without await compiles, and an error in it becomes an unhandled rejection. The typescript-eslint rule no-floating-promises finds those; see tooling, configuration and migrating from JavaScript.

JSON and API data are not typed

Everything above works because the compiler can see the code that produces each value. Data from outside the program is different. A response body, a JSON file or a message from a queue is a string when it arrives, and its structure is whatever the sender decided to send today. TypeScript cannot know it, and the standard library admits that: JSON.parse() returns any, and so does the promise of response.json().

any switches checking off, and it spreads. A string where you expected a number passes straight through:

const raw = '{"meterId": "E-1001", "kWh": "4.2"}';
const reading = JSON.parse(raw); // type: any

const total: number = reading.kWh + 1.5;
console.log(total);
4.21.5

total is declared as a number, yet it holds the string "4.21.5": the + concatenated. The same happens with fetch. In the next example a local API (started inside the example, so no internet is needed) has renamed summary to title. The annotation Ticket[] is accepted without any check, and the program fails later, at the first place that uses the missing field:

import http from "node:http";
import { once } from "node:events";
import type { AddressInfo } from "node:net";

interface Ticket {
  id: string;
  summary: string;
  priority: 1 | 2 | 3 | 4;
}

// A local API whose answer has changed shape: "summary" is now "title".
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify([{ id: "INC-30412", title: "Portal login slow", priority: 2 }]));
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address() as AddressInfo;

const response = await fetch(`http://127.0.0.1:${port}/api/tickets`);
const tickets: Ticket[] = await response.json(); // compiles: json() returns any
server.close();

for (const t of tickets) {
  console.log(`${t.id}: ${t.summary.toUpperCase()}`);
}
TypeError: Cannot read properties of undefined (reading 'toUpperCase')

The error appears far from its cause, with a message about toUpperCase, not about the API. In a larger program, the wrong value might also be stored, shown to a customer or sent on to another system before anything crashes.

Trust boundaries and unknown

A trust boundary is the place where data enters your program from a source the type checker cannot see: an HTTP response or request body, a file, an environment variable, user input, browser storage, a message queue. On the inside, types describe values the compiler has checked; on the outside, there are only strings and bytes. Give such data the type unknown, not any. Then the compiler refuses every use until you have checked it:

const data: unknown = JSON.parse('{"id": "INC-30412", "priority": 2}');

console.log(data.id);
example.ts(3,13): error TS18046: 'data' is of type 'unknown'.
Data from outside the program, such as an API response, a JSON file, user input or browser storage, arrives as unknown. It crosses the trust boundary only through a run-time check: a type guard, an assertion function or a schema. Inside, the value has a checked type such as Ticket. Data that fails the check is rejected. outside API response JSON file user input localStorage unknown trust boundary inside run-time check guard · assert · schema valid checked type Ticket invalid: reject
Types are trusted inside the program. Data only gets a real type after a check that runs on every value that crosses the boundary.

Validating data at run time

Runtime validation is code that checks, while the program runs, that a value from outside really has the structure and value ranges the program expects, and rejects it otherwise. In TypeScript, the check is written so that the compiler can follow it, and the value has the right type after the check. The simplest form is a type guard: a function that returns value is Ticket.

interface Ticket {
  id: string;
  summary: string;
  priority: 1 | 2 | 3 | 4;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isTicket(value: unknown): value is Ticket {
  return (
    isRecord(value) &&
    typeof value.id === "string" &&
    /^INC-\d{5}$/.test(value.id) &&
    typeof value.summary === "string" &&
    (value.priority === 1 || value.priority === 2 || value.priority === 3 || value.priority === 4)
  );
}

const body = `[
  {"id": "INC-30412", "summary": "Portal login slow", "priority": 2},
  {"id": "INC-30415", "title": "Meter API timeouts", "priority": 1},
  {"id": "INC-30418", "summary": "Invoice PDF missing", "priority": "high"}
]`;

const data: unknown = JSON.parse(body);
if (!Array.isArray(data)) throw new Error("Expected an array of tickets");

for (const item of data) {
  if (isTicket(item)) {
    console.log(`OK       ${item.id} (P${item.priority}) ${item.summary}`);
  } else {
    console.log(`REJECTED ${JSON.stringify(item)}`);
  }
}
OK       INC-30412 (P2) Portal login slow
REJECTED {"id":"INC-30415","title":"Meter API timeouts","priority":1}
REJECTED {"id":"INC-30418","summary":"Invoice PDF missing","priority":"high"}
  • isRecord rules out null (whose typeof is "object") and arrays, and gives the compiler a type with properties it may read.
  • The guard checks every field the code will use, including value ranges: the ID pattern and the four allowed priorities. A check that only says "is an object" proves nothing.
  • Inside if (isTicket(item)), item is a Ticket; in the else it is still unknown.

Assertion functions

An assertion function is a function with the return type asserts value is Type: it returns normally when the value is valid and throws otherwise. After the call, the compiler treats the value as the asserted type for the rest of the block, without an if. That suits code where invalid data should stop the work, with a clear message:

interface MeterReading {
  meterId: string;
  kWh: number;
}

function assertReading(value: unknown): asserts value is MeterReading {
  if (typeof value !== "object" || value === null) {
    throw new TypeError("Reading must be an object");
  }
  const r = value as Record<string, unknown>;
  if (typeof r.meterId !== "string") {
    throw new TypeError("meterId must be a string");
  }
  if (typeof r.kWh !== "number" || !Number.isFinite(r.kWh) || r.kWh < 0) {
    throw new TypeError(`kWh must be a non-negative number, got ${JSON.stringify(r.kWh)}`);
  }
}

for (const line of ['{"meterId": "E-1001", "kWh": 4.2}', '{"meterId": "E-1002", "kWh": "3.1"}']) {
  const data: unknown = JSON.parse(line);
  assertReading(data);
  console.log(`${data.meterId}: ${data.kWh.toFixed(1)} kWh`); // data is MeterReading here
}
E-1001: 4.2 kWh
TypeError: kWh must be a non-negative number, got "3.1"

The error now names the field and the value, at the moment the data arrived, instead of a TypeError somewhere later. Note the as Record<string, unknown> inside the function: an assertion in the middle of a check is fine, because the next lines test every property it lets you read.

interface MeterReading {
  meterId: string;
  kWh: number;
}

const data: unknown = JSON.parse('{"meter": "E-1001", "kwh": 4.2}');
const reading = data as MeterReading; // no check: just a promise to the compiler

console.log(reading.meterId);
console.log(reading.kWh.toFixed(2));
undefined
TypeError: Cannot read properties of undefined (reading 'toFixed')

A typed fetch helper

Putting it together: a small generic helper that fetches JSON, checks the status with response.ok, reads the body as unknown, and returns it only when a type guard accepts it. Callers cannot get data out of it without passing a guard, so validation cannot be forgotten. The local API serves a good version 1 and a changed version 2:

import http from "node:http";
import { once } from "node:events";
import type { AddressInfo } from "node:net";

interface Ticket {
  id: string;
  summary: string;
  priority: 1 | 2 | 3 | 4;
}

function isTicket(value: unknown): value is Ticket {
  if (typeof value !== "object" || value === null) return false;
  const v = value as Record<string, unknown>;
  return (
    typeof v.id === "string" &&
    typeof v.summary === "string" &&
    (v.priority === 1 || v.priority === 2 || v.priority === 3 || v.priority === 4)
  );
}

// Fetch JSON and only return it once the guard has checked it.
async function getJson<T>(url: string, guard: (value: unknown) => value is T): Promise<T> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`HTTP ${response.status} for ${new URL(url).pathname}`);
  }
  const data: unknown = await response.json();
  if (!guard(data)) {
    throw new TypeError(`Unexpected response shape from ${new URL(url).pathname}`);
  }
  return data;
}

const isTicketList = (value: unknown): value is Ticket[] =>
  Array.isArray(value) && value.every(isTicket);

// A local API: one good endpoint, one that changed its field names.
const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "application/json" });
  if (req.url === "/v2/tickets") {
    res.end(JSON.stringify([{ id: "INC-30412", title: "Portal login slow", priority: 2 }]));
  } else {
    res.end(JSON.stringify([
      { id: "INC-30412", summary: "Portal login slow", priority: 2 },
      { id: "INC-30415", summary: "Meter API timeouts", priority: 1 },
    ]));
  }
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;

for (const path of ["/v1/tickets", "/v2/tickets"]) {
  try {
    const tickets = await getJson(base + path, isTicketList); // Ticket[]
    for (const t of tickets) console.log(`${t.id} (P${t.priority}) ${t.summary}`);
  } catch (err) {
    console.log(err instanceof Error ? `Failed: ${err.message}` : "Failed");
  }
}
server.close();
INC-30412 (P2) Portal login slow
INC-30415 (P1) Meter API timeouts
Failed: Unexpected response shape from /v2/tickets

The type parameter T of getJson is inferred from the guard (see generics), so tickets is a Ticket[] without an annotation. The changed API is now reported where it happens, with the path that caused it, and the program continues.

Schema libraries

Hand-written guards are fine for a few types. For many types, or deeply nested ones, most projects use a schema validation library instead. Zod is a widely used example; Valibot and ArkType are others. You describe the structure once, as a value; the library validates data at run time and derives the TypeScript type from the same description, so the two cannot drift apart. A sketch of what that looks like with Zod (not run here; check the library's documentation for the current API):

import { z } from "zod";

const Ticket = z.object({
  id: z.string().regex(/^INC-\d{5}$/),
  summary: z.string(),
  priority: z.number().int().min(1).max(4),
});
type Ticket = z.infer<typeof Ticket>; // { id: string; summary: string; priority: number }

const result = z.array(Ticket).safeParse(await response.json());
if (!result.success) {
  console.log("Invalid response:", result.error.issues.length, "problems");
} else {
  for (const t of result.data) console.log(t.id, t.summary);
}

A schema library is an extra dependency, so the usual care for dependencies applies. When an API publishes a JSON Schema or an OpenAPI description, tools can also generate TypeScript types and validators from it, which keeps your types in step with the API contract.

Errors in catch

JavaScript can throw any value, not only Error objects: a string, a number, an object from a library. TypeScript therefore cannot know what arrives in catch. In strict mode (the option useUnknownInCatchVariables, part of strict), the catch variable is unknown:

function parseKWh(text: string): number {
  const kWh = Number(text);
  if (Number.isNaN(kWh)) throw new RangeError(`Not a number: "${text}"`);
  return kWh;
}

try {
  parseKWh("4,2");
} catch (err) {
  console.log(err.message);
}
example.ts(10,15): error TS18046: 'err' is of type 'unknown'.

Narrow it before use. instanceof works for Error and its subclasses, including your own custom error classes, because classes do exist at run time:

class ApiError extends Error {
  constructor(message: string, public readonly status: number) {
    super(message);
    this.name = "ApiError";
  }
}

function report(err: unknown): string {
  if (err instanceof ApiError) return `${err.name} ${err.status}: ${err.message}`;
  if (err instanceof Error) return `${err.name}: ${err.message}`;
  return `Non-error value thrown: ${String(err)}`;
}

const failures: (() => void)[] = [
  () => { throw new ApiError("Meter API unavailable", 503); },
  () => { throw new RangeError('Not a number: "4,2"'); },
  () => { throw "timeout"; },
];

for (const fail of failures) {
  try {
    fail();
  } catch (err) {
    console.log(report(err));
  }
}
ApiError 503: Meter API unavailable
RangeError: Not a number: "4,2"
Non-error value thrown: timeout

One gap remains. The callback of a promise's .catch() method receives its reason as any, not unknown, so a typo there is not caught:

const p: Promise<number> = Promise.reject(new Error("timeout"));
p.catch((err) => console.log(err.mesage));
undefined

err.mesage compiles and prints undefined. Annotate the parameter yourself, .catch((err: unknown) => …), or use try/catch with await, where strict mode applies.

Cleaning up with using

A using declaration declares a constant whose value is disposed of automatically when the enclosing block ends, whether it ends normally, with return, or with an error. The value must have a method under the well-known symbol Symbol.dispose; for asynchronous clean-up, await using calls Symbol.asyncDispose and waits for it. It replaces the try/finally blocks that close connections, files and locks:

class MeterConnection {
  constructor(public meterId: string) {
    console.log(`open ${meterId}`);
  }
  read(): number {
    return 4.2;
  }
  [Symbol.dispose]() {
    console.log(`close ${this.meterId}`);
  }
}

function readMeter(id: string): number {
  using conn = new MeterConnection(id);
  const kWh = conn.read();
  if (kWh > 4) throw new RangeError(`Implausible reading on ${id}`);
  return kWh;
}

try {
  readMeter("E-1001");
} catch (err) {
  console.log(err instanceof Error ? err.message : err);
}
open E-1001
close E-1001
Implausible reading on E-1001

The connection is closed before the error reaches the catch. The asynchronous version waits for the clean-up before the function's promise settles:

class ExportFile {
  lines: string[] = [];
  constructor(public name: string) {}
  async [Symbol.asyncDispose]() {
    await Promise.resolve(); // e.g. flush to disk
    console.log(`flushed ${this.lines.length} lines to ${this.name}`);
  }
}

async function exportReadings(readings: number[]): Promise<void> {
  await using file = new ExportFile("readings-2026-10.csv");
  for (const kWh of readings) file.lines.push(kWh.toFixed(1));
  console.log("export done");
}

await exportReadings([4.2, 3.1, 5.0]);
console.log("after export");
export done
flushed 3 lines to readings-2026-10.csv
after export