Hooks
What a hook is
Section titled “What a hook is”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, usingctxas the current context.left(error): stop. Later steps do not run, and the pipeline’s result isleft(error).
Without onLeft, a step’s left stops the pipeline unchanged. onException and onConflictingKeys are required.
Example: recover with a default
Section titled “Example: recover with a default”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}`, );Things to know about hooks
Section titled “Things to know about hooks”- 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
EitherAsyncit returned rejects with that error, andawaitthrows it. Keep hooks simple, or catch inside them. - A
leftneeds a value. In mini-fp 2,left(undefined)throws. A step can throwundefinedor reject with no reason, so inonExceptionwrap 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.
Conflicting keys
Section titled “Conflicting keys”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,falseornullalready counts as written, so the step that fills it is reported as a conflict. nullis a value. A step that writesnullhas 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.