Dataslope logoDataslope

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

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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.

Code Block
TypeScript 5.7

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

Challenge
TypeScript 5.7
Compose two async lookups with TaskResult

In workflow.ts, implement run(userId):

  1. Look up the user via fetchUser.
  2. If found, look up their preferred language via fetchLang.
  3. Return a Result<string, string> whose ok value is
Hello, <name>! Preferred language: <lang>.

Use chainTR and mapTRno 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' }

QuestionSelect one

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

On this page