Typing asynchronous and external data
Promises and Awaited, typed fetch results, AbortSignal, and modelling failure as a value instead of hoping nothing rejects.
Promises and Awaited
interface User { id: string; name: string }
// an async function always returns a promise, so the annotation is the resolved value
async function loadUser(id: string): Promise<User> {
const response = await fetch('/api/users/' + id);
return (await response.json()) as User;
}
// Awaited unwraps nested promises, which a hand-written Promise<T> cannot
type Nested = Awaited<Promise<Promise<number>>>; // number
type Resolved = Awaited<ReturnType<typeof loadUser>>; // User
// Promise.all keeps tuple positions when it receives a tuple
async function loadPage(id: string) {
const [user, posts] = await Promise.all([loadUser(id), loadPosts(id)]);
return { user, posts }; // user: User, posts: Post[]
}
// on a homogeneous array the result is one type, so the tuple shape is lost
const scores: number[] = await Promise.all(ids.map(id => fetchScore(id)));
// Promise.allSettled never rejects: each entry carries its own outcome
const settled = await Promise.allSettled([loadUser('1'), loadUser('2')]);
for (const entry of settled) {
if (entry.status === 'fulfilled') console.log(entry.value.name);
else console.error(entry.reason);
}- The type system does not track rejections:
Promise<User>says nothing about what a failure looks like, so that contract lives in documentation or in a result type. - Annotate the return type of exported async functions; local ones infer the resolved value correctly.
- Never leave a promise unhandled: mark a fire-and-forget call with
voidand a.catchso the intent is visible. for await (const chunk of stream)consumes any async iterable, which is how a streamed response is read.
Typing fetch results
class HttpError extends Error {
constructor(readonly status: number, readonly url: string) {
super('HTTP ' + status + ' for ' + url);
this.name = 'HttpError';
}
}
interface Envelope<T> {
data: T;
meta: { requestId: string };
}
// response.json() is typed any, so every response crosses an unknown boundary
async function get<T>(url: string, validate: (raw: unknown) => T, signal?: AbortSignal): Promise<T> {
const response = await fetch(url, {
headers: { accept: 'application/json' },
signal
});
if (!response.ok) throw new HttpError(response.status, url);
const raw: unknown = await response.json(); // unknown, not any
return validate(raw); // proven before anyone uses it
}
// cancellation belongs in the signature: a signal in, an abort out
async function search(query: string, signal: AbortSignal): Promise<string[]> {
const response = await fetch('/api/search?q=' + encodeURIComponent(query), { signal });
if (!response.ok) throw new HttpError(response.status, '/api/search');
return (await response.json()) as string[];
}
// the caller owns the lifetime of the controller
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);| Boundary | Declared type | What is true at runtime |
|---|---|---|
response.json() | Promise<any> | Whatever the server sent, possibly an error page |
JSON.parse(text) | any | Anything - it throws on malformed input |
fetch(...).ok | boolean | False for 4xx and 5xx, whose body may still be JSON |
localStorage.getItem | string | null | A string you wrote, or one a user edited |
await promise | The resolved type | It can reject with anything at all |
⚠️
A type assertion on a network response is an unchecked claim.
as User applied to a 500 error page produces an object with no members and a crash far away from the cause - validate instead, and let the failure surface where the request was made.Modelling failure without exceptions
type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
// one wrapper turns any rejection into a value the caller must inspect
async function attempt<T>(work: () => Promise<T>): Promise<Result<T>> {
try {
return { ok: true, value: await work() };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
}
}
const result = await attempt(() => loadUser('u_1'));
if (result.ok) {
console.log(result.value.name); // the success branch carries the value
} else {
console.error(result.error.message); // the failure branch carries the reason
}
// a catch clause variable is unknown: narrow before you use it
try {
JSON.parse(raw);
} catch (error) {
if (error instanceof SyntaxError) report(error.message);
else if (error instanceof HttpError) report(error.status);
}- Decide per boundary whether failure is exceptional (throw) or expected (return a Result).
- Wrap the call once with
attemptinstead of repeating try/catch in every caller. - Keep the original cause on the error object so logs carry the real stack, not just your summary message.
- Use a discriminated union so the caller cannot reach
result.valuebefore checkingok.
FAQ
Why is a catch variable not typed as Error?
Because JavaScript lets you throw any value, from a string to an object with a status field. Treat it as
unknown and narrow with instanceof or a shape check before reading a property.Should an async function reject or return a Result?
Reject for failures the caller cannot act on. Return a Result for expected outcomes such as validation, not-found or a conflict, because the type then forces every caller to handle them.
Related
Narrowing, unions and exhaustive checks Runtime validation at the edges
Last refreshed 2026-09-18.