From c5bdc8baa7e3b5c21a702a685f07be1e809f8ee7 Mon Sep 17 00:00:00 2001 From: Gabriel Juchault Date: Sat, 10 Jan 2026 18:15:39 +0100 Subject: [PATCH] feat: errdefer --- .cspell.json | 1 + README.md | 50 ++++++++++++++++++++++++++++- src/__tests__/errdefer.test.ts | 57 ++++++++++++++++++++++++++++++++++ src/errdefer.ts | 19 ++++++++++++ src/flow.ts | 34 ++++++++++++++++---- 5 files changed, 154 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/errdefer.test.ts create mode 100644 src/errdefer.ts diff --git a/.cspell.json b/.cspell.json index 28685c9..624f204 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,6 +4,7 @@ "words": [ "bahmutov", "degit", + "errdefer", "esbuild", "flowgen", "octocat", diff --git a/README.md b/README.md index 9f333f8..2aadca6 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,54 @@ const result = flow(async function* () { }); ``` +### `errdefer()` + +```ts +function* errdefer( + callback: (error: Error) => void | Promise +): Generator, void, unknown>; +``` + +This method is similar to `errdefer` in other languages (eg. [zig](https://ziglang.org/documentation/master/#errdefer)). It allows to cleanup eventual leftovers when a method partially failed. Similar to a `finally` keyword. +It takes the error as parameter if you need it + +Example: + +```ts +let globalTimeout1: NodeJS.Timeout; +let globalTimeout2: NodeJS.Timeout; + +// Two dependency starting a long-living process like a timeout or a database connection +const genLongLivingDependency1 = gen(async function longLivingDependency() { + globalTimeout1 = setTimeout(() => {}, 1000); + return "done"; +}); + +const genLongLivingDependency2 = gen(async function longLivingDependency() { + globalTimeout2 = setTimeout(() => {}, 1000); + return "done"; +}); + +const genFailingDependency = gen(async function failingDependency() { + throw new Error("some failing dependency"); +}); + +async function* main() { + const dependency1 = yield* genLongLivingDependency1(); + // this will be called if `main` has a failure somewhere + yield* errdefer(() => clearTimeout(globalTimeout1)); + + const dependency2 = yield* genLongLivingDependency2(); + // this will be called if `main` has a failure somewhere, after the first errdefer + yield* errdefer(() => clearTimeout(globalTimeout2)); + + // since this is failing, it will call every errdefer callback, evaluated in reverse order + const failingDependency = yield* genFailingDependency(); + + return [dependency1, dependency2, failingDependency]; +} +``` + ### `never()` ```ts @@ -285,7 +333,7 @@ async function* method(a: 1 | 2): AsyncGenerator { function noop(): AsyncGenerator; ``` -A noop helper. Can be useful when you want to yield nothing just to please the linter. +A noop helper. Can be useful when you want to yield nothing just to please the linter to get a generator even if you don't really yield. Example: diff --git a/src/__tests__/errdefer.test.ts b/src/__tests__/errdefer.test.ts new file mode 100644 index 0000000..7218948 --- /dev/null +++ b/src/__tests__/errdefer.test.ts @@ -0,0 +1,57 @@ +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; +import { errdefer } from "../errdefer.ts"; +import { flow, gen, noop } from "../index.ts"; + +let globalTimeout1: NodeJS.Timeout; +let globalTimeout2: NodeJS.Timeout; + +await describe("errdefer()", async () => { + await describe("given two long living dependencies that register a timeout and a failing dependency", async () => { + const genLongLivingDependency1 = gen(async function longLivingDependency() { + globalTimeout1 = setTimeout(() => {}, 1000); + return "done"; + }); + + const genLongLivingDependency2 = gen(async function longLivingDependency() { + globalTimeout2 = setTimeout(() => {}, 1000); + return "done"; + }); + + const genFailingDependency = gen(async function failingDependency() { + throw new Error("some failing dependency"); + }); + + await describe("given a main method that yields the generator and errdefer a cleanup", async () => { + const cleanupOrder: number[] = []; + + async function* main() { + const dependency1 = yield* genLongLivingDependency1(); + yield* errdefer((error) => { + equal((error as Error).message, "some failing dependency"); + cleanupOrder.push(1); + clearTimeout(globalTimeout1); + }); + + yield* noop(); + + const dependency2 = yield* genLongLivingDependency2(); + yield* errdefer((error) => { + equal((error as Error).message, "some failing dependency"); + cleanupOrder.push(2); + clearTimeout(globalTimeout2); + }); + const failingDependency = yield* genFailingDependency(); + + return [dependency1, dependency2, failingDependency]; + } + + await it("should cleanup, in order", async () => { + const result = await flow(main); + ok(result.ok === false); + equal((result.error as Error).message, "some failing dependency"); + deepEqual(cleanupOrder, [1, 2]); + }); + }); + }); +}); diff --git a/src/errdefer.ts b/src/errdefer.ts new file mode 100644 index 0000000..d91e3e0 --- /dev/null +++ b/src/errdefer.ts @@ -0,0 +1,19 @@ +export interface Errdefer { + type: "errdefer"; + callback: (error: Error) => void | Promise; +} + +export function* errdefer( + callback: (err: Error) => void | Promise, +): Generator, void, unknown> { + yield { type: "errdefer", callback }; +} + +export function isErrdefer(obj: unknown): obj is Errdefer { + return ( + typeof obj === "object" && + obj !== null && + "type" in obj && + obj.type === "errdefer" + ); +} diff --git a/src/flow.ts b/src/flow.ts index 0fd293e..6024ef6 100644 --- a/src/flow.ts +++ b/src/flow.ts @@ -1,3 +1,5 @@ +import { type Errdefer, isErrdefer } from "./errdefer.ts"; + /** * This method turns a generator into a promise. Useful as entrypoint before using generators. * Inside the generator, always `yield*` other generators. @@ -24,15 +26,35 @@ * */ export async function flow( - generator: () => Generator | AsyncGenerator, + generator: () => + | Generator, Value> + | AsyncGenerator, Value>, ): Promise<{ ok: true; value: Value } | { ok: false; error: Error }> { - const step = await generator().next(); + const deferQueue: Errdefer[] = []; - if (step.done === false) { - return { ok: false, error: step.value }; - } + const gen = generator(); + + while (true) { + const step = await gen.next(); + + if (step.done === true) { + return { ok: true, value: step.value as Value }; + } - return { ok: true, value: step.value as Value }; + if (isErrdefer(step.value)) { + deferQueue.push(step.value); + continue; + } + + const error = step.value as Error; + + while (deferQueue.length > 0) { + const defer = deferQueue.shift(); + await defer?.callback(error); + } + + return { ok: false, error }; + } } /**