Async Code and Typing API Data
Typing promises and async functions, why data from JSON and APIs is unknown until checked, validating it at runtime, and handling errors in catch.
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'.
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"}
-
isRecordrules outnull(whosetypeofis"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)),itemis aTicket; in theelseit is stillunknown.
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