🎓 A1Learner
🔍
0 of 37 lessons completed

TypeScript Async Composition

Async composition joins several async steps into one typed flow, where each step receives the checked value of the step before it. A composition returns failures as values, so a caller handles them like any other result.

Sequential steps pass narrowed values along

loadProfile returns a profile or null. The caller awaits it, checks for null, and passes the narrowed profile into the next step.

interface Profile {
  userName: string;
  city: string;
}

async function loadProfile(id: number): Promise<Profile | null> {
  if (id === 0) {
    return null;
  }
  return { userName: 'Asha', city: 'Pune' };
}

async function loadGreeting(profile: Profile): Promise<string> {
  return 'Hello ' + profile.userName + ' from ' + profile.city;
}

async function main(): Promise<void> {
  const profile = await loadProfile(7);
  if (profile === null) {
    console.log('no profile');
    return;
  }
  const greeting = await loadGreeting(profile);
  console.log(greeting);
  const missing = await loadProfile(0);
  console.log(missing === null ? 'no profile' : 'found');
}

main();

A typed fallback with Promise.race

The delayed read settles after a timer, and the fallback promise is already resolved. The race returns the fallback, and the result is a plain string either way.

async function delayedLabel(text: string): Promise<string> {
  await new Promise<void>((resolve) => setTimeout(resolve, 10));
  return text;
}

async function main(): Promise<void> {
  const fallback: Promise<string> = Promise.resolve('cached label');
  const winner: string = await Promise.race([delayedLabel('live label'), fallback]);
  console.log(winner);
  const live: string = await Promise.race([Promise.resolve('live label'), delayedLabel('slow label')]);
  console.log(live);
}

main();

Failures gathered into a Result

readQuantity returns a Result instead of throwing. Two calls gather one success and one failure, and the ok tag routes each print.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

async function readQuantity(text: string): Promise<Result<number, string>> {
  const value = Number(text);
  if (Number.isNaN(value)) {
    return { ok: false, error: 'Not a number: ' + text };
  }
  return { ok: true, value };
}

async function main(): Promise<void> {
  const results: Result<number, string>[] = [];
  results.push(await readQuantity('4'));
  results.push(await readQuantity('many'));
  for (const outcome of results) {
    if (outcome.ok) {
      console.log('Quantity: ' + outcome.value);
    } else {
      console.log('Failed: ' + outcome.error);
    }
  }
}

main();

A Result chain stops at the first failure

Each step returns a Result, and the flow returns early on a failure shape. The price step runs only with a narrowed success value.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

async function readQuantity(text: string): Promise<Result<number, string>> {
  const value = Number(text);
  if (Number.isNaN(value) || value <= 0) {
    return { ok: false, error: 'Bad quantity: ' + text };
  }
  return { ok: true, value };
}

async function priceFor(quantity: number): Promise<Result<number, string>> {
  return { ok: true, value: quantity * 25 };
}

async function totalFor(text: string): Promise<Result<number, string>> {
  const quantity = await readQuantity(text);
  if (!quantity.ok) {
    return quantity;
  }
  return priceFor(quantity.value);
}

async function main(): Promise<void> {
  const good = await totalFor('3');
  if (good.ok) {
    console.log('Total: ' + good.value);
  }
  const bad = await totalFor('zero');
  if (!bad.ok) {
    console.log('Failed: ' + bad.error);
  }
}

main();

Example

interface Customer {
  userName: string;
}

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };

async function loadCustomer(id: number): Promise<Customer | null> {
  if (id === 0) {
    return null;
  }
  return { userName: 'Asha' };
}

async function readQuantity(text: string): Promise<Result<number, string>> {
  const value = Number(text);
  if (Number.isNaN(value) || value <= 0) {
    return { ok: false, error: 'Bad quantity: ' + text };
  }
  return { ok: true, value };
}

async function delayedLabel(text: string): Promise<string> {
  await new Promise<void>((resolve) => setTimeout(resolve, 10));
  return text;
}

async function checkout(id: number, text: string): Promise<void> {
  const customer = await loadCustomer(id);
  if (customer === null) {
    console.log('no customer');
    return;
  }
  const quantity = await readQuantity(text);
  if (!quantity.ok) {
    console.log('Failed: ' + quantity.error);
    return;
  }
  const label = await Promise.race([delayedLabel('live label'), Promise.resolve('cached label')]);
  console.log(customer.userName + ' x' + quantity.value + ' (' + label + ')');
}

async function main(): Promise<void> {
  await checkout(1, '2');
  await checkout(1, 'many');
  await checkout(0, '2');
}

main();

Key points

  • A sequential composition awaits one step, checks its value, and passes the narrowed value to the next step.
  • Promise.race returns the first settled value, and its result type is the union of the raced promise types.
  • Racing a slow read against an already resolved fallback gives the caller a typed string either way.
  • A Result is a discriminated union with an ok tag that separates a success value from an error message.
  • An async step can return a Result, so an expected failure travels as data instead of a thrown error.

In this example, a checkout flow loads a customer, reads a quantity as a Result, and races a delayed label against a cached fallback, printing one line for the success path and one for each failure path.

Note

The exhibits settle in a fixed order, so their printed lines are the same on every run.

Try it Yourself

Edit the code, then press Run.

Running is simulated · TypeScript Playground

Exercise

In a sequential async composition, what does the next step receive?