From 598ed602b1c72661131960bfb865996e4430d30a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Houllier?= Date: Tue, 30 Jun 2026 19:11:20 +0200 Subject: [PATCH] feat: add Traversable utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add traverse, sequence, traverseResult, and sequenceResult functions that invert nested structures (e.g., Array> → Option>) for fail-fast collection processing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Traversable.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++ src/Traversable.ts | 89 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 177 insertions(+) create mode 100644 src/Traversable.test.ts create mode 100644 src/Traversable.ts diff --git a/src/Traversable.test.ts b/src/Traversable.test.ts new file mode 100644 index 0000000..174e5cc --- /dev/null +++ b/src/Traversable.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test" +import { Option } from "./Option.ts" +import { Result } from "./Result.ts" +import { + sequence, + sequenceResult, + traverse, + traverseResult, +} from "./Traversable.ts" + +describe("traverse", () => { + test("with all Some results returns Some of array", () => { + const result = traverse([1, 2, 3], (n) => Option.Some(n * 2)) + expect(result.isOk()).toBe(true) + expect(result.orElse([])).toEqual([2, 4, 6]) + }) + + test("with a None result returns None", () => { + const result = traverse([1, 2, 3], (n) => + n === 2 ? Option.None() : Option.Some(n * 2), + ) + expect(result.isOk()).toBe(false) + }) +}) + +describe("sequence", () => { + test("collects all Some values into Some array", () => { + const result = sequence([ + Option.Some(1), + Option.Some(2), + Option.Some(3), + ]) + expect(result.isOk()).toBe(true) + expect(result.orElse([])).toEqual([1, 2, 3]) + }) + + test("with any None returns None", () => { + const result = sequence([ + Option.Some(1), + Option.None(), + Option.Some(3), + ]) + expect(result.isOk()).toBe(false) + }) +}) + +describe("traverseResult", () => { + test("with all Ok returns Ok of array", () => { + const result = traverseResult([1, 2, 3], (n) => + Result.Ok(n * 2), + ) + expect(result.isError()).toBe(false) + expect(result.match((v) => v, () => [])).toEqual([2, 4, 6]) + }) + + test("with an Error returns the first Error", () => { + const result = traverseResult([1, 2, 3], (n) => + n === 2 + ? Result.Error("failed at 2") + : Result.Ok(n * 2), + ) + expect(result.isError()).toBe(true) + expect(result.match(() => "", (e) => e)).toBe("failed at 2") + }) +}) + +describe("sequenceResult", () => { + test("collects all Ok values into Ok array", () => { + const result = sequenceResult([ + Result.Ok(1), + Result.Ok(2), + Result.Ok(3), + ]) + expect(result.isError()).toBe(false) + expect(result.match((v) => v, () => [])).toEqual([1, 2, 3]) + }) + + test("with any Error returns the first Error", () => { + const result = sequenceResult([ + Result.Ok(1), + Result.Error("oops"), + Result.Ok(3), + ]) + expect(result.isError()).toBe(true) + expect(result.match(() => "", (e) => e)).toBe("oops") + }) +}) diff --git a/src/Traversable.ts b/src/Traversable.ts new file mode 100644 index 0000000..e6a68c7 --- /dev/null +++ b/src/Traversable.ts @@ -0,0 +1,89 @@ +import { Option } from "./Option.ts" +import { Result } from "./Result.ts" + +/** + * Traversable utilities invert nested structures. + * For example, `Array>` becomes `Option>`, + * enabling "fail-fast" collection processing: if any element + * fails (None or Error), the entire result fails immediately. + */ + +/** + * Maps each element through a function returning Option, then flips + * the structure: if all results are Some, returns Some of the array; + * if any is None, returns None. + * @param values - The array of values to traverse + * @param fn - A function mapping each value to an Option + * @returns Option containing the array of results, or None + */ +export function traverse( + values: T[], + fn: (value: T) => Option, +): Option { + const results: R[] = [] + for (const value of values) { + const result = fn(value) + if (!result.isOk()) { + return Option.None() + } + results.push(result.orElse(undefined as never)) + } + return Option.Some(results) +} + +/** + * Flips an `Option[]` into an `Option`. + * If all values are Some, returns Some of the array; + * if any is None, returns None. + * @param values - The array of Options to sequence + * @returns Option containing the array of unwrapped values, or None + */ +export function sequence(values: Option[]): Option { + return traverse(values, (v) => v) +} + +/** + * Maps each element through a function returning Result, then flips + * the structure: if all results are Ok, returns Ok of the array; + * if any is Error, returns the first Error. + * @param values - The array of values to traverse + * @param fn - A function mapping each value to a Result + * @returns Result containing the array of results, or the first Error + */ +export function traverseResult( + values: T[], + fn: (value: T) => Result, +): Result { + const results: R[] = [] + for (const value of values) { + const result = fn(value) + if (result.isError()) { + return Result.Error( + result.match( + () => undefined as never, + (e) => e, + ), + ) + } + results.push( + result.match( + (v) => v, + () => undefined as never, + ), + ) + } + return Result.Ok(results) +} + +/** + * Flips a `Result[]` into a `Result`. + * If all values are Ok, returns Ok of the array; + * if any is Error, returns the first Error. + * @param values - The array of Results to sequence + * @returns Result containing the array of unwrapped values, or the first Error + */ +export function sequenceResult( + values: Result[], +): Result { + return traverseResult(values, (v) => v) +} diff --git a/src/index.ts b/src/index.ts index 94cf7bf..1a5a02a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,3 +8,4 @@ export * from "./Monad.ts" export * from "./Option.ts" export * from "./Pipe.ts" export * from "./Result.ts" +export * from "./Traversable.ts"