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?