Functional Async Workflows
Tasks, lazy promises, and composing asynchronous computations with `map`, `chain`, and `Result`.
JavaScript's Promise is a good citizen of the FP world — almost.
Promise<A> represents "an A that will arrive later". Its
.then is roughly map (when the callback returns a value) and
flatMap (when the callback returns another Promise). Once you
see that pattern, everything you learned in the Category Theory
chapter applies.
But promises have one small awkwardness: they start running the
moment you construct them. That makes them less composable than
they could be. In FP we often prefer a lazy variant called
Task<A>:
type Task<A> = () => Promise<A>;A Task is a recipe for an async computation. Building one does
nothing; calling it (task()) actually kicks off the work. This
is the same trick as IO<A> from the last
chapter — laziness for composability.
Tasks and their three operators
The familiar trio appears again: of, map, chain. The exact
same patterns you used for Option and Result apply to Task.
Running tasks in parallel vs sequence
For independent tasks, you want applicative behavior: run them in parallel, combine their results.
This is the exact same Functor / Applicative / Monad split you
saw before, applied to asynchrony. sequence corresponds to
monadic chaining (each step waits for the previous);
parallel corresponds to applicative combining (independent
steps in parallel).
Tasks that can fail: TaskResult<E, A>
Promises model "an A that will arrive". Result models "an A or
an error". Combining them gives TaskResult — the type that
shows up in most real applications:
type TaskResult<E, A> = () => Promise<Result<E, A>>;A TaskResult is a recipe for an async computation that either
succeeds with an A or fails with an E — and rejections from
the underlying promise are not part of the success channel.
The chain doesn't blow up on failure — it short-circuits as a
value. You decide what to do with the err at the very end,
where the result is rendered.
Comparing imperative and functional async
// Imperative async — control flow tangled with happy/sad paths
async function loadDashboard(uid: string) {
let user, settings, projects;
try { user = await fetchUser(uid); } catch (e) { handle(e); return; }
try { settings = await fetchSettings(uid); } catch (e) { handle(e); return; }
try { projects = await fetchProjects(uid); } catch (e) { handle(e); return; }
return { user, settings, projects };
}
// Functional async — happy path reads top-to-bottom
const loadDashboard = (uid: string) =>
chainTR(fetchUser(uid), (user) =>
chainTR(fetchSettings(uid), (settings) =>
mapTR (fetchProjects(uid), (projects) => ({ user, settings, projects }))));The functional version reads as one long sequence of "and then" — the error handling has been moved out of the code's body into the structure of the type itself.
A small multi-file challenge
In workflow.ts, implement run(userId):
- Look up the user via
fetchUser. - If found, look up their preferred language via
fetchLang. - Return a
Result<string, string>whose ok value is
Hello, <name>! Preferred language: <lang>.
Use chainTR and mapTR — no try/catch, no if/else for
errors. Errors must propagate via the Result channel.
Expected output
{ kind: 'ok', value: 'Hello, Ada! Preferred language: TypeScript.' }
{ kind: 'err', error: 'no such user' }
{ kind: 'err', error: 'no language set' }
Why might you prefer Task<A> = () => Promise<A> over a bare Promise<A> in functional code?
Tasks are faster than Promises at runtime
Tasks can hold synchronous values, Promises can't
A Task is lazy — building one does no work, so you can compose, retry, parallelize, or substitute it without accidentally kicking off the computation
Tasks cannot fail