Skip to content

Hooks

A hook is a function in PipelineOptions that the pipeline calls when a step does not simply succeed. There are three situations, and one hook for each:

Hook When it runs It receives
onLeft (optional) A step returned left(error). The error, and the context the step received.
onException A step threw, or its promise rejected. The thrown value, and the context the step received.
onConflictingKeys A step returned a context that changed or removed a value an earlier step had written. The keys, the context the step received, and the context it returned.

Every hook follows the same rule, and returns a StepResult like a step does:

  • right(ctx): recover. The pipeline continues with the next step, using ctx as the current context.
  • left(error): stop. Later steps do not run, and the pipeline’s result is left(error).

Without onLeft, a step’s left stops the pipeline unchanged. onException and onConflictingKeys are required.

loadAvatar fails when the user has no avatar. That should not stop the page from rendering, so onLeft puts a default avatar in the context and the pipeline continues with render. Any other error stops the pipeline:

import { Option, left, right } from "@ngwes/mini-fp";
import { Pipeline } from "@ngwes/groucho-fp";
import type { PipelineOptions, Step } from "@ngwes/groucho-fp";
type User = { id: string; name: string };
type Ctx = { userId: string; user?: User; avatarUrl?: string; html?: string };
type Err =
| { tag: "user-not-found"; userId: string }
| { tag: "no-avatar"; userId: string }
| { tag: "crash"; cause: unknown }
| { tag: "conflict"; keys: ReadonlyArray<string> };
declare const db: {
findUser(id: string): Promise<User | undefined>;
findAvatarUrl(userId: string): Promise<string | undefined>;
};
const fetchUser: Step<Err, Ctx> = (ctx) =>
Option.fromAsync(db.findUser(ctx.userId))
.toEither<Err>({ tag: "user-not-found", userId: ctx.userId })
.map((user) => ({ ...ctx, user }));
const loadAvatar: Step<Err, Ctx> = (ctx) =>
Option.fromAsync(db.findAvatarUrl(ctx.userId))
.toEither<Err>({ tag: "no-avatar", userId: ctx.userId })
.map((avatarUrl) => ({ ...ctx, avatarUrl }));
const render: Step<Err, Ctx> = (ctx) =>
right({ ...ctx, html: `<img src="${ctx.avatarUrl}"> ${ctx.user?.name}` });
const options: PipelineOptions<Err, Ctx> = {
onLeft: (error, ctx) =>
error.tag === "no-avatar"
? right({ ...ctx, avatarUrl: "/img/default-avatar.png" }) // recover: render runs next
: left(error), // stop
onException: (exception) => left({ tag: "crash", cause: exception }),
onConflictingKeys: (keys) => left({ tag: "conflict", keys }),
};
const page = await Pipeline({ userId: "42" }, [fetchUser, loadAvatar, render], options)
.match(
(ctx) => ctx.html ?? "",
(error) => `error: ${error.tag}`,
);
  • A hook’s context is trusted. The context a hook returns is not checked for conflicts: the hook has the last word. It does get frozen, like any other context.
  • A hook that throws rejects the result. If a hook throws, or its promise rejects, the pipeline does not call another hook: the EitherAsync it returned rejects with that error, and await throws it. Keep hooks simple, or catch inside them.
  • A left needs a value. In mini-fp 2, left(undefined) throws. A step can throw undefined or reject with no reason, so in onException wrap the exception in your error type (left({ tag: "crash", cause: exception })) rather than passing it on as is.
  • Cleanup goes in the hooks. The pipeline has no rollback of its own. If a step opened a transaction, roll it back in the hook that stops the pipeline.

A step adds to the context; it should not change what an earlier step wrote. After each step, the pipeline compares the context the step received with the one it returned. A key conflicts when it held a value (anything but undefined) and the returned context:

  • holds a different value for it, compared with !==, or
  • does not have the key any more (the step dropped it).

Writing a key that was undefined is not a conflict: that is how a step adds its output. Three consequences:

  • Initialise step outputs as undefined. Declare them as optional fields and leave them out of the initial context. A field that starts as "", 0, false or null already counts as written, so the step that fills it is reported as a conflict.
  • null is a value. A step that writes null has written the key; a later step that changes it conflicts.
  • Objects are compared by reference. A step that returns a new object with the same content in an existing key ({ ...ctx, user: { ...ctx.user } }) conflicts. Keep the reference you received.

onConflictingKeys receives the conflicting keys, the context the step received (ctx) and the context it returned (next). Return right(next) to accept the step’s output, right(ctx) to keep the previous context and move on, or left(...) to stop:

import { left, right } from "@ngwes/mini-fp";
import type { PipelineOptions } from "@ngwes/groucho-fp";
type Ctx = { orderId: string; total?: number; updatedAt?: Date };
type Err = { tag: "conflict"; keys: ReadonlyArray<string> } | { tag: "crash"; cause: unknown };
const options: PipelineOptions<Err, Ctx> = {
onException: (exception) => left({ tag: "crash", cause: exception }),
// updatedAt may be rewritten by any step; any other overwrite is a bug
onConflictingKeys: (keys, ctx, next) =>
keys.every((key) => key === "updatedAt") ? right(next) : left({ tag: "conflict", keys }),
};

The keys are typed keyof Ctx & string, so a typo in the comparison above does not compile.