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 void and a .catch so 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);
BoundaryDeclared typeWhat is true at runtime
response.json()Promise<any>Whatever the server sent, possibly an error page
JSON.parse(text)anyAnything - it throws on malformed input
fetch(...).okbooleanFalse for 4xx and 5xx, whose body may still be JSON
localStorage.getItemstring | nullA string you wrote, or one a user edited
await promiseThe resolved typeIt 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);
}
  1. Decide per boundary whether failure is exceptional (throw) or expected (return a Result).
  2. Wrap the call once with attempt instead of repeating try/catch in every caller.
  3. Keep the original cause on the error object so logs carry the real stack, not just your summary message.
  4. Use a discriminated union so the caller cannot reach result.value before checking ok.

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.

Narrowing, unions and exhaustive checks Runtime validation at the edges

Last refreshed 2026-09-18.