diff --git a/.github/scripts/render-coverage.mjs b/.github/scripts/render-coverage.mjs new file mode 100644 index 00000000..3340b43c --- /dev/null +++ b/.github/scripts/render-coverage.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Render coverage-summary.json into a markdown table for the PR + * comment. Reads packages/fp/coverage/coverage-summary.json (produced + * by the json-summary reporter) and emits a markdown document to + * stdout. The CI workflow captures that output and posts it as a + * sticky PR comment. + * + * Total row plus one row per file, sorted by file path. Per-file + * thresholds are 100% on statements / branches / functions / lines + * (rule 0001 / ADR 0002). + */ + +import { readFileSync } from 'node:fs'; +import { resolve, relative, sep } from 'node:path'; + +const summaryPath = resolve('packages/fp/coverage/coverage-summary.json'); +const summary = JSON.parse(readFileSync(summaryPath, "utf8")); +const repoRoot = resolve("."); + +const fmt = (entry) => (entry && typeof entry.pct === "number" ? `${entry.pct.toFixed(2)}%` : "—"); +const branchCell = (entry) => { + if (!entry || typeof entry.total !== "number") return "—"; + if (entry.total === 0) return "n/a"; + return fmt(entry); +}; + +const lines = []; +lines.push("## Coverage report"); +lines.push(""); +lines.push("| File | % Stmts | % Branch | % Funcs | % Lines |"); +lines.push("| --- | ---: | ---: | ---: | ---: |"); + +const total = summary.total ?? {}; +lines.push(`| **Total** | **${fmt(total.statements)}** | **${fmt(total.branches)}** | **${fmt(total.functions)}** | **${fmt(total.lines)}** |`); + +const fileKeys = Object.keys(summary).filter((k) => k !== 'total').sort(); +for (const key of fileKeys) { + const file = summary[key]; + const rel = relative(repoRoot, key).split(sep).join("/"); + lines.push(`| ${rel} | ${fmt(file.statements)} | ${branchCell(file.branches)} | ${fmt(file.functions)} | ${fmt(file.lines)} |`); +} + +lines.push(""); +lines.push("_Per-file thresholds: 100% on statements / branches / functions / lines (ADR 0002). Files with no branches render `n/a` in the Branch column. The threshold gate is disabled in this PR and lands with the full method × variant test matrix in a follow-up._"); +lines.push(""); + +import { writeFileSync } from 'node:fs'; +// Write the comment to a known path so the workflow can pass it +// via `path:` to the sticky-pull-request-comment action. +writeFileSync('coverage-comment.md', lines.join('\n')); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdcb4f54..7eecea24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,53 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm turbo build - - run: pnpm turbo test + - run: pnpm turbo test --filter=@deessejs/fp + + coverage: + name: Coverage + needs: test + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm turbo build + # --filter=@deessejs/fp limits the run to this package. The other + # workspace packages (apps/web) don't have a test:coverage script, + # so an unfiltered turbo run would refuse to execute any task. + - run: pnpm turbo test:coverage --filter=@deessejs/fp + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ github.sha }} + path: packages/fp/coverage + retention-days: 14 + + - name: Render coverage table + if: success() + id: render + run: node .github/scripts/render-coverage.mjs + + - name: Post coverage comment + if: success() + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: coverage + path: coverage-comment.md changeset-check: name: Changeset check @@ -113,7 +159,6 @@ jobs: run: | BASE=${{ github.event.pull_request.base.ref }} git fetch origin "$BASE" - # Sanity: the PR head must be downstream of the base ref. if ! git merge-base --is-ancestor "origin/$BASE" HEAD; then echo "::error::PR head is not ahead of origin/$BASE. Refusing to diff." exit 1 diff --git a/.gitignore b/.gitignore index 588a5e45..867b9a6c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,6 @@ temp/ # Changesets (keep for CI) # .changeset/ is NOT ignored - needed for release workflow + +# Build artifacts +coverage-comment.md diff --git a/docs/engineering/plans/architecture-classes.md b/docs/engineering/plans/architecture-classes.md new file mode 100644 index 00000000..4c7f0728 --- /dev/null +++ b/docs/engineering/plans/architecture-classes.md @@ -0,0 +1,61 @@ +# Architecture classes + +**Status**: Implemented (PR #TBD). +**Date**: 2026-08-17. +**Branch**: `architecture/classes`. + +## Goal + +Replace the plain-object + closure factories currently used by `Result` and `Maybe` with internal classes (`OkImpl`, `ErrImpl`, `SomeImpl`, `NoneImpl`) hidden behind the public factory functions. The public API surface stays byte-for-byte identical; the internal implementation gains type inference, removes a class of chained casts, and aligns with the patterns spelled out in rule 0014 ("Functions Over Classes for Public API"). + +In the same PR, deliver the pipeable functions that the `TODO` comments in `result/index.ts` and `maybe/index.ts` have been signalling since v1.0. + +## Decisions + +1. **Public surface is unchanged.** `ok`, `err`, `some`, `none`, `maybe`, `Unit`, `isResult`, `isMaybe`, `isUnit`, and the type names (`Ok`, `Err`, `Result`, `Some`, `None`, `Maybe`, `Unit`, `OkType`, `ErrType`, `SomeType`) keep their signatures and exported names. No new exports are *required* by this refactor; the pipeables are additive. +2. **Classes are internal.** `OkImpl`, `ErrImpl`, `SomeImpl`, `NoneImpl` live in `result/internal/` and `maybe/internal/` respectively. They are not re-exported. Per rule 0014, the only public construction point is the factory function. +3. **Type aliases over `interface`.** Per rule 0012, the public types are `type Ok = OkImpl` (and equivalents). The former `interface` declarations become type aliases pointing at the class. This removes the ambiguity of the rule 0012 exception list: classes are the open shape; `type` is the public contract. +4. **Private fields via `#`.** State is stored in `#value` / `#error` (or equivalent) using ECMAScript private fields. No `readonly` placeholder, no `private` TS keyword that compiles to public. Rule 0014 asks for true encapsulation; `#` delivers it. +5. **`none` is a static singleton.** `NoneImpl.NONE` is a single instance; `none` exports it. Mirrors the current behaviour with the same identity guarantees (`some(10) === some(10)` is intentionally false; `none === none` is true). +6. **Discrimination via `_tag` field.** The `_tag` field is public on the class instances (because `_tag` is part of the public type contract — `isResult` and `isMaybe` rely on it). Consumers inspect `_tag` for their own guards; the class does not expose `instanceof` checks. +7. **No `as unknown as ...` in the implementation.** The previous `constants.ts` relied on chained casts (rule 0008 violation) to convince the compiler that `return this` inside an `Ok` literal was typed as `Ok`. Classes infer `this` correctly. The refactor removes every chained cast inside the refactored modules. +8. **Pipeables are pure functions.** Each pipeable is a function from a value to a function of the operation: `map(fn: (value: T) => B): (result: Result) => Result`. They compose through `pipe`. They do not capture `this`. +9. **Unit is untouched.** `Unit` is a one-property singleton. Converting it to a class is ceremony without value. The rule of three (rule 0001, invariant 4) does not apply. + +## File map + +``` +packages/fp/src/ +├── index.ts # unchanged barrel (no new public exports outside pipeables) +├── types.ts # unchanged +├── result/ +│ ├── types.ts # Ok/Err/Result as type aliases to OkImpl/ErrImpl +│ ├── constants.ts # ok()/err() factories use the internal classes +│ ├── internal/ +│ │ ├── ok-impl.ts # OkImpl class (not exported) +│ │ └── err-impl.ts # ErrImpl class (not exported) +│ ├── functions.ts # NEW — pipeable map, flatMap, mapError, ... +│ └── index.ts # re-exports types, factories, AND pipeables +├── maybe/ +│ ├── types.ts # Some/None/Maybe as type aliases +│ ├── constants.ts # some()/none()/maybe() factories +│ ├── internal/ +│ │ ├── some-impl.ts # SomeImpl class (not exported) +│ │ └── none-impl.ts # NoneImpl class with NONE singleton +│ ├── functions.ts # NEW — pipeable map, flatMap, filter, ... +│ └── index.ts # re-exports types, factories, AND pipeables +└── unit/ # unchanged +``` + +## Out of scope + +- Behaviour changes. Every public method keeps its current semantics. +- Test changes. `tests/index.test.ts` exercises the public API and should pass without edits. +- Documentation site (`apps/web/`). Doc updates land in a follow-up PR. +- `Try`, `pipe`, `flow`, `AsyncResult`, `Queue`, `Sequence`, `Collection`, `gen`. These are not in the current `src/`. If they exist in feature branches, they are merged independently. + +## Rollout + +- Single PR, single changeset. +- Changeset: `minor` if pipeables are considered a new feature; `patch` if we treat them as completion of an existing TODO. Proposal: `minor` (new exports). +- After merge to `staging`, the next release will exercise the new internal classes via the existing smoke test before publishing. diff --git a/docs/engineering/plans/function-utilities.md b/docs/engineering/plans/function-utilities.md new file mode 100644 index 00000000..37070a0c --- /dev/null +++ b/docs/engineering/plans/function-utilities.md @@ -0,0 +1,57 @@ +# Function utilities + +**Status**: Implemented (PR #TBD). +**Date**: 2026-08-17. +**Branch**: `function/pipe-and-friends`. + +## Goal + +Deliver the function utilities that the documentation has been promising since v1.0 but the code has never shipped: + +- `pipe` — left-to-right function composition with a starting value. +- `flow` — left-to-right function composition that returns a function. +- `identity` — the identity function. +- `constant` — wraps a value into a function that ignores its argument. +- `flip` — swaps the first two arguments of a binary function. +- `tupled` — converts a function whose first argument is a tuple into a function that takes the tuple. +- `untupled` — inverse of `tupled`. + +These are the seven exports announced in `docs/internal/product/features/function-utilities.md` and in the top-level `README.md`. + +## Decisions + +1. **ESM-only, package-local.** The module lives at `packages/fp/src/function/`. No runtime dependencies. Pure functions, no state. +2. **Variadic overloads, not arrays.** `pipe(...args)` and `flow(...fns)` accept up to nine steps. Beyond that, the type system widens to `Function`-equivalent and the caller is on their own — this matches the spec in `function-utilities.md`. +3. **`identity` and `constant` are arrow functions, not classes.** Rule 0014 — classes are not exports. Style preference: arrow functions because they show the closure more clearly for these trivially-sized functions. +4. **`flip` works on two-arg functions only.** Three+ argument `flip` is a different shape (permutation); out of scope. Documented in JSDoc. +5. **`tupled` / `untupled` are inverses.** `untupled(tupled(f))` returns the same function shape as `f`. Tests assert both directions. +6. **No `any`.** All overloads are typed. The variadic tail collapses to `(...args: unknown[]) => unknown` only when the call site widens — the supplied overloads cover the documented arities (1-9). +7. **The new exports do not collide with the existing pipeables.** The barrel already disambiguates Maybe/Result pipeables by suffixing. The function utilities (`pipe`, `flow`, `identity`, `constant`, `flip`, `tupled`, `untupled`) keep their bare names because none of them clash with a `Result` or `Maybe` export. + +## File map + +``` +packages/fp/src/ +├── function/ +│ ├── pipe.ts +│ ├── flow.ts +│ ├── identity.ts +│ ├── constant.ts +│ ├── flip.ts +│ ├── tupled.ts +│ ├── untupled.ts +│ └── index.ts # re-exports the seven functions +└── index.ts # extends the public barrel +``` + +## Out of scope + +- `gen()` — generator composition. Larger feature, separate PR. +- `Try`, `sleep`, `retry`, `timeout`, `Queue`, `Predicate`, `Refinement`, `Context`, `Sequence`, `Collection` — listed in the README but unimplemented. Out of scope for this PR. +- `pipeAsync` — async pipeline variant. Can be a follow-up. + +## Rollout + +- Single PR, single changeset. +- Changeset: `minor` (new public exports). +- After merge to staging, the next release will surface the new exports via the existing smoke test. diff --git a/docs/internal/product/README.md b/docs/internal/product/README.md index 85e86b6d..ed24f518 100644 --- a/docs/internal/product/README.md +++ b/docs/internal/product/README.md @@ -1,4 +1,4 @@ -# @deessejs/fp — Functional Programming Utilities +# @deessejs/fp - Functional Programming Utilities A lightweight TypeScript library of functional programming primitives. Designed to be simple, composable, and dependency-free. @@ -6,61 +6,56 @@ A lightweight TypeScript library of functional programming primitives. Designed > **Simple by default.** No over-engineering, no fancy type gymnastics. Just the primitives you need to write cleaner code. -This library is the core of an ecosystem — intentionally minimal, fast to learn, and easy to extend. +This library is the core of an ecosystem - intentionally minimal, fast to learn, and easy to extend. ## Quick Start ```typescript -import { Result, ok, err, pipe } from '@deessejs/fp'; +import { Result, ok, err, pipe, fromThrowable, attempt } from "@deessejs/fp"; // Result: represent values that may have failed const divide = (a: number, b: number): Result => - b === 0 ? err('Division by zero') : ok(a / 2); + b === 0 ? err("Division by zero") : ok(a / b); -// Maybe: represent optional values -const findUser = (id: string): Maybe => db.get(id); - -// Chain operations -const result = pipe( - ok(5), - Result.map(n => n * 2), - Result.flatMap(n => n > 10 ? ok(n) : err('too small')), -); +// Wrap a throwing function +const readFile = fromThrowable({ + onSuccess: () => fs.readFileSync("config.json", "utf-8"), + onError: (e) => (e instanceof Error ? e : new Error(String(e))), +}); ``` ## Features ### Core Primitives -- **[Result](features/result.md)** — `Ok | Err` pattern for type-safe error handling -- **[Maybe](features/maybe.md)** — `Some | None` pattern for optional values -- **[Try](features/try.md)** — Wrap sync/async operations that may throw -- **[Unit](features/unit.md)** — The unit type for void-returning functions +- **[Result](features/result.md)** - `Ok | Err` pattern for type-safe error handling, including wrapping throwing functions +- **[Maybe](features/maybe.md)** - `Some | None` pattern for optional values +- **[Unit](features/unit.md)** - The unit type for void-returning operations ### Function Utilities -- **[Function Utilities](features/function-utilities.md)** — `pipe`, `flow`, `identity`, `constant`, `flip`, `tupled` +- **[Function Utilities](features/function-utilities.md)** - `pipe`, `flow`, `identity`, `constant`, `flip` ### Async Utilities -- **[Async Utilities](features/async-utilities.md)** — `sleep`, `retry`, `timeout`, `Queue` +- **[Async Utilities](features/async-utilities.md)** - `sleep`, `retry`, `timeout`, `Queue` ### Predicate Utilities -- **[Predicate Utilities](features/predicate-utilities.md)** — `Predicate`, `Refinement`, `not`, `and`, `or` +- **[Predicate Utilities](features/predicate-utilities.md)** - `Predicate`, `Refinement`, `not`, `and`, `or` ### Collection Types -- **[Collection Types](features/collection-types.md)** — `Context`, `Sequence`, `Collection`, AsyncIterator utils +- **[Collection Types](features/collection-types.md)** - `Context`, `Sequence`, `Collection`, AsyncIterator utils ### Advanced -- **[Generator Composition](features/generator-composition.md)** — `gen()` with `yield*` for clean async flows -- **[Serialization](features/serialization.md)** — `serialize`/`deserialize` for RPC +- **[Generator Composition](features/generator-composition.md)** - `gen()` with `yield*` for clean async flows +- **[Serialization](features/serialization.md)** - `serialize`/`deserialize` for RPC ### Ecosystem -- **[Ecosystem Integration](features/ecosystem-integration.md)** — First-class `@deessejs/errors` support +- **[Ecosystem Integration](features/ecosystem-integration.md)** - First-class `@deessejs/errors` support ## Installation diff --git a/docs/internal/product/features/result.md b/docs/internal/product/features/result.md index 57ed5eb1..18dcd662 100644 --- a/docs/internal/product/features/result.md +++ b/docs/internal/product/features/result.md @@ -517,4 +517,137 @@ function deserialize(value: unknown): Result; // Partition array of Results function partition(results: readonly Result[]): [T[], E[]]; -``` \ No newline at end of file +```\n## Wrapping Throwing Functions\n\nResult is also the home for wrapping throwing code. The fromThrowable and fromAsyncThrowable factories catch exceptions and surface them as Err, so every throwing boundary in your codebase becomes a typed Result.\n\n### fromThrowable\n\n```typescript\nfunction fromThrowable(thunk: () => T): Result;\nfunction fromThrowable(options: {\n onSuccess: () => T;\n onError: (cause: unknown) => E;\n}): Result;\n```\n\nTwo overloads:\n\n- fromThrowable(thunk) — captures any thrown value into an UnhandledException carrying the original cause.\n- fromThrowable({ onSuccess, onError }) — runs onSuccess inside a try/catch; thrown values are mapped through onError.\n\n```typescript\nimport { fromThrowable, ok, err, getOrElse } from "@deessejs/fp";\n\nconst config = getOrElse(defaultConfig)(\n fromThrowable({\n onSuccess: () => readConfigSync(path),\n onError: (e) => e instanceof Error ? e : new Error(String(e)),\n }),\n);\n```\n\n### fromAsyncThrowable\n\n```typescript\nfunction fromAsyncThrowable(thunk: () => Promise): Promise>;\nfunction fromAsyncThrowable(options: {\n onSuccess: () => Promise;\n onError: (cause: unknown) => E | Promise;\n}): Promise>;\n```\n\nSame shape, async. Rejects and sync throws are captured; the onError mapper may itself return a Promise.\n\n```typescript\nimport { pipe, map, getOrElse, fromAsyncThrowable } from "@deessejs/fp";\n\nconst templates = await pipe(\n fromAsyncThrowable(() => orpc.templates.list(undefined, liveCache)),\n map((list) => list.templates),\n getOrElse([]),\n);\n```\n\n### UnhandledException\n\n```typescript\ninterface UnhandledException {\n readonly _tag: "UnhandledException";\n readonly cause: unknown;\n}\n```\n\nWrapper placed in the Err.error field when the thunk-only overload of fromThrowable / fromAsyncThrowable is used and no onError mapper is supplied. The original thrown value is preserved in cause.\n\n### attempt\n\n```typescript\nfunction attempt(config: AttemptConfig): Attempt;\n\ninterface AttemptConfig {\n readonly onSuccess: () => T | Promise;\n readonly retry?: RetryConfig;\n readonly normalize?: (e: unknown) => unknown;\n}\n\ninterface Attempt {\n execute(): Promise>;\n clientSafe(): Promise>;\n}\n```\n\nA lazy wrapper around a throwing operation. attempt() does not invoke onSuccess; the wrapped operation runs only when execute() or clientSafe() is called. A single re-attempt is performed when config.retry.shouldRetry(cause) returns true. clientSafe() returns Result with a safe-shape error suitable for HTTP responses.\n\n### withReporting\n\n```typescript\nfunction withReporting(\n onSuccess: () => T | Promise,\n operationName: string,\n reporter: ErrorReporter,\n metadata?: Readonly>,\n): Promise>;\n\ninterface ErrorReporter {\n report(error: unknown, context: ErrorContext): void;\n}\ninterface ErrorContext {\n readonly timestamp: number;\n readonly operation: string;\n readonly metadata?: Readonly>;\n}\ninterface ReportableError {\n readonly _tag: "ReportableError";\n readonly message: string;\n readonly cause?: unknown;\n}\n```\n\nWraps a sync or async operation. On failure, the original cause is forwarded to the ErrorReporter, and a Result is returned. The ReportableError preserves the original cause in its cause field.\n\n### classifyError\n\n```typescript\nfunction classifyError(\n e: unknown,\n rules: ClassificationRule[],\n): ErrorClassification;\n\ntype ErrorClassification = "retryable" | "non-retryable";\n\ninterface ClassificationRule {\n readonly error: ErrorConstructor;\n readonly classification: ErrorClassification;\n}\n\ntype ErrorConstructor = abstract new (...args: unknown[]) => Error;\n```\n\nMatches a thrown value against a list of Error constructors with instanceof and returns the classification of the first matching rule, or non-retryable when the value is not an Error or no rule matches.\n' +echo "result.md: $(wc -l < /c/Users/dpereira/.t3/worktrees/fp/t3code-ca972007/docs/internal/product/features/result.md) lines" +APPEND_EOF_DUMMY +echo "result.md: $(wc -l < /c/Users/dpereira/.t3/worktrees/fp/t3code-ca972007/docs/internal/product/features/result.md) lines" + + +## Wrapping Throwing Functions + +Result is also the home for wrapping throwing code. The fromThrowable and fromAsyncThrowable factories catch exceptions and surface them as Err, so every throwing boundary in your codebase becomes a typed Result. + +### fromThrowable + +```typescript +function fromThrowable(thunk: () => T): Result; +function fromThrowable(options: { + onSuccess: () => T; + onError: (cause: unknown) => E; +}): Result; +``` + +Two overloads: + +- fromThrowable(thunk) — captures any thrown value into an UnhandledException carrying the original cause. +- fromThrowable({ onSuccess, onError }) — runs onSuccess inside a try/catch; thrown values are mapped through onError. + +```typescript +import { fromThrowable, ok, err, getOrElse } from "@deessejs/fp"; + +const config = getOrElse(defaultConfig)( + fromThrowable({ + onSuccess: () => readConfigSync(path), + onError: (e) => e instanceof Error ? e : new Error(String(e)), + }), +); +``` + +### fromAsyncThrowable + +```typescript +function fromAsyncThrowable(thunk: () => Promise): Promise>; +function fromAsyncThrowable(options: { + onSuccess: () => Promise; + onError: (cause: unknown) => E | Promise; +}): Promise>; +``` + +Same shape, async. Rejects and sync throws are captured; the onError mapper may itself return a Promise. + +```typescript +import { pipe, map, getOrElse, fromAsyncThrowable } from "@deessejs/fp"; + +const templates = await pipe( + fromAsyncThrowable(() => orpc.templates.list(undefined, liveCache)), + map((list) => list.templates), + getOrElse([]), +); +``` + +### UnhandledException + +```typescript +interface UnhandledException { + readonly _tag: "UnhandledException"; + readonly cause: unknown; +} +``` + +Wrapper placed in the Err.error field when the thunk-only overload of fromThrowable / fromAsyncThrowable is used and no onError mapper is supplied. The original thrown value is preserved in cause. + +### attempt + +```typescript +function attempt(config: AttemptConfig): Attempt; + +interface AttemptConfig { + readonly onSuccess: () => T | Promise; + readonly retry?: RetryConfig; + readonly normalize?: (e: unknown) => unknown; +} + +interface Attempt { + execute(): Promise>; + clientSafe(): Promise>; +} +``` + +A lazy wrapper around a throwing operation. attempt() does not invoke onSuccess; the wrapped operation runs only when execute() or clientSafe() is called. A single re-attempt is performed when config.retry.shouldRetry(cause) returns true. clientSafe() returns Result with a safe-shape error suitable for HTTP responses. + +### withReporting + +```typescript +function withReporting( + onSuccess: () => T | Promise, + operationName: string, + reporter: ErrorReporter, + metadata?: Readonly>, +): Promise>; + +interface ErrorReporter { + report(error: unknown, context: ErrorContext): void; +} +interface ErrorContext { + readonly timestamp: number; + readonly operation: string; + readonly metadata?: Readonly>; +} +interface ReportableError { + readonly _tag: "ReportableError"; + readonly message: string; + readonly cause?: unknown; +} +``` + +Wraps a sync or async operation. On failure, the original cause is forwarded to the ErrorReporter, and a Result is returned. The ReportableError preserves the original cause in its cause field. + +### classifyError + +```typescript +function classifyError( + e: unknown, + rules: ClassificationRule[], +): ErrorClassification; + +type ErrorClassification = "retryable" | "non-retryable"; + +interface ClassificationRule { + readonly error: ErrorConstructor; + readonly classification: ErrorClassification; +} + +type ErrorConstructor = abstract new (...args: unknown[]) => Error; +``` + +Matches a thrown value against a list of Error constructors with instanceof and returns the classification of the first matching rule, or non-retryable when the value is not an Error or no rule matches. diff --git a/docs/internal/product/features/try.md b/docs/internal/product/features/try.md deleted file mode 100644 index 49cac11e..00000000 --- a/docs/internal/product/features/try.md +++ /dev/null @@ -1,805 +0,0 @@ -# Try - -Wraps synchronous or asynchronous operations that may throw. Converts exceptions into `Result`. - -## Why Try? - -Never let exceptions escape silently. Every throwing function should be wrapped with `try_` or `tryPromise`. - -```typescript -// Without Try - exception might slip through -function parseConfig(json: string) { - return JSON.parse(json); // throws on invalid JSON -} - -// With Try - errors are explicit -function parseConfig(json: string) { - return try_(() => JSON.parse(json)); -} -``` - -## Installation - -```typescript -import { try_, tryPromise } from '@deessejs/fp'; -``` - -## Real-World Examples - -### JSON Configuration File - -```typescript -import { try_, tryPromise } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -const ConfigError = error({ - name: 'ConfigError', - message: 'Configuration error: {reason}', -}); - -const ParseError = error({ - name: 'ParseError', - message: 'Failed to parse: {cause}', -}); - -// Read and parse config file -async function loadConfig(path: string): Promise> { - return tryPromise(() => fs.readFile(path, 'utf-8')) - .mapError(e => ConfigError({ reason: `Cannot read ${path}: ${e}` })) - .flatMap(content => try_({ - try: () => JSON.parse(content) as unknown, - catch: e => ParseError({ cause: e }), - })) - .map(data => validateConfigSchema(data)) - .mapError(e => ConfigError({ reason: `Invalid config: ${e.message}` })); -} - -// Validate schema -function validateConfigSchema(data: unknown): Result { - if (!data || typeof data !== 'object') { - return err(ConfigError({ reason: 'Config must be an object' })); - } - - const obj = data as Record; - - if (typeof obj.port !== 'number') { - return err(ConfigError({ reason: 'port must be a number' })); - } - - return ok(obj as AppConfig); -} - -// Usage -app.start(async () => { - const config = await loadConfig('./config.json'); - - config.match({ - ok: (cfg) => { - app.listen(cfg.port); - console.log(`Server started on port ${cfg.port}`); - }, - err: (e) => { - console.error('Failed to load config:', e.message); - process.exit(1); - }, - }); -}); -``` - -### API Request with Error Handling - -```typescript -import { tryPromise, retry, exponential, timeout } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -const ApiError = error({ - name: 'ApiError', - message: 'API request failed: {cause}', -}); - -const NetworkError = error({ - name: 'NetworkError', - message: 'Network error: {reason}', -}); - -// Robust API client -async function apiRequest( - url: string, - options?: RequestInit -): Promise> { - const robustFetch = retry({ - attempts: 3, - delay: exponential(100), - shouldRetry: (e) => e.message.includes('ECONNRESET'), - }); - - return timeout(10000, () => - robustFetch(() => fetch(url, options)) - ).mapError(e => { - if (e instanceof TimeoutError) { - return NetworkError({ reason: 'Request timed out' }); - } - return NetworkError({ reason: e.message }); - }).flatMap(async response => { - if (!response.ok) { - const body = await response.text().catch(() => 'Unknown error'); - return err(ApiError({ cause: `HTTP ${response.status}: ${body}` })); - } - - return tryPromise(() => response.json() as Promise) - .mapError(e => ApiError({ cause: e })); - }); -} - -// Get user with error handling -async function getUser(userId: string): Promise> { - return apiRequest(`/api/users/${userId}`); -} - -// Usage -app.get('/users/:id', async (req, res) => { - const result = await getUser(req.params.id); - - result.match({ - ok: (user) => res.json(user), - err: (e) => { - if (is(e, NetworkError)) { - res.status(503).json({ error: 'Service unavailable' }); - } else { - res.status(500).json({ error: 'Internal error' }); - } - }, - }); -}); -``` - -### Database Operations - -```typescript -import { try_, tryPromise } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -const DatabaseError = error({ - name: 'DatabaseError', - message: 'Database operation failed: {cause}', -}); - -const QueryError = error({ - name: 'QueryError', - message: 'Query error: {reason}', -}); - -// Safe database query -async function safeQuery( - query: string, - params?: unknown[] -): Promise> { - return tryPromise(() => db.query(query, params)) - .mapError(e => DatabaseError({ cause: e })); -} - -// Safe transaction -async function safeTransaction( - fn: (client: DbClient) => Promise -): Promise> { - return tryPromise(async () => { - const client = await db.connect(); - try { - const result = await fn(client); - await client.commit(); - return result; - } catch (e) { - await client.rollback(); - throw e; - } finally { - client.release(); - } - }).mapError(e => DatabaseError({ cause: e })); -} - -// Safe insert with validation -async function insertUser( - data: unknown -): Promise> { - return try_({ - try: () => validateUserData(data), - catch: (e) => QueryError({ reason: e.message }), - }).flatMap(validData => - safeQuery( - 'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *', - [validData.email, validData.name] - ).map(rows => rows[0]) - ); -} - -// Usage -app.post('/users', async (req, res) => { - const result = await insertUser(req.body); - - result.match({ - ok: (user) => res.status(201).json(user), - err: (e) => { - if (is(e, QueryError)) { - res.status(400).json({ error: e.message }); - } else { - res.status(500).json({ error: 'Database error' }); - } - }, - }); -}); -``` - -### Client-Safe Error Handling - -Never expose raw internal errors to clients. Normalize them to safe, public-facing types. - -```typescript -import { try_, tryPromise, attempt } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -// Internal errors (never expose to clients) -const InternalError = error({ - name: 'InternalError', - message: 'Internal error: {cause}', -}); - -const DatabaseError = error({ - name: 'DatabaseError', - message: 'Database error: {cause}', -}); - -// Public errors (safe to expose) -const PublicError = error({ - name: 'PublicError', - message: '{message}', -}); - -// Normalize internal errors to public ones -function toPublicError(e: unknown): PublicError { - if (is(e, DatabaseError)) { - return PublicError({ message: 'Service temporarily unavailable' }); - } - if (is(e, InternalError)) { - return PublicError({ message: 'An unexpected error occurred' }); - } - // Unknown errors get sanitized - if (e instanceof Error) { - return PublicError({ message: 'An error occurred' }); - } - return PublicError({ message: 'Unknown error' }); -} - -// Client-safe API wrapper -async function clientSafe( - operation: () => Promise -): Promise> { - return tryPromise(operation).mapError(toPublicError); -} - -// Usage in API handler -app.get('/api/data', async (req, res) => { - const result = await clientSafe(() => fetchData(req.params.id)); - - res.json(serialize(result)); - // Client receives: { status: "error", error: { name: "PublicError", message: "..." } } - // Never: { status: "error", error: { name: "DatabaseError", cause: ConnectionRefused, stack: "..." } } -}); -``` - -### Error Normalization Interface - -Define a consistent normalization strategy for your entire application. - -```typescript -import { tryPromise } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -// Define your error taxonomy -const NetworkError = error({ - name: 'NetworkError', - message: 'Network error: {reason}', -}); - -const AuthError = error({ - name: 'AuthError', - message: 'Authentication failed: {reason}', -}); - -const ValidationError = error({ - name: 'ValidationError', - message: 'Validation failed: {reason}', -}); - -// Normalizer maps internal errors to public responses -type ErrorNormalizer = (e: E) => NormalizedError; - -interface NormalizedError { - code: string; - message: string; - status: number; - public: boolean; -} - -const normalizers: Record> = { - NetworkError: (e) => ({ - code: 'NETWORK_ERROR', - message: 'Unable to connect. Please check your connection.', - status: 503, - public: true, - }), - AuthError: (e) => ({ - code: 'AUTH_ERROR', - message: 'Authentication required.', - status: 401, - public: true, - }), - ValidationError: (e) => ({ - code: 'VALIDATION_ERROR', - message: e.message, - status: 400, - public: true, - }), -}; - -// Generic safe wrapper with normalization -async function safeApi( - operation: () => Promise -): Promise> { - return tryPromise(operation).mapError((e) => { - const normalizer = normalizers[e.constructor.name]; - return normalizer ? normalizer(e) : { - code: 'INTERNAL_ERROR', - message: 'An unexpected error occurred.', - status: 500, - public: false, - }; - }); -} -``` - -### Server/Client Error Boundaries - -Different error handling strategies for server and client contexts. - -```typescript -import { try_, tryPromise } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -// Server-side: rich error tracking -const ServerError = error({ - name: 'ServerError', - message: 'Server error: {cause}', -}); - -async function serverOperation( - operation: () => Promise, - context: { requestId: string; userId?: string } -): Promise> { - return tryPromise(operation) - .mapError(e => ServerError({ - cause: e, - })) - .tap(result => { - // Log for monitoring - if (result.isErr()) { - logger.error({ - requestId: context.requestId, - userId: context.userId, - error: result.error, - stack: result.error.cause instanceof Error - ? result.error.cause.stack - : undefined, - }); - } - }); -} - -// Client-side: safe error display -const ClientError = error({ - name: 'ClientError', - message: '{message}', -}); - -async function clientOperation( - operation: () => Promise -): Promise> { - return tryPromise(operation).mapError(e => { - // Extract safe message, never expose internals - if (e instanceof Error) { - return ClientError({ message: sanitizeMessage(e.message) }); - } - return ClientError({ message: 'Something went wrong' }); - }); -} - -// Shared safe wrapper for cross-platform code -async function safe( - operation: () => Promise, - options?: { - onError?: (e: unknown) => void; - context?: 'server' | 'client'; - } -): Promise> { - return tryPromise(operation).mapError(e => { - options?.onError?.(e); - - if (options?.context === 'server') { - return ServerError({ cause: e }); - } - - // Default to client-safe - return ClientError({ - message: e instanceof Error - ? sanitizeMessage(e.message) - : 'Unknown error', - }); - }); -} -``` - -### Retry with Error Classification - -Combine retry with error classification to selectively retry only recoverable errors. - -```typescript -import { tryPromise, retry, exponential, constant } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -const NetworkError = error({ - name: 'NetworkError', - message: 'Network error: {reason}', -}); - -const TimeoutError = error({ - name: 'TimeoutError', - message: 'Request timed out: {reason}', -}); - -const AuthError = error({ - name: 'AuthError', - message: 'Authentication failed: {reason}', -}); - -// Classify errors for retry decisions -type RetryableError = NetworkError | TimeoutError; -type NonRetryableError = AuthError; - -function classifyError(e: unknown): 'retryable' | 'non-retryable' { - if (is(e, AuthError)) return 'non-retryable'; - if (is(e, NetworkError) || is(e, TimeoutError)) return 'retryable'; - // Unknown errors: retry once - return 'retryable'; -} - -// Smart retry with error classification -async function smartRetry( - operation: () => Promise -): Promise> { - return retry({ - attempts: 3, - delay: exponential(100), - shouldRetry: (e) => classifyError(e) === 'retryable', - onRetry: (e, attempt) => { - console.warn(`Retry ${attempt}:`, e.message); - }, - })(operation); -} - -// Usage -async function fetchWithSmartRetry(url: string) { - return smartRetry(() => fetch(url)).mapError(e => { - if (is(e, NetworkError)) { - return NetworkError({ reason: `Failed to fetch ${url}` }); - } - return e; - }); -} -``` - -### Custom Error Reporters - -Attach metadata and context to errors for better debugging. - -```typescript -import { try_, tryPromise } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -const ReportableError = error({ - name: 'ReportableError', - message: '{message}', -}); - -// Error reporter interface -interface ErrorReporter { - report(error: unknown, context: ErrorContext): void; -} - -interface ErrorContext { - timestamp: number; - operation: string; - metadata?: Record; -} - -// Console reporter for development -const consoleReporter: ErrorReporter = { - report(error, context) { - console.error(`[${context.operation}]`, { - error, - ...context.metadata, - timestamp: new Date(context.timestamp).toISOString(), - }); - }, -}; - -// Metrics reporter for production -const metricsReporter: ErrorReporter = { - report(error, context) { - metrics.increment('error.count', { - operation: context.operation, - error_type: error instanceof Error ? error.name : 'unknown', - }); - }, -}; - -// Combined reporter -const reporter: ErrorReporter = { - report(error, context) { - consoleReporter.report(error, context); - if (process.env.NODE_ENV === 'production') { - metricsReporter.report(error, context); - } - }, -}; - -// Wrapper with reporting -function withReporting( - operation: () => Promise, - operationName: string, - metadata?: Record -): Promise> { - return tryPromise(operation) - .mapError(e => { - reporter.report(e, { - timestamp: Date.now(), - operation: operationName, - metadata, - }); - return ReportableError({ - message: e instanceof Error ? e.message : 'Operation failed', - }); - }); -} - -// Usage -const result = await withReporting( - () => processPayment(order), - 'processPayment', - { orderId: order.id, amount: order.total } -); -``` - -### File System Operations - -```typescript -import { try_, tryPromise } from '@deessejs/fp'; -import { error } from '@deessejs/errors'; - -const FileError = error({ - name: 'FileError', - message: 'File operation failed: {cause}', -}); - -// Read file safely -async function readFile(path: string): Promise> { - return tryPromise(() => fs.readFile(path, 'utf-8')) - .mapError(e => FileError({ reason: `Cannot read ${path}: ${e}` })); -} - -// Write file safely -async function writeFile( - path: string, - content: string -): Promise> { - return tryPromise(() => fs.writeFile(path, content, 'utf-8')) - .mapError(e => FileError({ reason: `Cannot write ${path}: ${e}` })); -} - -// Atomic write (write to temp, then rename) -async function atomicWrite( - path: string, - content: string -): Promise> { - const tempPath = `${path}.${Date.now()}.tmp`; - - return writeFile(tempPath, content) - .flatMap(() => tryPromise(() => fs.rename(tempPath, path)) - .mapError(e => FileError({ reason: `Cannot rename ${tempPath}: ${e}` })) - ); -} - -// Read multiple files -async function readConfigFiles( - paths: string[] -): Promise, FileError>> { - const results = await Promise.all( - paths.map(p => readFile(p).catch(() => err(FileError({ reason: `Failed: ${p}` })))) - ); - - const [oks, errs] = partition(results); - - if (errs.length > 0) { - return err(errs[0].error); - } - - const config: Record = {}; - paths.forEach((path, i) => { - config[path] = oks[i].value; - }); - - return ok(config); -} - -// Usage -async function loadSettings() { - const result = await readConfigFiles([ - './default-settings.json', - './user-settings.json', - process.env.SETTINGS_PATH ?? '', - ].filter(Boolean)); - - return result.map(configs => mergeConfigs(...Object.values(configs))); -} -``` - -## API Reference - -### try_ - -Wraps a synchronous function that may throw. - -```typescript -// Simple form -function try_(thunk: () => A): Result; - -// With custom error handler -function try_(options: { - try: () => A; - catch: (cause: unknown) => E; -}): Result; -``` - -### tryPromise - -Wraps an async function that may reject. - -```typescript -// Simple form -function tryPromise( - thunk: () => Promise -): Promise>; - -// With custom error handler -function tryPromise(options: { - try: () => Promise; - catch: (cause: unknown) => E | Promise; -}): Promise>; - -// With retry -function tryPromise( - thunk: () => Promise, - config: RetryConfig -): Promise>; -``` - -### attempt (Advanced) - -Creates a configured attempt with options for client-safe errors, retry, and normalization. - -```typescript -// Create attempt with options -function attempt(config: AttemptConfig): Attempt; - -// Attempt configuration -interface AttemptConfig { - try: () => T | Promise; - client?: boolean; // Normalize errors for client exposure - retry?: RetryConfig; - normalize?: (e: unknown) => unknown; -} - -// Attempt result -interface Attempt { - execute(): Promise>; - clientSafe(): Promise>; -} - -// Normalized error for client-safe responses -interface NormalizedError { - code: string; - message: string; - status: number; - public: boolean; -} -``` - -### Error Normalization - -```typescript -// Error normalizer function -type ErrorNormalizer = (e: E) => NormalizedError; - -// Sanitize error message for clients -function sanitizeMessage(message: string): string; - -// Create client-safe error -function toClientSafe( - operation: () => Promise, - normalizer: ErrorNormalizer -): Promise>; -``` - -### Error Classification - -```typescript -// Classify error for retry decisions -type ErrorClassification = 'retryable' | 'non-retryable'; - -function classifyError( - e: unknown, - rules: ClassificationRule[] -): ErrorClassification; - -interface ClassificationRule { - error: ErrorFactory; - classification: ErrorClassification; -} -``` - -### Error Reporting - -```typescript -// Error reporter interface -interface ErrorReporter { - report(error: unknown, context: ErrorContext): void; -} - -interface ErrorContext { - timestamp: number; - operation: string; - metadata?: Record; -} - -// Wrap operation with reporting -function withReporting( - operation: () => Promise, - operationName: string, - reporter: ErrorReporter, - metadata?: Record -): Promise>; -``` - -### Retry Configuration - -```typescript -interface RetryConfig { - attempts: number; - delay: DelayStrategy; - onRetry?: (error: E, attempt: number) => void; - shouldRetry?: (error: E) => boolean; -} - -type DelayStrategy = - | typeof exponential(baseMs: number) - | typeof linear(baseMs: number) - | typeof constant(baseMs: number); -``` - -### UnhandledException - -```typescript -// Error when no custom handler is provided -interface UnhandledException { - readonly name: 'UnhandledException'; - readonly cause: unknown; -} -``` \ No newline at end of file diff --git a/packages/fp/CHANGELOG.md b/packages/fp/CHANGELOG.md index dc04fdc9..5f314f4d 100644 --- a/packages/fp/CHANGELOG.md +++ b/packages/fp/CHANGELOG.md @@ -1,5 +1,86 @@ # @deessejs/fp +## 1.3.0 + +### Minor Changes + +- 7e9b7fd: refactor(fp): replace plain-object Result/Maybe with internal classes behind the public factory functions + + The public API is unchanged. `Ok`, `Err`, `Some`, `None`, `Result`, and `Maybe` are now `type` aliases pointing at internal `OkImpl`, `ErrImpl`, `SomeImpl`, and `NoneImpl` classes. The classes are not exported; the factory functions (`ok`, `err`, `some`, `none`, `maybe`) remain the only public construction entry points. + + Chained type assertions on the previous implementations are gone. `none` is a single static instance. + + Also delivers the pipeable functions that the `TODO` comments in `result/index.ts` and `maybe/index.ts` have been signalling since v1.0: `map`, `flatMap`, `mapError`, `filter`, `tap`, `tapAsync`, `flatMapAsync`, `match`, `fold`, `getOrElse`, `getOrThrow`, `getOrNull`, `getOrUndefined`, `toMaybe`, `toResult`, `toArray`, `toIterable`, `isOk`, `isErr`, `isSome`, `isNone` — and the `get` projection for `Maybe`. They compose through `pipe`. + + See `docs/engineering/plans/architecture-classes.md`. +- 2a05140: feat(fp): add function utilities (pipe, flow, identity, constant, flip, tupled, untupled) + + Delivers the function utilities that the documentation has been + promising since v1.0 (see `docs/internal/product/features/function-utilities.md`). + + - `pipe` — left-to-right function composition with a starting value. + - `flow` — left-to-right function composition that returns a function. + - `identity` — the identity function. + - `constant` — wraps a value into a function that ignores its argument. + - `flip` — swaps the first two arguments of a binary function. + - `tupled` / `untupled` — tuple ↔ positional adapters. + + `pipe` and `flow` carry variadic overloads up to nine steps. Beyond + that the tail collapses to `unknown` and the caller is on their own. + + These are the seven exports that the README and the documentation + have been advertising. The pipeables shipped in PR #431 (`map`, + `flatMap`, ...) are now usable through `pipe` as the JSDoc in + `result/functions.ts` and `maybe/functions.ts` already documents. + + See `docs/engineering/plans/function-utilities.md`. +- aae1039: refactor(fp): unify error handling on Result, retire the Try module + + The standalone Try type is gone. Wrapping throwing functions is now part of the Result surface. + + New public API: + - Result.fromThrowable(thunk or { onSuccess, onError }) returns Result + - Result.fromAsyncThrowable(thunk or { onSuccess, onError }) returns Promise> + - UnhandledException, AttemptConfig, Attempt, NormalizedError, RetryConfig, DelayStrategy, ErrorReporter, ErrorContext, ReportableError, ErrorClassification, ClassificationRule, ErrorConstructor types live on Result + - attempt, withReporting, classifyError are top-level exports backed by result/ modules + + Removed (no aliases; the previous Try PR was never published to npm): + - Success, Failure, Try types + - success, failure factories + - try_, tryPromise aliases + - mapTry, flatMapTry, matchTry, isSuccess, isFailure pipeables (and 11 others) + - _tag Success / Failure discriminants + - The src/try/ directory entirely + + Coverage stays at 100% on lines / branches / functions / statements. + + See docs/internal/product/features/result.md for the canonical documentation, including a new Wrapping Throwing Functions section. + +### Patch Changes + +- 4ad12c1: Split the CI's "Test + coverage gate" job into two: a fast `test` + job and a `coverage` job that posts a sticky PR comment with the + per-file coverage table. No source-code changes. The coverage + threshold gate is disabled in this PR (lands with the test matrix + in a follow-up). +- 726fb94: chore(release): sync main into staging + + Backports the CI/publish fixes from the 1.2.x release series and + commit `119b1e8` (architecture rules, `Ok.filter` contract, drop + dead dependency) into staging. No public API changes. + + - The `Ok.filter(predicate, errorFn)` contract is now part of the + release notes: when the predicate fails and an `errorFn` is + supplied, the result is `Err(errorFn(value))`; without `errorFn`, + the `Ok` passes through. + - Architecture rules mirrored in `src/index.ts` and the ADR pointer + in `docs/engineering/architecture/decisions/`. + - Release pipeline fixes from `main` (idempotent tag creation, + `resolve-version` quoting, `--provenance` removal, etc.) now + ship from staging. + + 🤖 Generated with [Claude Code](https://claude.com/claude-code) + ## 1.2.1 ### Patch Changes diff --git a/packages/fp/package.json b/packages/fp/package.json index 94551441..d09e1d63 100644 --- a/packages/fp/package.json +++ b/packages/fp/package.json @@ -1,6 +1,6 @@ { "name": "@deessejs/fp", - "version": "1.2.9", + "version": "1.3.0", "description": "Functional Programming Utilities for TypeScript", "homepage": "https://fp.deessejs.com", "repository": { @@ -20,6 +20,7 @@ "scripts": { "test": "vitest", "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "build": "tsc -p tsconfig.build.json", "type-check": "tsc --noEmit", "lint": "eslint src/" @@ -53,10 +54,11 @@ "provenance": true }, "devDependencies": { + "@vitest/coverage-v8": "^4.1.10", "@eslint/js": "^9.0.0", "eslint": "^9.0.0", "typescript": "^6.0.3", "typescript-eslint": "^8.61.0", - "vitest": "^4.1.9" + "vitest": "^4.1.10" } -} \ No newline at end of file +} diff --git a/packages/fp/src/function/compose.ts b/packages/fp/src/function/compose.ts new file mode 100644 index 00000000..ae2c4902 --- /dev/null +++ b/packages/fp/src/function/compose.ts @@ -0,0 +1,76 @@ +/** + * compose — right-to-left function composition. + * + * `compose(f, g, h)` returns `(x) => f(g(h(x)))`. The mirror image of + * `flow`. + * + * @see flow for left-to-right composition. + */ + +export function compose(bc: (a: A) => B): (a: A) => B; +export function compose(bc: (b: B) => C, ab: (a: A) => B): (a: A) => C; +export function compose( + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => D; +export function compose( + de: (d: D) => E, + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => E; +export function compose( + ef: (e: E) => F, + de: (d: D) => E, + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => F; +export function compose( + fg: (f: F) => G, + ef: (e: E) => F, + de: (d: D) => E, + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => G; +export function compose( + gh: (g: G) => H, + fg: (f: F) => G, + ef: (e: E) => F, + de: (d: D) => E, + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => H; +export function compose( + hi: (h: H) => I, + gh: (g: G) => H, + fg: (f: F) => G, + ef: (e: E) => F, + de: (d: D) => E, + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => I; +export function compose( + ij: (i: I) => J, + hi: (h: H) => I, + gh: (g: G) => H, + fg: (f: F) => G, + ef: (e: E) => F, + de: (d: D) => E, + cd: (c: C) => D, + bc: (b: B) => C, + ab: (a: A) => B, +): (a: A) => J; +export function compose(...fns: Array<(input: unknown) => unknown>): (input: unknown) => unknown { + return (value: unknown) => { + let result = value; + for (let i = fns.length - 1; i >= 0; i--) { + result = fns[i]!(result); + } + return result; + }; +} \ No newline at end of file diff --git a/packages/fp/src/function/const-thunks.ts b/packages/fp/src/function/const-thunks.ts new file mode 100644 index 00000000..f0a1740c --- /dev/null +++ b/packages/fp/src/function/const-thunks.ts @@ -0,0 +1,21 @@ +/** + * Constant thunks — functions that ignore their arguments and return a + * fixed value of a primitive type. + * + * Useful in pipes and filter chains where a boolean thunk is required. + */ + +/** Thunk that returns `true`. */ +export const constTrue = (): boolean => true; + +/** Thunk that returns `false`. */ +export const constFalse = (): boolean => false; + +/** Thunk that returns `null`. */ +export const constNull = (): null => null; + +/** Thunk that returns `undefined`. */ +export const constUndefined = (): undefined => undefined; + +/** Thunk that returns `void`. */ +export const constVoid = (): void => undefined; \ No newline at end of file diff --git a/packages/fp/src/function/constant.ts b/packages/fp/src/function/constant.ts new file mode 100644 index 00000000..35da5e38 --- /dev/null +++ b/packages/fp/src/function/constant.ts @@ -0,0 +1,6 @@ +/** + * constant — wraps a value into a function that ignores its argument. + */ +export function constant(value: A): (_arg: B) => A { + return (_arg: B) => value; +} diff --git a/packages/fp/src/function/endomorphism.ts b/packages/fp/src/function/endomorphism.ts new file mode 100644 index 00000000..3a79dbba --- /dev/null +++ b/packages/fp/src/function/endomorphism.ts @@ -0,0 +1,4 @@ +/** + * Endomorphism — a function from `A` to itself. + */ +export type Endomorphism = (a: A) => A; \ No newline at end of file diff --git a/packages/fp/src/function/flip.ts b/packages/fp/src/function/flip.ts new file mode 100644 index 00000000..b13e246f --- /dev/null +++ b/packages/fp/src/function/flip.ts @@ -0,0 +1,8 @@ +/** + * flip — swaps the first two arguments of a binary function. + * + * `flip(f)(a, b)` is equivalent to `f(b, a)`. + */ +export function flip(fn: (a: A, b: B) => C): (b: B, a: A) => C { + return (b: B, a: A) => fn(a, b); +} diff --git a/packages/fp/src/function/flow.ts b/packages/fp/src/function/flow.ts new file mode 100644 index 00000000..0cb323a2 --- /dev/null +++ b/packages/fp/src/function/flow.ts @@ -0,0 +1,77 @@ +/** + * flow — left-to-right function composition that returns a function. + * + * `flow(f1, f2, f3)` returns `(value) => f3(f2(f1(value)))`. + * + * The overloads below cover the documented arities (1 through 9). + * + * @see docs/internal/product/features/function-utilities.md + */ + +export function flow(ab: (a: A) => B): (a: A) => B; +export function flow(ab: (a: A) => B, bc: (b: B) => C): (a: A) => C; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, +): (a: A) => D; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, +): (a: A) => E; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, +): (a: A) => F; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, +): (a: A) => G; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, +): (a: A) => H; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, +): (a: A) => I; +export function flow( + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, + ij: (i: I) => J, +): (a: A) => J; +export function flow(...fns: Array<(input: unknown) => unknown>): (input: unknown) => unknown { + return (value: unknown) => { + let result = value; + for (const fn of fns) { + result = fn(result); + } + return result; + }; +} diff --git a/packages/fp/src/function/function-n.ts b/packages/fp/src/function/function-n.ts new file mode 100644 index 00000000..12fc6db1 --- /dev/null +++ b/packages/fp/src/function/function-n.ts @@ -0,0 +1,10 @@ +/** + * FunctionN — an N-ary function from a tuple of arguments to a value. + * + * `@since 2.0.0` in fp-ts. + * + * @example + * type Sum = FunctionN<[number, number], number>; + * const sum: Sum = (a, b) => a + b; + */ +export type FunctionN, B> = (...args: A) => B; \ No newline at end of file diff --git a/packages/fp/src/function/identity.ts b/packages/fp/src/function/identity.ts new file mode 100644 index 00000000..3955e2d2 --- /dev/null +++ b/packages/fp/src/function/identity.ts @@ -0,0 +1,6 @@ +/** + * identity — the identity function. Returns its argument unchanged. + */ +export function identity(value: A): A { + return value; +} diff --git a/packages/fp/src/function/index.ts b/packages/fp/src/function/index.ts new file mode 100644 index 00000000..c9d4309c --- /dev/null +++ b/packages/fp/src/function/index.ts @@ -0,0 +1,20 @@ +/** + * Function utilities — public module exports. + */ + +export { pipe } from './pipe.js'; +export { flow } from './flow.js'; +export { compose } from './compose.js'; +export { identity } from './identity.js'; +export { constant } from './constant.js'; +export { flip } from './flip.js'; +export { tupled } from './tupled.js'; +export { untupled } from './untupled.js'; +export { tuple } from './tuple.js'; +export { not, and, or } from './predicate.js'; +export { constTrue, constFalse, constNull, constUndefined, constVoid } from './const-thunks.js'; + +export type { Lazy } from './lazy.js'; +export type { Predicate, Refinement } from './predicate.js'; +export type { Endomorphism } from './endomorphism.js'; +export type { FunctionN } from './function-n.js'; \ No newline at end of file diff --git a/packages/fp/src/function/lazy.ts b/packages/fp/src/function/lazy.ts new file mode 100644 index 00000000..f9470807 --- /dev/null +++ b/packages/fp/src/function/lazy.ts @@ -0,0 +1,6 @@ +/** + * Lazy — a thunk. A function that takes no arguments and returns a value. + * + * Used to defer computation: `Lazy = () => A`. + */ +export type Lazy = () => A; \ No newline at end of file diff --git a/packages/fp/src/function/pipe.ts b/packages/fp/src/function/pipe.ts new file mode 100644 index 00000000..9a37816e --- /dev/null +++ b/packages/fp/src/function/pipe.ts @@ -0,0 +1,85 @@ +/** + * pipe — left-to-right function composition with a starting value. + * + * `pipe(value, f1, f2, f3)` is equivalent to `f3(f2(f1(value)))`. + * + * The overloads below cover the documented arities (1 through 9). + * Beyond that, the function accepts any number of functions and + * returns `unknown`; the caller is responsible for the wider shape. + * + * @see docs/internal/product/features/function-utilities.md + */ + +export function pipe(value: A): A; +export function pipe(value: A, ab: (a: A) => B): B; +export function pipe(value: A, ab: (a: A) => B, bc: (b: B) => C): C; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, +): D; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, +): E; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, +): F; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, +): G; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, +): H; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, +): I; +export function pipe( + value: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, + ij: (i: I) => J, +): J; +export function pipe(value: unknown, ...fns: Array<(input: unknown) => unknown>): unknown { + let result = value; + for (const fn of fns) { + result = fn(result); + } + return result; +} diff --git a/packages/fp/src/function/predicate.ts b/packages/fp/src/function/predicate.ts new file mode 100644 index 00000000..6be4a240 --- /dev/null +++ b/packages/fp/src/function/predicate.ts @@ -0,0 +1,36 @@ +/** + * Predicate — a function from `A` to `boolean`. + */ +export type Predicate = (a: A) => boolean; + +/** + * Refinement — a `Predicate` that narrows its argument to `B`. + * + * Behaves as a type guard: `(a: A) => a is B`. + */ +export type Refinement = (a: A) => a is B; + +/** + * not — negates a `Predicate`. + */ +export function not(predicate: Predicate): Predicate { + return (a: A) => !predicate(a); +} + +/** + * and — short-circuit logical AND of two predicates. + * + * Equivalent to `(a) => left(a) && right(a)`. + */ +export function and(left: Predicate, right: Predicate): Predicate { + return (a: A) => left(a) && right(a); +} + +/** + * or — short-circuit logical OR of two predicates. + * + * Equivalent to `(a) => left(a) || right(a)`. + */ +export function or(left: Predicate, right: Predicate): Predicate { + return (a: A) => left(a) || right(a); +} \ No newline at end of file diff --git a/packages/fp/src/function/tuple.ts b/packages/fp/src/function/tuple.ts new file mode 100644 index 00000000..6b49687e --- /dev/null +++ b/packages/fp/src/function/tuple.ts @@ -0,0 +1,12 @@ +/** + * tuple — typed identity function for tuple literals. + * + * Forces TypeScript to infer a tuple type (with literal narrowing) instead + * of widening to an array. Equivalent to `as const` but ergonomic. + * + * @example + * const point = tuple(1, 2); // readonly [1, 2] instead of number[] + */ +export function tuple>(...t: T): T { + return t; +} \ No newline at end of file diff --git a/packages/fp/src/function/tupled.ts b/packages/fp/src/function/tupled.ts new file mode 100644 index 00000000..475cfa8f --- /dev/null +++ b/packages/fp/src/function/tupled.ts @@ -0,0 +1,11 @@ +/** + * tupled — converts a function whose first argument is a tuple into a + * function that takes the tuple as a single argument. + * + * `tupled(f)([a, b])` is equivalent to `f(a, b)`. + */ +export function tupled( + fn: (...args: A) => B, +): (args: A) => B { + return (args: A) => fn(...args); +} diff --git a/packages/fp/src/function/untupled.ts b/packages/fp/src/function/untupled.ts new file mode 100644 index 00000000..b3e846c3 --- /dev/null +++ b/packages/fp/src/function/untupled.ts @@ -0,0 +1,12 @@ +/** + * untupled — inverse of `tupled`. Converts a function that takes a + * tuple into a function that takes the tuple elements as positional + * arguments. + * + * `untupled(f)(a, b)` is equivalent to `f([a, b])`. + */ +export function untupled( + fn: (args: A) => B, +): (...args: A) => B { + return (...args: A) => fn(args); +} diff --git a/packages/fp/src/index.ts b/packages/fp/src/index.ts index 8377a676..9c4d5dbf 100644 --- a/packages/fp/src/index.ts +++ b/packages/fp/src/index.ts @@ -5,20 +5,104 @@ */ // Result exports -export type { Ok, Err, Result } from './result/types.js'; -export { ok, err } from './result/constants.js'; +export type { + Ok, + Err, + Result, + UnhandledException, + AttemptConfig, + Attempt, + NormalizedError, + RetryConfig, + DelayStrategy, + ErrorReporter, + ErrorContext, + ReportableError, + ErrorClassification, + ClassificationRule, + ErrorConstructor, +} from "./result/types.js"; +export { + ok, + err, + fromThrowable, + fromAsyncThrowable, +} from "./result/constants.js"; +export { + map, + flatMap, + mapError, + filter, + tap, + tapAsync, + flatMapAsync, + match, + fold, + getOrElse, + getOrThrow, + getOrNull, + getOrUndefined, + toMaybe, + toOption, + isOk, + isErr, +} from "./result/functions.js"; + +export { attempt, withReporting, classifyError } from "./result/index.js"; // Maybe exports -export type { Some, None, Maybe } from './maybe/types.js'; -export { some, none, maybe } from './maybe/constants.js'; +export type { Some, None, Maybe } from "./maybe/types.js"; +export { some, none, maybe } from "./maybe/constants.js"; +export { + map as mapMaybe, + flatMap as flatMapMaybe, + filter as filterMaybe, + filterMap, + tap as tapMaybe, + tapAsync as tapAsyncMaybe, + match as matchMaybe, + fold as foldMaybe, + getOrElse as getOrElseMaybe, + getOrThrow as getOrThrowMaybe, + getOrNull as getOrNullMaybe, + getOrUndefined as getOrUndefinedMaybe, + get as getMaybe, + toResult, + toArray, + toIterable, + isSome, + isNone, +} from "./maybe/functions.js"; // Unit exports -export type { Unit } from './unit/types.js'; -export { unit, isUnit } from './unit/constants.js'; +export type { Unit } from "./unit/types.js"; +export { unit, isUnit } from "./unit/constants.js"; + +// Function utilities +export { + pipe, + flow, + compose, + identity, + constant, + flip, + tupled, + untupled, + tuple, + not, + and, + or, + constTrue, + constFalse, + constNull, + constUndefined, + constVoid, +} from "./function/index.js"; +export type { Lazy, Predicate, Refinement, Endomorphism, FunctionN } from "./function/index.js"; // Type utilities -export { isResult, isMaybe } from './types.js'; -export type { OkType, ErrType, SomeType } from './types.js'; +export { isResult, isMaybe } from "./types.js"; +export type { OkType, ErrType, SomeType } from "./types.js"; // Forward-looking additions are tracked in the ADR under // docs/engineering/architecture/decisions/, not as inline TODOs. diff --git a/packages/fp/src/maybe/constants.ts b/packages/fp/src/maybe/constants.ts index f7c61778..d18446cb 100644 --- a/packages/fp/src/maybe/constants.ts +++ b/packages/fp/src/maybe/constants.ts @@ -1,16 +1,15 @@ /** - * Maybe constructors: some(), none(), maybe() + * Maybe constructors: some(), none, maybe(). * - * Each factory returns a plain object whose shape satisfies the - * discriminated union `Maybe`. Inside the literal, every method - * binds `this` to the public `Some` / `None` type — that is the - * one annotation that lets short-circuit returns (`return this`, - * `return none`) type-check without chained casts. + * `some()` is a factory function. `none` is the singleton exposed by + * NoneImpl.NONE. `maybe()` lifts a nullable value into a Maybe. + * + * @see rule 0014 — Functions Over Classes for Public API. */ -import type { Some, None, Maybe } from './types.js'; -import type { Result } from '../result/types.js'; -import { ok, err } from '../result/constants.js'; +import type { Maybe, Some, None } from './types.js'; +import { SomeImpl } from './internal/some-impl.js'; +import { NoneImpl } from './internal/none-impl.js'; /** * Create a Some Maybe. @@ -19,131 +18,13 @@ import { ok, err } from '../result/constants.js'; * some(10).map(x => x * 2) // Some(20) */ export function some(value: T): Some { - const someResult: Some = { - _tag: 'Some', - value, - map(this: Some, fn: (value: T) => B): Maybe { - return some(fn(this.value)); - }, - flatMap(this: Some, fn: (value: T) => Maybe): Maybe { - return fn(this.value); - }, - filter(this: Some, predicate: (value: T) => boolean): Maybe { - return predicate(this.value) ? this : none; - }, - filterMap(this: Some, fn: (value: T) => Maybe): Maybe { - return fn(this.value); - }, - tap(this: Some, fn: (value: T) => unknown): Maybe { - fn(this.value); - return this; - }, - tapAsync(this: Some, fn: (value: T) => Promise): Promise> { - return Promise.resolve(fn(this.value)).then(() => this); - }, - match(this: Some, handlers: { some: (value: T) => U; none: () => U }): U { - return handlers.some(this.value); - }, - fold(this: Some, onSome: (value: T) => U, _onNone: () => U): U { - return onSome(this.value); - }, - getOrElse(this: Some, _defaultValue: U): T | U { - return this.value; - }, - getOrThrow(this: Some, _message?: string): T { - return this.value; - }, - getOrNull(this: Some): T | null { - return this.value; - }, - getOrUndefined(this: Some): T | undefined { - return this.value; - }, - get(this: Some, key: K): Maybe { - return maybe(this.value[key]); - }, - toResult(this: Some, _error: E): Result { - return ok(this.value); - }, - toArray(this: Some): T[] { - return [this.value]; - }, - toIterable(this: Some): Iterable { - return [this.value]; - }, - isSome(this: Some): this is Some { - return true; - }, - isNone(this: Some): this is None { - return false; - }, - }; - return someResult; + return new SomeImpl(value); } /** * The None singleton. */ -export const none: None = (() => { - const noneResult: None = { - _tag: 'None', - map(this: None, _fn: (value: never) => B): Maybe { - return this; - }, - flatMap(this: None, _fn: (value: never) => Maybe): Maybe { - return this; - }, - filter(this: None, _predicate: (value: never) => boolean): Maybe { - return this; - }, - filterMap(this: None, _fn: (value: never) => Maybe): Maybe { - return this; - }, - tap(this: None, _fn: (value: never) => unknown): Maybe { - return this; - }, - tapAsync(this: None, _fn: (value: never) => Promise): Promise> { - return Promise.resolve(this); - }, - match(this: None, handlers: { some: (value: never) => U; none: () => U }): U { - return handlers.none(); - }, - fold(this: None, _onSome: (value: never) => U, onNone: () => U): U { - return onNone(); - }, - getOrElse(this: None, defaultValue: U): never | U { - return defaultValue; - }, - getOrThrow(this: None, message?: string): never { - throw new Error(message ?? 'Expected Some but got None'); - }, - getOrNull(this: None): null { - return null; - }, - getOrUndefined(this: None): undefined { - return undefined; - }, - get(this: None, _key: never): Maybe { - return this; - }, - toResult(this: None, error: E): Result { - return err(error); - }, - toArray(this: None): [] { - return []; - }, - toIterable(this: None): Iterable { - return []; - }, - isSome(this: None): this is Some { - return false; - }, - isNone(this: None): this is None { - return true; - }, - }; - return noneResult; -})(); +export const none: None = NoneImpl.NONE; /** * Create Maybe from nullable value. @@ -154,5 +35,5 @@ export const none: None = (() => { * maybe(10) // Some(10) */ export function maybe(value: T | null | undefined): Maybe { - return value != null ? some(value) : (none as Maybe); + return value != null ? some(value) : none; } diff --git a/packages/fp/src/maybe/functions.ts b/packages/fp/src/maybe/functions.ts new file mode 100644 index 00000000..2e994984 --- /dev/null +++ b/packages/fp/src/maybe/functions.ts @@ -0,0 +1,146 @@ +/** + * Pipeable functions for Maybe. + * + * Each pipeable is a pure function with the shape + * `(value) => (operand) => result`. They compose through `pipe`: + * `pipe(value, map(fn), flatMap(chain))`. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Maybe, Some, None } from './types.js'; +import type { Result } from '../result/types.js'; + +/** + * Map over the Some value. Passes through on None. + */ +export function map(fn: (value: T) => B): (maybe: Maybe) => Maybe { + return (m) => m.map(fn); +} + +/** + * Bind through a function that returns a Maybe. Passes through on None. + */ +export function flatMap(fn: (value: T) => Maybe): (maybe: Maybe) => Maybe { + return (m) => m.flatMap(fn); +} + +/** + * Filter on the Some value. Converts to None when the predicate fails. + */ +export function filter(predicate: (value: T) => boolean): (maybe: Maybe) => Maybe { + return (m) => m.filter(predicate); +} + +/** + * Map over the Some value, then flatten. Passes through on None. + */ +export function filterMap(fn: (value: T) => Maybe): (maybe: Maybe) => Maybe { + return (m) => m.filterMap(fn); +} + +/** + * Side effect on the Some value. Passes through unchanged. + */ +export function tap(fn: (value: T) => unknown): (maybe: Maybe) => Maybe { + return (m) => m.tap(fn); +} + +/** + * Side effect on the Some value, async. Passes through unchanged. + */ +export function tapAsync(fn: (value: T) => Promise): (maybe: Maybe) => Promise> { + return (m) => m.tapAsync(fn); +} + +/** + * Pattern matching on Maybe. + */ +export function match(handlers: { + some: (value: T) => U; + none: () => U; +}): (maybe: Maybe) => U { + return (m) => m.match(handlers); +} + +/** + * Fold over Maybe — apply one of two functions. + */ +export function fold(onSome: (value: T) => U, onNone: () => U): (maybe: Maybe) => U { + return (m) => m.fold(onSome, onNone); +} + +/** + * Return the Some value, or a default on None. + */ +export function getOrElse(defaultValue: T): (maybe: Maybe) => T { + return (m) => m.getOrElse(defaultValue); +} + +/** + * Return the Some value, or throw on None. + */ +export function getOrThrow(message?: string): (maybe: Maybe) => T { + return (m) => m.getOrThrow(message); +} + +/** + * Return the Some value, or null on None. + */ +export function getOrNull(): (maybe: Maybe) => T | null { + return (m) => m.getOrNull(); +} + +/** + * Return the Some value, or undefined on None. + */ +export function getOrUndefined(): (maybe: Maybe) => T | undefined { + return (m) => m.getOrUndefined(); +} + +/** + * Project a property of the Some value, returning a Maybe. + * + * Because the inferred `K` cannot be recovered from the type of the + * `Maybe` value alone, the return type is widened to `Maybe`. + * Callers who need a narrower type should use the instance method + * directly: `some(value).get(specificKey)`. + */ +export function get(key: keyof T): (maybe: Maybe) => Maybe { + return (m) => m.get(key); +} + +/** + * Convert to a Result. None becomes Err with the given error. + */ +export function toResult(error: E): (maybe: Maybe) => Result { + return (m) => m.toResult(error); +} + +/** + * Convert to an array. None becomes an empty array. + */ +export function toArray(): (maybe: Maybe) => T[] { + return (m) => m.toArray(); +} + +/** + * Convert to an iterable. None becomes an empty iterable. + */ +export function toIterable(): (maybe: Maybe) => Iterable { + return (m) => m.toIterable(); +} + +/** + * Type predicate: is Some. + */ +export function isSome(m: Maybe): m is Some { + return m.isSome(); +} + +/** + * Type predicate: is None. + */ +export function isNone(m: Maybe): m is None { + return m.isNone(); +} diff --git a/packages/fp/src/maybe/index.ts b/packages/fp/src/maybe/index.ts index 44336800..4c9f3a35 100644 --- a/packages/fp/src/maybe/index.ts +++ b/packages/fp/src/maybe/index.ts @@ -1,7 +1,29 @@ /** - * Maybe module exports + * Maybe module exports. + * + * Public API: types, factories, and pipeable functions. + * @see rule 0014 — Functions Over Classes for Public API. */ export type { Some, None, Maybe } from './types.js'; export { some, none, maybe } from './constants.js'; -// TODO: export instance methods as pipeable functions \ No newline at end of file +export { + map, + flatMap, + filter, + filterMap, + tap, + tapAsync, + match, + fold, + getOrElse, + getOrThrow, + getOrNull, + getOrUndefined, + get, + toResult, + toArray, + toIterable, + isSome, + isNone, +} from './functions.js'; diff --git a/packages/fp/src/maybe/internal/none-impl.ts b/packages/fp/src/maybe/internal/none-impl.ts new file mode 100644 index 00000000..e7651a11 --- /dev/null +++ b/packages/fp/src/maybe/internal/none-impl.ts @@ -0,0 +1,94 @@ +/** + * NoneImpl — internal implementation of the None variant. + * + * Not exported. The single instance is exposed publicly through the + * `none` constant. There is no public constructor — the `NONE` static + * is the only NoneImpl that ever exists. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Result } from '../../result/types.js'; +import { err } from '../../result/constants.js'; +import type { Maybe } from '../types.js'; +import type { SomeImpl } from './some-impl.js'; + +export class NoneImpl { + readonly _tag = 'None' as const; + + static readonly NONE = new NoneImpl(); + + private constructor() {} + + map(_fn: (value: never) => B): Maybe { + return this; + } + + flatMap(_fn: (value: never) => Maybe): Maybe { + return this; + } + + filter(_predicate: (value: never) => boolean): Maybe { + return this; + } + + filterMap(_fn: (value: never) => Maybe): Maybe { + return this; + } + + tap(_fn: (value: never) => unknown): Maybe { + return this; + } + + tapAsync(_fn: (value: never) => Promise): Promise> { + return Promise.resolve(this); + } + + match(handlers: { some: (value: never) => U; none: () => U }): U { + return handlers.none(); + } + + fold(_onSome: (value: never) => U, onNone: () => U): U { + return onNone(); + } + + getOrElse(defaultValue: U): never | U { + return defaultValue; + } + + getOrThrow(message?: string): never { + throw new Error(message ?? 'Expected Some but got None'); + } + + getOrNull(): null { + return null; + } + + getOrUndefined(): undefined { + return undefined; + } + + get(_key: PropertyKey): Maybe { + return this; + } + + toResult(error: E): Result { + return err(error); + } + + toArray(): [] { + return []; + } + + toIterable(): Iterable { + return []; + } + + isSome(): this is SomeImpl { + return false; + } + + isNone(): this is NoneImpl { + return true; + } +} diff --git a/packages/fp/src/maybe/internal/some-impl.ts b/packages/fp/src/maybe/internal/some-impl.ts new file mode 100644 index 00000000..5ba8f5fe --- /dev/null +++ b/packages/fp/src/maybe/internal/some-impl.ts @@ -0,0 +1,96 @@ +/** + * SomeImpl — internal implementation of the Some variant. + * + * Not exported. The public surface is the `Some` type alias (in + * `./types.ts`) and the `some()` factory (in `../constants.ts`). + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Result } from '../../result/types.js'; +import { ok } from '../../result/constants.js'; +import type { Maybe } from '../types.js'; +import { maybe } from '../constants.js'; +import { NoneImpl } from './none-impl.js'; + +export class SomeImpl { + readonly _tag = 'Some' as const; + readonly value: T; + + constructor(value: T) { + this.value = value; + } + + map(fn: (value: T) => B): Maybe { + return new SomeImpl(fn(this.value)); + } + + flatMap(fn: (value: T) => Maybe): Maybe { + return fn(this.value); + } + + filter(predicate: (value: T) => boolean): Maybe { + return predicate(this.value) ? this : NoneImpl.NONE; + } + + filterMap(fn: (value: T) => Maybe): Maybe { + return fn(this.value); + } + + tap(fn: (value: T) => unknown): Maybe { + fn(this.value); + return this; + } + + tapAsync(fn: (value: T) => Promise): Promise> { + return Promise.resolve(fn(this.value)).then(() => this); + } + + match(handlers: { some: (value: T) => U; none: () => U }): U { + return handlers.some(this.value); + } + + fold(onSome: (value: T) => U, _onNone: () => U): U { + return onSome(this.value); + } + + getOrElse(_defaultValue: U): T | U { + return this.value; + } + + getOrThrow(_message?: string): T { + return this.value; + } + + getOrNull(): T | null { + return this.value; + } + + getOrUndefined(): T | undefined { + return this.value; + } + + get(key: K): Maybe { + return maybe(this.value[key]); + } + + toResult(_error: E): Result { + return ok(this.value); + } + + toArray(): T[] { + return [this.value]; + } + + toIterable(): Iterable { + return [this.value]; + } + + isSome(): this is SomeImpl { + return true; + } + + isNone(): this is NoneImpl { + return false; + } +} diff --git a/packages/fp/src/maybe/types.ts b/packages/fp/src/maybe/types.ts index b8f8ef72..8954f60a 100644 --- a/packages/fp/src/maybe/types.ts +++ b/packages/fp/src/maybe/types.ts @@ -1,61 +1,28 @@ -import type { Result } from '../result/types.js'; - /** - * Some variant of Maybe - represents a present value + * Maybe — public type contract. + * + * The class implementations live in `./internal/`. They are not exported. + * The public types are `type` aliases (rule 0012) pointing at the + * internal classes. + * + * @see rule 0014 — Functions Over Classes for Public API. + * @see rule 0012 — Prefer `type` Over `interface`. */ -export interface Some { - readonly _tag: 'Some'; - readonly value: T; - // Instance methods - map(fn: (value: T) => B): Maybe; - flatMap(fn: (value: T) => Maybe): Maybe; - filter(predicate: (value: T) => boolean): Maybe; - filterMap(fn: (value: T) => Maybe): Maybe; - tap(fn: (value: T) => unknown): Maybe; - tapAsync(fn: (value: T) => Promise): Promise>; - match(handlers: { some: (value: T) => U; none: () => U }): U; - fold(onSome: (value: T) => U, _onNone: () => U): U; - getOrElse(_defaultValue: T): T; - getOrThrow(_message?: string): T; - getOrNull(): T | null; - getOrUndefined(): T | undefined; - get(key: K): Maybe; - toResult(_error: E): Result; - toArray(): T[]; - toIterable(): Iterable; - isSome(): this is Some; - isNone(): this is None; -} +import type { SomeImpl } from './internal/some-impl.js'; +import type { NoneImpl } from './internal/none-impl.js'; /** - * None variant of Maybe - represents an absent value + * Some variant of Maybe — represents a present value. */ -export interface None { - readonly _tag: 'None'; +export type Some = SomeImpl; - // Instance methods - map(_fn: (value: never) => B): Maybe; - flatMap(_fn: (value: never) => Maybe): Maybe; - filter(_predicate: (value: never) => boolean): Maybe; - filterMap(_fn: (value: never) => Maybe): Maybe; - tap(_fn: (value: never) => unknown): Maybe; - tapAsync(_fn: (value: never) => Promise): Promise>; - match(handlers: { some: (value: never) => U; none: () => U }): U; - fold(_onSome: (value: never) => U, onNone: () => U): U; - getOrElse(defaultValue: T): T; - getOrThrow(message?: string): never; - getOrNull(): null; - getOrUndefined(): undefined; - get(_key: never): Maybe; - toResult(error: E): Result; - toArray(): []; - toIterable(): Iterable; - isSome(): this is Some; - isNone(): this is None; -} +/** + * None variant of Maybe — represents an absent value. + */ +export type None = NoneImpl; /** - * Discriminated union of Some and None + * Discriminated union of Some and None. */ -export type Maybe = Some | None; \ No newline at end of file +export type Maybe = Some | None; diff --git a/packages/fp/src/result/attempt.ts b/packages/fp/src/result/attempt.ts new file mode 100644 index 00000000..28e173d4 --- /dev/null +++ b/packages/fp/src/result/attempt.ts @@ -0,0 +1,42 @@ +/** + * attempt — higher-level wrapper that captures thrown values into a + * {@link Result}. + * + * Returns an {@link Attempt} exposing two methods: + * + * - `execute()` returns `Result`; the error is the + * original thrown value, optionally run through the caller-supplied + * `normalize` function. + * - `clientSafe()` returns `Result`; the error is + * always coerced into a {@link NormalizedError} suitable for an + * HTTP response. + * + * Construction is lazy. `attempt()` does not invoke `onSuccess`; + * the wrapped operation runs only when `execute()` or `clientSafe()` + * is called. `config.retry` is consulted for a single re-attempt + * when `shouldRetry(cause)` returns `true`. A retry loop is + * intentionally out of scope here — the `DelayStrategy` type ships + * for forward compatibility, but no delay calculator is implemented + * yet. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Attempt, AttemptConfig } from './types.js'; +import { AttemptImpl } from './internal/attempt-impl.js'; + +/** + * Create a configured {@link Attempt}. + * + * @example + * const getConfig = attempt({ + * onSuccess: () => fetch('/api/config').then((r) => r.json()), + * normalize: (e) => (e instanceof Error ? e.message : 'unknown'), + * }); + * + * const result = await getConfig.execute(); + * const safe = await getConfig.clientSafe(); + */ +export function attempt(config: AttemptConfig): Attempt { + return new AttemptImpl(config); +} diff --git a/packages/fp/src/result/classify.ts b/packages/fp/src/result/classify.ts new file mode 100644 index 00000000..57228845 --- /dev/null +++ b/packages/fp/src/result/classify.ts @@ -0,0 +1,38 @@ +/** + * classifyError — match a thrown value against a list of rules and + * return a classification for retry decisions. + * + * The default for an unknown error is `'non-retryable'` — the safer + * choice. A caller that wants the opposite should add a final + * catch-all rule. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { ErrorClassification, ClassificationRule } from './types.js'; + +/** + * Classify an error against a list of {@link ClassificationRule} + * entries. Returns the classification of the first matching rule, or + * `'non-retryable'` if the value is not an `Error` or no rule matches. + * + * @example + * class NetworkError extends Error {} + * class TimeoutError extends Error {} + * + * const kind = classifyError(err, [ + * { error: NetworkError, classification: 'retryable' }, + * { error: TimeoutError, classification: 'retryable' }, + * ]); + * // kind === 'retryable' if err is a NetworkError or TimeoutError + */ +export function classifyError( + e: unknown, + rules: ClassificationRule[], +): ErrorClassification { + if (!(e instanceof Error)) return 'non-retryable'; + for (const rule of rules) { + if (e instanceof rule.error) return rule.classification; + } + return 'non-retryable'; +} diff --git a/packages/fp/src/result/constants.ts b/packages/fp/src/result/constants.ts index 2d1ebe15..2d2f9c4c 100644 --- a/packages/fp/src/result/constants.ts +++ b/packages/fp/src/result/constants.ts @@ -1,16 +1,20 @@ /** - * Result constructors: ok(), err() + * Result constructors: ok(), err(), fromThrowable(), + * fromAsyncThrowable(). * - * Each factory returns a plain object whose shape satisfies the - * discriminated union `Result`. Inside the literal, every method - * binds `this` to the public `Ok` / `Err` type — that is the - * one annotation that lets short-circuit returns (`return this`) - * type-check without chained casts. + * `ok` and `err` are the only public entry points into the + * `OkImpl` / `ErrImpl` classes. `fromThrowable` and + * `fromAsyncThrowable` are thin wrappers that catch thrown values + * and surface them as `Err`. + * + * @see rule 0014 — Functions Over Classes for Public API. */ -import type { Ok, Err, Result } from './types.js'; -import type { Maybe } from '../maybe/types.js'; -import { some, none } from '../maybe/constants.js'; +import type { Ok, Err } from './types.js'; +import { OkImpl } from './internal/ok-impl.js'; +import { ErrImpl } from './internal/err-impl.js'; + +export { fromThrowable, fromAsyncThrowable } from './wrapping.js'; /** * Create an Ok result. @@ -19,75 +23,7 @@ import { some, none } from '../maybe/constants.js'; * ok(10).map(x => x * 2) // Ok(20) */ export function ok(value: T): Ok { - // The first generic is T (the value), the second is E (the error). - // `Ok` is the default; consumers can widen E by annotating - // their factories or chain `.filter(..., fn)` to produce a wider E. - const okResult: Ok = { - _tag: 'Ok', - value, - map(this: Ok, fn: (value: T) => B): Result { - return ok(fn(this.value)); - }, - flatMap(this: Ok, fn: (value: T) => Result): Result { - return fn(this.value); - }, - mapError(this: Ok, _fn: (error: never) => E2): Result { - return this as unknown as Result; - }, - filter( - this: Ok, - predicate: (value: T) => boolean, - errorFn?: (value: T) => E, - ): Result { - if (predicate(this.value)) return this; - if (errorFn) return err(errorFn(this.value)); - return this; - }, - tap(this: Ok, fn: (value: T) => unknown): Result { - fn(this.value); - return this; - }, - tapAsync(this: Ok, fn: (value: T) => Promise): Promise> { - return Promise.resolve(fn(this.value)).then(() => this); - }, - flatMapAsync( - this: Ok, - fn: (value: T) => Promise>, - ): Promise> { - return Promise.resolve(fn(this.value)); - }, - match(this: Ok, handlers: { ok: (value: T) => U; err: (error: E) => U }): U { - return handlers.ok(this.value); - }, - fold(this: Ok, onOk: (value: T) => U, _onErr: (error: E) => U): U { - return onOk(this.value); - }, - getOrElse(this: Ok, _defaultValue: T): T { - return this.value; - }, - getOrThrow(this: Ok, _message?: string): T { - return this.value; - }, - getOrNull(this: Ok): T | null { - return this.value; - }, - getOrUndefined(this: Ok): T | undefined { - return this.value; - }, - toMaybe(this: Ok): Maybe { - return some(this.value); - }, - toOption(this: Ok): Maybe { - return some(this.value); - }, - isOk(this: Ok): this is Ok { - return true; - }, - isErr(this: Ok): this is Err { - return false; - }, - }; - return okResult; + return new OkImpl(value); } /** @@ -97,70 +33,5 @@ export function ok(value: T): Ok { * err('error').map(x => x * 2) // Err('error') */ export function err(error: E): Err { - const errResult: Err = { - _tag: 'Err', - error, - map(this: Err, _fn: (value: never) => B): Result { - return this as unknown as Result; - }, - flatMap(this: Err, _fn: (value: never) => Result): Result { - return this as unknown as Result; - }, - mapError(this: Err, fn: (error: E) => E2): Err { - return err(fn(this.error)); - }, - filter( - this: Err, - _predicate: (value: never) => boolean, - _errorFn?: (value: never) => E, - ): Err { - return this; - }, - tap(this: Err, _fn: (value: never) => unknown): Err { - return this; - }, - tapAsync(this: Err, _fn: (value: never) => Promise): Promise> { - return Promise.resolve(this); - }, - flatMapAsync( - this: Err, - _fn: (value: never) => Promise>, - ): Promise> { - // Required because Err structurally satisfies Result - // by widening T to the union, but the compiler does not infer it - // across two different generic type parameters without classes. - return Promise.resolve(this as unknown as Err); - }, - match(this: Err, handlers: { ok: (value: never) => U; err: (error: E) => U }): U { - return handlers.err(this.error); - }, - fold(this: Err, _onOk: (value: never) => U, onErr: (error: E) => U): U { - return onErr(this.error); - }, - getOrElse(this: Err, defaultValue: U): T | U { - return defaultValue; - }, - getOrThrow(this: Err, message?: string): never { - throw new Error(message ?? String(this.error)); - }, - getOrNull(this: Err): null { - return null; - }, - getOrUndefined(this: Err): undefined { - return undefined; - }, - toMaybe(this: Err): Maybe { - return none as unknown as Maybe; - }, - toOption(this: Err): Maybe { - return none as unknown as Maybe; - }, - isOk(this: Err): this is Ok { - return false; - }, - isErr(this: Err): this is Err { - return true; - }, - }; - return errResult; -} \ No newline at end of file + return new ErrImpl(error); +} diff --git a/packages/fp/src/result/functions.ts b/packages/fp/src/result/functions.ts new file mode 100644 index 00000000..2c02908e --- /dev/null +++ b/packages/fp/src/result/functions.ts @@ -0,0 +1,155 @@ +/** + * Pipeable functions for Result. + * + * Each pipeable is a pure function with the shape + * `(value) => (operand) => result`. They compose through `pipe`: + * `pipe(value, map(fn), flatMap(chain))`. + * + * The instance methods on `Ok` and `Err` remain for ergonomics. The + * pipeables are the preferred surface for composition pipelines. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Ok, Err, Result } from './types.js'; +import type { Maybe } from '../maybe/types.js'; + +// ----------------------------------------------------------------------------- +// Synchronous pipeables +// ----------------------------------------------------------------------------- + +/** + * Map over the Ok value. Passes through on Err. + */ +export function map(fn: (value: T) => B): (result: Result) => Result { + return (result) => result.map(fn); +} + +/** + * Bind through a function that returns a Result. Passes through on Err. + */ +export function flatMap( + fn: (value: T) => Result, +): (result: Result) => Result { + return (result) => result.flatMap(fn); +} + +/** + * Map over the Err value. Passes through on Ok. + */ +export function mapError( + fn: (error: E) => E2, +): (result: Result) => Result { + return (result) => result.mapError(fn); +} + +/** + * Filter on the Ok value. Converts to Err when the predicate fails. + */ +export function filter( + predicate: (value: T) => boolean, + errorFn?: (value: T) => E, +): (result: Result) => Result { + return (result) => result.filter(predicate, errorFn); +} + +/** + * Side effect on the Ok value. Passes through unchanged. + */ +export function tap(fn: (value: T) => unknown): (result: Result) => Result { + return (result) => result.tap(fn); +} + +/** + * Side effect on the Ok value, async. Passes through unchanged. + */ +export function tapAsync( + fn: (value: T) => Promise, +): (result: Result) => Promise> { + return (result) => result.tapAsync(fn); +} + +/** + * Bind through a function that returns a Promise. Passes through on Err. + */ +export function flatMapAsync( + fn: (value: T) => Promise>, +): (result: Result) => Promise> { + return (result) => result.flatMapAsync(fn); +} + +/** + * Pattern matching on Result. + */ +export function match(handlers: { + ok: (value: T) => U; + err: (error: E) => U; +}): (result: Result) => U { + return (result) => result.match(handlers); +} + +/** + * Fold over Result — apply one of two functions. + */ +export function fold( + onOk: (value: T) => U, + onErr: (error: E) => U, +): (result: Result) => U { + return (result) => result.fold(onOk, onErr); +} + +/** + * Return the Ok value, or a default on Err. + */ +export function getOrElse(defaultValue: T): (result: Result) => T { + return (result) => result.getOrElse(defaultValue); +} + +/** + * Return the Ok value, or throw on Err. + */ +export function getOrThrow(message?: string): (result: Result) => T { + return (result) => result.getOrThrow(message); +} + +/** + * Return the Ok value, or null on Err. + */ +export function getOrNull(): (result: Result) => T | null { + return (result) => result.getOrNull(); +} + +/** + * Return the Ok value, or undefined on Err. + */ +export function getOrUndefined(): (result: Result) => T | undefined { + return (result) => result.getOrUndefined(); +} + +/** + * Convert to a Maybe. + */ +export function toMaybe(): (result: Result) => Maybe { + return (result) => result.toMaybe(); +} + +/** + * Alias for `toMaybe`. Kept for expressive parity with the instance method. + */ +export function toOption(): (result: Result) => Maybe { + return (result) => result.toOption(); +} + +/** + * Type predicate: is Ok. + */ +export function isOk(result: Result): result is Ok { + return result.isOk(); +} + +/** + * Type predicate: is Err. + */ +export function isErr(result: Result): result is Err { + return result.isErr(); +} diff --git a/packages/fp/src/result/index.ts b/packages/fp/src/result/index.ts index 3538bbf5..7186ba38 100644 --- a/packages/fp/src/result/index.ts +++ b/packages/fp/src/result/index.ts @@ -1,8 +1,52 @@ /** - * Result module exports + * Result module exports. + * + * Public API: types, factories, pipeable functions, and the + * wrapping helpers `fromThrowable` / `fromAsyncThrowable`. + * + * @see rule 0014 — Functions Over Classes for Public API. */ -export type { Ok, Err, Result } from './types.js'; -export { ok, err } from './constants.js'; -// TODO: export instance methods as pipeable functions -// export { map, flatMap, mapError, filter, tap, match, ... } from './functions.js'; \ No newline at end of file +export type { + Ok, + Err, + Result, + UnhandledException, + AttemptConfig, + Attempt, + NormalizedError, + RetryConfig, + DelayStrategy, + ErrorReporter, + ErrorContext, + ReportableError, + ErrorClassification, + ClassificationRule, + ErrorConstructor, +} from './types.js'; + +export { ok, err, fromThrowable, fromAsyncThrowable } from './constants.js'; + +export { + map, + flatMap, + mapError, + filter, + tap, + tapAsync, + flatMapAsync, + match, + fold, + getOrElse, + getOrThrow, + getOrNull, + getOrUndefined, + toMaybe, + toOption, + isOk, + isErr, +} from './functions.js'; + +export { attempt } from './attempt.js'; +export { withReporting } from './reporting.js'; +export { classifyError } from './classify.js'; diff --git a/packages/fp/src/result/internal/attempt-impl.ts b/packages/fp/src/result/internal/attempt-impl.ts new file mode 100644 index 00000000..e495f709 --- /dev/null +++ b/packages/fp/src/result/internal/attempt-impl.ts @@ -0,0 +1,104 @@ +/** + * AttemptImpl — internal implementation of {@link Attempt}. + * + * Not exported. The public surface is the `Attempt` type alias + * (in `../types.ts`) and the `attempt()` factory (in + * `../attempt.ts`). + * + * Holds the {@link AttemptConfig} so that `execute()` and + * `clientSafe()` can capture and run the supplied `onSuccess` on + * demand. Construction is lazy: `attempt()` returns the wrapper + * without invoking `onSuccess`. Each call to `execute()` / + * `clientSafe()` runs `onSuccess` afresh. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import { ok, err } from '../../result/constants.js'; +import type { Result } from '../../result/types.js'; +import type { + Attempt, + AttemptConfig, + NormalizedError, + RetryConfig, +} from '../types.js'; + +/** + * Default {@link NormalizedError} used when `clientSafe()` must hide + * a raw cause behind a generic 500. + */ +const DEFAULT_NORMALIZED: NormalizedError = { + code: 'INTERNAL_ERROR', + message: 'An unexpected error occurred', + status: 500, + public: false, +}; + +/** + * Map a thrown value to a {@link NormalizedError}. + * + * `normalize` runs first if supplied; otherwise the raw cause is + * hidden behind `DEFAULT_NORMALIZED`. If `normalize` returns a value + * that does not already match the `NormalizedError` shape, the + * function falls back to the default. + */ +function toNormalized( + cause: unknown, + normalize: ((e: unknown) => unknown) | undefined, +): NormalizedError { + const raw = normalize ? normalize(cause) : cause; + if ( + raw !== null && + typeof raw === 'object' && + typeof (raw as { code?: unknown }).code === 'string' && + typeof (raw as { message?: unknown }).message === 'string' && + typeof (raw as { status?: unknown }).status === 'number' && + typeof (raw as { public?: unknown }).public === 'boolean' + ) { + return raw as NormalizedError; + } + return DEFAULT_NORMALIZED; +} + +/** + * True when the configured retry policy allows a re-attempt. + * Returns `false` when no retry config is supplied. + */ +function shouldRetry(cause: unknown, retry: RetryConfig | undefined): boolean { + if (!retry || !retry.shouldRetry) return false; + return retry.shouldRetry(cause); +} + +export class AttemptImpl implements Attempt { + private readonly config: AttemptConfig; + + constructor(config: AttemptConfig) { + this.config = config; + } + + async execute(): Promise> { + try { + const value = await this.config.onSuccess(); + return ok(value); + } catch (cause) { + if (shouldRetry(cause, this.config.retry)) { + try { + const value = await this.config.onSuccess(); + return ok(value); + } catch (cause2) { + return err(this.config.normalize ? this.config.normalize(cause2) : cause2); + } + } + return err(this.config.normalize ? this.config.normalize(cause) : cause); + } + } + + async clientSafe(): Promise> { + try { + const value = await this.config.onSuccess(); + return ok(value); + } catch (cause) { + return err(toNormalized(cause, this.config.normalize)); + } + } +} diff --git a/packages/fp/src/result/internal/err-impl.ts b/packages/fp/src/result/internal/err-impl.ts new file mode 100644 index 00000000..68c8ee1a --- /dev/null +++ b/packages/fp/src/result/internal/err-impl.ts @@ -0,0 +1,96 @@ +/** + * ErrImpl — internal implementation of the Err variant. + * + * Not exported. The public surface is the `Err` type alias (in + * `./types.ts`) and the `err()` factory (in `../constants.ts`). + * + * The pass-through methods (`map`, `flatMap`, `filter`, `tap`, `tapAsync`, + * `flatMapAsync`) widen `T` to the new value type because the Err + * variant carries no value — the original `T` is logically + * `never` for the consumer. We expose `T` as a parameter so that the + * discriminated union `Ok | Err` narrows consistently. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Result } from '../types.js'; +import type { Maybe } from '../../maybe/types.js'; +import { none } from '../../maybe/constants.js'; +import { OkImpl } from './ok-impl.js'; + +export class ErrImpl { + readonly _tag = 'Err' as const; + readonly error: E; + + constructor(error: E) { + this.error = error; + } + + map(_fn: (value: never) => B): Result { + return this as unknown as ErrImpl; + } + + flatMap(_fn: (value: never) => Result): Result { + return this as unknown as ErrImpl; + } + + mapError(fn: (error: E) => E2): ErrImpl { + return new ErrImpl(fn(this.error)); + } + + filter(_predicate: (value: never) => boolean, _errorFn?: (value: never) => E): ErrImpl { + return this; + } + + tap(_fn: (value: never) => unknown): ErrImpl { + return this; + } + + tapAsync(_fn: (value: never) => Promise): Promise> { + return Promise.resolve(this); + } + + flatMapAsync(_fn: (value: never) => Promise>): Promise> { + return Promise.resolve(this as unknown as ErrImpl); + } + + match(handlers: { ok: (value: never) => U; err: (error: E) => U }): U { + return handlers.err(this.error); + } + + fold(_onOk: (value: never) => U, onErr: (error: E) => U): U { + return onErr(this.error); + } + + getOrElse(defaultValue: U): T | U { + return defaultValue; + } + + getOrThrow(message?: string): never { + throw new Error(message ?? String(this.error)); + } + + getOrNull(): null { + return null; + } + + getOrUndefined(): undefined { + return undefined; + } + + toMaybe(): Maybe { + return none; + } + + toOption(): Maybe { + return none; + } + + isOk(): this is OkImpl { + return false; + } + + isErr(): this is ErrImpl { + return true; + } +} diff --git a/packages/fp/src/result/internal/ok-impl.ts b/packages/fp/src/result/internal/ok-impl.ts new file mode 100644 index 00000000..9c08fb3b --- /dev/null +++ b/packages/fp/src/result/internal/ok-impl.ts @@ -0,0 +1,98 @@ +/** + * OkImpl — internal implementation of the Ok variant. + * + * Not exported. The public surface is the `Ok` type alias (in + * `./types.ts`) and the `ok()` factory (in `../constants.ts`). + * + * `mapError` and `filter` return `OkImpl` / `Result` from + * an `OkImpl` instance. The widening of `E` is a single cross- + * boundary cast (rule 0008): the runtime shape carries no error, so + * the new error type is purely nominal. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import type { Result } from '../types.js'; +import type { Maybe } from '../../maybe/types.js'; +import { some } from '../../maybe/constants.js'; +import { ErrImpl } from './err-impl.js'; + +export class OkImpl { + readonly _tag = 'Ok' as const; + readonly value: T; + + constructor(value: T) { + this.value = value; + } + + map(fn: (value: T) => B): Result { + return new OkImpl(fn(this.value)); + } + + flatMap(fn: (value: T) => Result): Result { + return fn(this.value); + } + + mapError(_fn: (error: never) => E2): OkImpl { + return this as unknown as OkImpl; + } + + filter(predicate: (value: T) => boolean, errorFn?: (value: T) => E): Result { + if (predicate(this.value)) return this; + if (errorFn) return new ErrImpl(errorFn(this.value)); + return this; + } + + tap(fn: (value: T) => unknown): OkImpl { + fn(this.value); + return this; + } + + tapAsync(fn: (value: T) => Promise): Promise> { + return Promise.resolve(fn(this.value)).then(() => this); + } + + flatMapAsync(fn: (value: T) => Promise>): Promise> { + return Promise.resolve(fn(this.value)); + } + + match(handlers: { ok: (value: T) => U; err: (error: E) => U }): U { + return handlers.ok(this.value); + } + + fold(onOk: (value: T) => U, _onErr: (error: E) => U): U { + return onOk(this.value); + } + + getOrElse(_defaultValue: T): T { + return this.value; + } + + getOrThrow(_message?: string): T { + return this.value; + } + + getOrNull(): T | null { + return this.value; + } + + getOrUndefined(): T | undefined { + return this.value; + } + + toMaybe(): Maybe { + return some(this.value); + } + + toOption(): Maybe { + return some(this.value); + } + + isOk(): this is OkImpl { + return true; + } + + isErr(): this is ErrImpl { + return false; + } +} diff --git a/packages/fp/src/result/reporting.ts b/packages/fp/src/result/reporting.ts new file mode 100644 index 00000000..99e85f65 --- /dev/null +++ b/packages/fp/src/result/reporting.ts @@ -0,0 +1,62 @@ +/** + * withReporting — wrap a throwing operation so that any caught + * error is forwarded to a caller-supplied {@link ErrorReporter} and + * the operation's outcome is returned as a `Result`. + * + * The reporter always sees the original thrown value, never the + * wrapper. The `Result` carries a {@link ReportableError} that + * preserves the cause for debugging while exposing a flat `message` + * for callers. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import { ok, err } from './constants.js'; +import type { Result } from './types.js'; +import type { ErrorReporter, ErrorContext, ReportableError } from './types.js'; + +/** + * Wrap a sync or async operation in error reporting. + * + * The reporter is invoked exactly once when the operation throws, + * with the original thrown value and an {@link ErrorContext} carrying + * the current timestamp, the operation name, and any caller-supplied + * metadata. + * + * @example + * const reporter: ErrorReporter = { + * report(e, ctx) { metrics.increment('error', { op: ctx.operation }); }, + * }; + * + * await withReporting( + * () => processPayment(order), + * 'processPayment', + * reporter, + * { orderId: order.id }, + * ); + */ +export async function withReporting( + onSuccess: () => T | Promise, + operationName: string, + reporter: ErrorReporter, + metadata?: Readonly>, +): Promise> { + const context: ErrorContext = { + timestamp: Date.now(), + operation: operationName, + metadata, + }; + try { + const value = await onSuccess(); + return ok(value); + } catch (cause) { + reporter.report(cause, context); + const reported: ReportableError = { + _tag: 'ReportableError', + message: cause instanceof Error ? cause.message : 'Operation failed', + cause, + }; + return err(reported); + } +} diff --git a/packages/fp/src/result/types.ts b/packages/fp/src/result/types.ts index 9b781d11..daf8c0f6 100644 --- a/packages/fp/src/result/types.ts +++ b/packages/fp/src/result/types.ts @@ -1,60 +1,160 @@ /** - * Ok variant of Result - represents a successful computation - */ -export interface Ok { - readonly _tag: 'Ok'; - readonly value: T; - - // Instance methods - map(fn: (value: T) => B): Result; - flatMap(fn: (value: T) => Result): Result; - mapError(_fn: (error: never) => E2): Result; - filter(predicate: (value: T) => boolean, _errorFn?: (value: T) => E): Result; - tap(fn: (value: T) => unknown): Result; - tapAsync(fn: (value: T) => Promise): Promise>; - flatMapAsync(fn: (value: T) => Promise>): Promise>; - match(handlers: { ok: (value: T) => U; err: (error: E) => U }): U; - fold(onOk: (value: T) => U, _onErr: (error: E) => U): U; - getOrElse(_defaultValue: T): T; - getOrThrow(_message?: string): T; - getOrNull(): T | null; - getOrUndefined(): T | undefined; - toMaybe(): Maybe; - toOption(): Maybe; - isOk(): this is Ok; - isErr(): this is Err; -} - -/** - * Err variant of Result - represents a failed computation - */ -export interface Err { - readonly _tag: 'Err'; - readonly error: E; - - // Instance methods - map(_fn: (value: never) => B): Result; - flatMap(_fn: (value: never) => Result): Result; - mapError(fn: (error: E) => E2): Result; - filter(_predicate: (value: never) => boolean, _errorFn?: (value: never) => E): Result; - tap(_fn: (value: never) => unknown): Result; - tapAsync(_fn: (value: never) => Promise): Promise>; - flatMapAsync(_fn: (value: never) => Promise>): Promise>; - match(handlers: { ok: (value: never) => U; err: (error: E) => U }): U; - fold(_onOk: (value: never) => U, onErr: (error: E) => U): U; - getOrElse(defaultValue: T): T; - getOrThrow(message?: string): never; - getOrNull(): null; - getOrUndefined(): undefined; - toMaybe(): Maybe; - toOption(): Maybe; - isOk(): this is Ok; - isErr(): this is Err; -} - -/** - * Discriminated union of Ok and Err + * Result — public type contract. + * + * The class implementations live in `./internal/`. They are not exported. + * The public types are `type` aliases (rule 0012) pointing at the + * internal classes. + * + * @see rule 0014 — Functions Over Classes for Public API. + * @see rule 0012 — Prefer `type` Over `interface`. + */ + +import type { OkImpl } from './internal/ok-impl.js'; +import type { ErrImpl } from './internal/err-impl.js'; + +/** + * Ok variant of Result — represents a successful computation. + */ +export type Ok = OkImpl; + +/** + * Err variant of Result — represents a failed computation. + */ +export type Err = ErrImpl; + +/** + * Discriminated union of Ok and Err. */ export type Result = Ok | Err; -import type { Maybe } from '../maybe/types.js'; +/** + * Wrapper placed in the `error` field of an `Err` when a throwing + * function is wrapped with the thunk-only overload of + * `fromThrowable` / `fromAsyncThrowable` (no `onError` mapper + * supplied). + */ +export interface UnhandledException { + readonly _tag: 'UnhandledException'; + readonly cause: unknown; +} + +/** + * Configuration passed to {@link attempt}. + * + * `retry` is reserved for forward compatibility with a future retry + * helper; the current implementation performs at most one re-attempt + * when `retry.shouldRetry(cause)` returns `true`. + */ +export interface AttemptConfig { + readonly onSuccess: () => T | Promise; + readonly client?: boolean; + readonly retry?: RetryConfig; + readonly normalize?: (e: unknown) => unknown; +} + +/** + * The object returned by {@link attempt}. + * + * - `execute()` returns a {@link Result} carrying the original + * (possibly normalised) error. + * - `clientSafe()` returns a {@link Result} where every error is + * mapped to a {@link NormalizedError} safe for HTTP responses. + */ +export interface Attempt { + execute(): Promise>; + clientSafe(): Promise>; +} + +/** + * Error shape safe for exposing to a public-facing client. + * + * Built by `clientSafe()` from any thrown value. The `public` flag + * distinguishes errors that are intentionally surfaced (4xx) from + * errors that escaped and should be hidden behind a 500. + */ +export interface NormalizedError { + readonly code: string; + readonly message: string; + readonly status: number; + readonly public: boolean; +} + +/** + * Retry configuration. Reserved for forward compatibility with a + * future retry helper. The current implementation only inspects + * `shouldRetry` and performs at most one re-attempt inside + * {@link attempt}. + */ +export interface RetryConfig { + readonly attempts: number; + readonly delay: DelayStrategy; + readonly onRetry?: (error: E, attempt: number) => void; + readonly shouldRetry?: (error: E) => boolean; +} + +/** + * Tagged union describing a delay schedule. Reserved for forward + * compatibility — no delay helper is shipped yet. + */ +export type DelayStrategy = + | { readonly kind: 'exponential'; readonly baseMs: number } + | { readonly kind: 'linear'; readonly baseMs: number } + | { readonly kind: 'constant'; readonly baseMs: number }; + +/** + * Pluggable sink for error events. Used by {@link withReporting}. + */ +export interface ErrorReporter { + report(error: unknown, context: ErrorContext): void; +} + +/** + * Metadata attached to a reported error event. + */ +export interface ErrorContext { + readonly timestamp: number; + readonly operation: string; + readonly metadata?: Readonly>; +} + +/** + * Structured error returned by {@link withReporting} when the + * wrapped operation throws. The original cause is preserved in the + * `cause` field for debugging, while `message` carries a flat string + * safe to render to a caller. + */ +export interface ReportableError { + readonly _tag: 'ReportableError'; + readonly message: string; + readonly cause?: unknown; +} + +/** + * Outcome of {@link classifyError}. `'retryable'` means the caller + * should attempt the operation again; `'non-retryable'` means the + * caller should propagate the error. + */ +export type ErrorClassification = 'retryable' | 'non-retryable'; + +/** + * One entry in the rule list passed to {@link classifyError}. + * + * `error` is matched against the thrown value with `instanceof`. + */ +export interface ClassificationRule { + readonly error: ErrorConstructor; + readonly classification: ErrorClassification; +} + +/** + * TypeScript-friendly `Error` constructor type. Use it for fields + * that name an `Error` subclass by reference (e.g. in + * {@link ClassificationRule}). + * + * The parameter list is `unknown[]` because the runtime never + * instantiates these constructors — it only matches existing + * instances with `instanceof`. `unknown[]` is wider than the + * standard `any[]` and satisfies the project's lint policy without + * weakening the public contract. + */ +export type ErrorConstructor = abstract new (...args: unknown[]) => Error; diff --git a/packages/fp/src/result/wrapping.ts b/packages/fp/src/result/wrapping.ts new file mode 100644 index 00000000..79a9ddc0 --- /dev/null +++ b/packages/fp/src/result/wrapping.ts @@ -0,0 +1,112 @@ +/** + * Wrapping helpers — turn throwing functions into Result-returning + * ones. These are the single source of truth that backs both the + * `Result.fromThrowable` / `Result.fromAsyncThrowable` factories + * and the `try_` / `tryPromise` aliases re-exported from + * `src/try/index.ts`. + * + * The `Result` type is the only reasoning: a thrown value is + * captured into the `Err` variant, and `ok` / `err` are the only + * construction entry points. + * + * @see rule 0014 — Functions Over Classes for Public API. + */ + +import { ok, err } from './constants.js'; +import type { Result } from './types.js'; +import type { UnhandledException } from './types.js'; + +/** + * Wrap a synchronous function that may throw. + * + * Two forms: + * + * - `fromThrowable(thunk)` — captures any thrown value into an + * {@link UnhandledException} carrying the original cause. + * - `fromThrowable({ onSuccess, onError })` — runs `onSuccess` + * inside a `try`/`catch`; thrown values are mapped through + * `onError`. + * + * @example + * const r = fromThrowable(() => JSON.parse(input)); + * // r: Result + * + * @example + * const r = fromThrowable({ + * onSuccess: () => fs.readFileSync(path, 'utf-8'), + * onError: (e) => (e instanceof Error ? e : new Error(String(e))), + * }); + * // r: Result + */ +export function fromThrowable(thunk: () => T): Result; +export function fromThrowable(options: { + readonly onSuccess: () => T; + readonly onError: (cause: unknown) => E; +}): Result; +export function fromThrowable( + arg: (() => T) | { readonly onSuccess: () => T; readonly onError: (cause: unknown) => E }, +): Result | Result { + if (typeof arg === 'function') { + try { + return ok(arg()); + } catch (cause) { + return err({ _tag: 'UnhandledException', cause }); + } + } + const opts = arg; + try { + return ok(opts.onSuccess()); + } catch (cause) { + return err(opts.onError(cause)); + } +} + +/** + * Wrap an asynchronous function that may reject. + * + * Two forms mirror `fromThrowable`: + * + * - `fromAsyncThrowable(thunk)` — rejects (and sync throws) are + * captured into an {@link UnhandledException}. + * - `fromAsyncThrowable({ onSuccess, onError })` — the caller maps + * the cause through `onError`, which may itself be async. + * + * @example + * const r = await fromAsyncThrowable(() => fetch(url).then((res) => res.json())); + * // r: Result + * + * @example + * const r = await fromAsyncThrowable({ + * onSuccess: () => orpc.templates.list(undefined, liveCache), + * onError: (e) => (e instanceof Error ? e : new Error(String(e))), + * }); + * // r: Result + */ +export function fromAsyncThrowable( + thunk: () => Promise, +): Promise>; +export function fromAsyncThrowable(options: { + readonly onSuccess: () => Promise; + readonly onError: (cause: unknown) => E | Promise; +}): Promise>; +export async function fromAsyncThrowable( + arg: + | (() => Promise) + | { readonly onSuccess: () => Promise; readonly onError: (cause: unknown) => E | Promise }, +): Promise | Result> { + if (typeof arg === 'function') { + try { + const value = await arg(); + return ok(value); + } catch (cause) { + return err({ _tag: 'UnhandledException', cause }); + } + } + const opts = arg; + try { + const value = await opts.onSuccess(); + return ok(value); + } catch (cause) { + return err(await opts.onError(cause)); + } +} diff --git a/packages/fp/tests/function/compose.test.ts b/packages/fp/tests/function/compose.test.ts new file mode 100644 index 00000000..a19cdcbb --- /dev/null +++ b/packages/fp/tests/function/compose.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { compose, flow } from '@deessejs/fp'; + +describe('compose', () => { + it('returns a function that applies a single step', () => { + const f = compose((x: number) => x * 2); + expect(f(10)).toBe(20); + }); + + it('composes two functions right-to-left', () => { + // compose(g, f)(x) === g(f(x)) + const f = compose((x: number) => x + 1, (x: number) => x * 2); + expect(f(10)).toBe(21); + }); + + it('composes three functions right-to-left', () => { + const f = compose( + (s: string) => `!${s}!`, + (s: string) => s.toUpperCase(), + (s: string) => s.trim(), + ); + expect(f(' hello ')).toBe('!HELLO!'); + }); + + it('composes through up to nine functions', () => { + const add = (n: number) => (x: number) => x + n; + const f = compose(add(9), add(8), add(7), add(6), add(5), add(4), add(3), add(2), add(1)); + expect(f(0)).toBe(45); + }); + + it('is the mirror of flow', () => { + const add = (n: number) => (x: number) => x + n; + const c = compose(add(1), add(2), add(3)); + const f = flow(add(3), add(2), add(1)); + expect(c(0)).toBe(f(0)); + }); +}); \ No newline at end of file diff --git a/packages/fp/tests/function/const-thunks.test.ts b/packages/fp/tests/function/const-thunks.test.ts new file mode 100644 index 00000000..b5463900 --- /dev/null +++ b/packages/fp/tests/function/const-thunks.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { constTrue, constFalse, constNull, constUndefined, constVoid } from '@deessejs/fp'; + +describe('constTrue', () => { + it('always returns true', () => { + expect(constTrue()).toBe(true); + }); +}); + +describe('constFalse', () => { + it('always returns false', () => { + expect(constFalse()).toBe(false); + }); +}); + +describe('constNull', () => { + it('always returns null', () => { + expect(constNull()).toBe(null); + }); +}); + +describe('constUndefined', () => { + it('always returns undefined', () => { + expect(constUndefined()).toBe(undefined); + }); +}); + +describe('constVoid', () => { + it('always returns undefined (void type)', () => { + expect(constVoid()).toBe(undefined); + }); +}); \ No newline at end of file diff --git a/packages/fp/tests/function/constant.test.ts b/packages/fp/tests/function/constant.test.ts new file mode 100644 index 00000000..a7bdbf32 --- /dev/null +++ b/packages/fp/tests/function/constant.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { constant } from '@deessejs/fp'; + +describe('constant', () => { + it('returns the wrapped value regardless of the argument', () => { + const k = constant(42); + expect(k(0)).toBe(42); + expect(k('whatever')).toBe(42); + expect(k(null)).toBe(42); + }); + + it('wraps an object reference', () => { + const obj = { a: 1 }; + const k = constant(obj); + expect(k('x')).toBe(obj); + }); + + it('returns the same value across many calls', () => { + const k = constant('hello'); + expect(k(1)).toBe('hello'); + expect(k(2)).toBe('hello'); + expect(k(3)).toBe('hello'); + }); +}); diff --git a/packages/fp/tests/function/flip.test.ts b/packages/fp/tests/function/flip.test.ts new file mode 100644 index 00000000..8e3a7169 --- /dev/null +++ b/packages/fp/tests/function/flip.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { flip } from '@deessejs/fp'; + +describe('flip', () => { + it('swaps the first two arguments', () => { + const divide = (a: number, b: number) => a / b; + const flipped = flip(divide); + expect(flipped(2, 10)).toBe(5); + }); + + it('works with string concatenation', () => { + const concat = (a: string, b: string) => `${a}-${b}`; + const flipped = flip(concat); + expect(flipped('world', 'hello')).toBe('hello-world'); + }); + + it('preserves the result type', () => { + const fn = (a: number, b: number) => a + b; + const flipped = flip(fn); + const result: number = flipped(2, 3); + expect(result).toBe(5); + }); +}); diff --git a/packages/fp/tests/function/flow.test.ts b/packages/fp/tests/function/flow.test.ts new file mode 100644 index 00000000..983e4205 --- /dev/null +++ b/packages/fp/tests/function/flow.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { flow } from '@deessejs/fp'; + +describe('flow', () => { + it('returns a function that applies a single step', () => { + const double = flow((x: number) => x * 2); + expect(double(10)).toBe(20); + }); + + it('composes two functions left-to-right', () => { + const fn = flow((x: number) => x + 1, (x: number) => x * 2); + expect(fn(10)).toBe(22); + }); + + it('composes up to nine functions', () => { + const add = (n: number) => (x: number) => x + n; + const fn = flow(add(1), add(2), add(3), add(4), add(5), add(6), add(7), add(8), add(9)); + expect(fn(0)).toBe(45); + }); + + it('returns a reusable function', () => { + const slugify = flow( + (s: string) => s.trim(), + (s: string) => s.toLowerCase(), + (s: string) => s.replace(/\s+/g, '-'), + ); + expect(slugify(' Hello World ')).toBe('hello-world'); + expect(slugify(' Foo Bar Baz ')).toBe('foo-bar-baz'); + }); +}); diff --git a/packages/fp/tests/function/identity.test.ts b/packages/fp/tests/function/identity.test.ts new file mode 100644 index 00000000..aa050f95 --- /dev/null +++ b/packages/fp/tests/function/identity.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { identity } from '@deessejs/fp'; + +describe('identity', () => { + it('returns its argument', () => { + expect(identity(1)).toBe(1); + }); + + it('returns strings unchanged', () => { + expect(identity('hello')).toBe('hello'); + }); + + it('returns the same object reference', () => { + const obj = { a: 1 }; + expect(identity(obj)).toBe(obj); + }); + + it('returns null', () => { + expect(identity(null)).toBe(null); + }); + + it('returns undefined', () => { + expect(identity(undefined)).toBe(undefined); + }); +}); diff --git a/packages/fp/tests/function/lazy.test.ts b/packages/fp/tests/function/lazy.test.ts new file mode 100644 index 00000000..19bbddbc --- /dev/null +++ b/packages/fp/tests/function/lazy.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import type { Lazy } from '@deessejs/fp'; + +describe('Lazy', () => { + it('is a zero-argument function returning a value', () => { + const thunk: Lazy = () => 42; + expect(thunk()).toBe(42); + }); + + it('deferred computation runs at call time', () => { + let ran = 0; + const thunk: Lazy = () => { + ran++; + return ran; + }; + expect(thunk()).toBe(1); + expect(thunk()).toBe(2); + }); +}); \ No newline at end of file diff --git a/packages/fp/tests/function/pipe.test.ts b/packages/fp/tests/function/pipe.test.ts new file mode 100644 index 00000000..62606146 --- /dev/null +++ b/packages/fp/tests/function/pipe.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from 'vitest'; +import { pipe } from '@deessejs/fp'; + +describe('pipe', () => { + it('returns the value when no functions are supplied', () => { + expect(pipe(42)).toBe(42); + }); + + it('applies a single function', () => { + expect(pipe(10, (x: number) => x * 2)).toBe(20); + }); + + it('composes two functions left-to-right', () => { + expect(pipe(10, (x: number) => x + 1, (x: number) => x * 2)).toBe(22); + }); + + it('composes three functions', () => { + expect(pipe(' hello ', (s: string) => s.trim(), (s: string) => s.toUpperCase(), (s: string) => `${s}!`)).toBe( + 'HELLO!', + ); + }); + + it('composes through up to nine functions', () => { + const add = (n: number) => (x: number) => x + n; + const result = pipe(0, add(1), add(2), add(3), add(4), add(5), add(6), add(7), add(8), add(9)); + expect(result).toBe(45); + }); + + it('preserves the empty string and other falsy values', () => { + expect(pipe('', (s: string) => s.length)).toBe(0); + }); +}); diff --git a/packages/fp/tests/function/predicate.test.ts b/packages/fp/tests/function/predicate.test.ts new file mode 100644 index 00000000..b6780063 --- /dev/null +++ b/packages/fp/tests/function/predicate.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { not, and, or, type Predicate, type Refinement } from '@deessejs/fp'; + +describe('Predicate', () => { + it('narrows the parameter type to A', () => { + const isPositive: Predicate = (n: number) => n > 0; + expect(isPositive(1)).toBe(true); + expect(isPositive(-1)).toBe(false); + }); +}); + +describe('Refinement', () => { + it('narrows the parameter type to B in the true branch', () => { + const isString: Refinement = (a: unknown): a is string => typeof a === 'string'; + const value: unknown = 'hello'; + if (isString(value)) { + expect(value.toUpperCase()).toBe('HELLO'); + } else { + throw new Error('expected string'); + } + }); +}); + +describe('not', () => { + it('negates a predicate', () => { + const isPositive = (n: number) => n > 0; + const isNotPositive = not(isPositive); + expect(isNotPositive(1)).toBe(false); + expect(isNotPositive(-1)).toBe(true); + expect(isNotPositive(0)).toBe(true); + }); + + it('returns a Predicate with the same parameter type', () => { + const isLong = (s: string) => s.length > 3; + const isShort = not(isLong); + expect(isShort('hi')).toBe(true); + expect(isShort('hello')).toBe(false); + }); +}); + +describe('and', () => { + it('returns true when both predicates are true', () => { + const isPositive = (n: number) => n > 0; + const isEven = (n: number) => n % 2 === 0; + const isPositiveEven = and(isPositive, isEven); + expect(isPositiveEven(2)).toBe(true); + }); + + it('returns false when the left predicate is false', () => { + const isPositive = (n: number) => n > 0; + const isEven = (n: number) => n % 2 === 0; + const isPositiveEven = and(isPositive, isEven); + expect(isPositiveEven(-2)).toBe(false); + }); + + it('returns false when the right predicate is false', () => { + const isPositive = (n: number) => n > 0; + const isEven = (n: number) => n % 2 === 0; + const isPositiveEven = and(isPositive, isEven); + expect(isPositiveEven(3)).toBe(false); + }); + + it('short-circuits — right predicate is not called when left is false', () => { + let rightCalls = 0; + const isPositive = (_n: number) => false; + const isEven = (_n: number) => { + rightCalls++; + return true; + }; + and(isPositive, isEven)(1); + expect(rightCalls).toBe(0); + }); +}); + +describe('or', () => { + it('returns true when the left predicate is true', () => { + const isPositive = (n: number) => n > 0; + const isZero = (n: number) => n === 0; + const isNonNegative = or(isZero, isPositive); + expect(isNonNegative(0)).toBe(true); + }); + + it('returns true when the right predicate is true', () => { + const isPositive = (n: number) => n > 0; + const isZero = (n: number) => n === 0; + const isNonNegative = or(isZero, isPositive); + expect(isNonNegative(5)).toBe(true); + }); + + it('returns false when both predicates are false', () => { + const isPositive = (n: number) => n > 0; + const isZero = (n: number) => n === 0; + const isNonNegative = or(isZero, isPositive); + expect(isNonNegative(-1)).toBe(false); + }); + + it('short-circuits — right predicate is not called when left is true', () => { + let rightCalls = 0; + const isPositive = (_n: number) => true; + const isEven = (_n: number) => { + rightCalls++; + return true; + }; + or(isPositive, isEven)(1); + expect(rightCalls).toBe(0); + }); +}); \ No newline at end of file diff --git a/packages/fp/tests/function/tuple.test.ts b/packages/fp/tests/function/tuple.test.ts new file mode 100644 index 00000000..4f846d69 --- /dev/null +++ b/packages/fp/tests/function/tuple.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { tuple } from '@deessejs/fp'; + +describe('tuple', () => { + it('returns its arguments as a tuple', () => { + expect(tuple(1, 2, 3)).toEqual([1, 2, 3]); + }); + + it('preserves empty tuple', () => { + expect(tuple()).toEqual([]); + }); + + it('returns the same tuple on a single element', () => { + expect(tuple('a')).toEqual(['a']); + }); +}); \ No newline at end of file diff --git a/packages/fp/tests/function/tupled.test.ts b/packages/fp/tests/function/tupled.test.ts new file mode 100644 index 00000000..afd4c04c --- /dev/null +++ b/packages/fp/tests/function/tupled.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { tupled, untupled } from '@deessejs/fp'; + +describe('tupled', () => { + it('packs positional arguments into a tuple', () => { + const fn = (a: number, b: number) => a + b; + const t = tupled(fn); + expect(t([1, 2])).toBe(3); + }); + + it('works with three arguments', () => { + const fn = (a: number, b: number, c: number) => a + b + c; + const t = tupled(fn); + expect(t([1, 2, 3])).toBe(6); + }); + + it('works with heterogeneous types', () => { + const fn = (a: string, b: number, c: boolean) => `${a}-${b}-${c}`; + const t = tupled(fn); + expect(t(['x', 1, true])).toBe('x-1-true'); + }); +}); + +describe('untupled', () => { + it('unpacks a tuple into positional arguments', () => { + const fn = (pair: readonly [number, number]) => pair[0] + pair[1]; + const u = untupled(fn); + expect(u(1, 2)).toBe(3); + }); + + it('works with three arguments', () => { + const fn = (triple: readonly [number, number, number]) => triple[0] + triple[1] + triple[2]; + const u = untupled(fn); + expect(u(1, 2, 3)).toBe(6); + }); +}); + +describe('tupled / untupled inverses', () => { + it('untupled(tupled(f)) is callable with positional args', () => { + const f = (a: number, b: number) => `${a}:${b}`; + const round = untupled(tupled(f)); + expect(round(1, 2)).toBe('1:2'); + }); + + it('tupled(untupled(f)) is callable with a tuple', () => { + const f = (pair: readonly [number, number]) => pair[0] + pair[1]; + const round = tupled(untupled(f)); + expect(round([1, 2])).toBe(3); + }); +}); diff --git a/packages/fp/src/index.test.ts b/packages/fp/tests/index.test.ts similarity index 97% rename from packages/fp/src/index.test.ts rename to packages/fp/tests/index.test.ts index 9fade6b6..521a3bfa 100644 --- a/packages/fp/src/index.test.ts +++ b/packages/fp/tests/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { ok, err, some, none, maybe, unit, isUnit } from '../src/index.js'; -import type { Result } from '../src/index.js'; +import { ok, err, some, none, maybe, unit, isUnit } from '@deessejs/fp'; +import type { Result } from '@deessejs/fp'; describe('Result', () => { describe('ok', () => { diff --git a/packages/fp/tests/maybe/functions.test.ts b/packages/fp/tests/maybe/functions.test.ts new file mode 100644 index 00000000..6dc795ab --- /dev/null +++ b/packages/fp/tests/maybe/functions.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect } from 'vitest'; +import { + some, + none, + ok, + err, + mapMaybe, + flatMapMaybe, + filterMaybe, + filterMap, + tapMaybe, + tapAsyncMaybe, + matchMaybe, + foldMaybe, + getOrElseMaybe, + getOrThrowMaybe, + getOrNullMaybe, + getOrUndefinedMaybe, + getMaybe, + toResult, + toArray, + toIterable, + isSome, + isNone, +} from '@deessejs/fp'; + +describe('Maybe pipeables', () => { + describe('map', () => { + it('applies the function on Some', () => { + expect(mapMaybe((x: number) => x * 2)(some(10)).getOrNull()).toBe(20); + }); + + it('passes through on None', () => { + expect(mapMaybe((x: number) => x * 2)(none).isNone()).toBe(true); + }); + }); + + describe('flatMap', () => { + it('binds on Some', () => { + expect(flatMapMaybe((x: number) => some(x + 1))(some(10)).getOrNull()).toBe(11); + }); + + it('passes through on None', () => { + expect(flatMapMaybe(() => none)(none).isNone()).toBe(true); + }); + }); + + describe('filter', () => { + it('keeps Some when predicate passes', () => { + expect(filterMaybe((x: number) => x > 5)(some(10)).isSome()).toBe(true); + }); + + it('drops Some when predicate fails', () => { + expect(filterMaybe((x: number) => x > 100)(some(10)).isNone()).toBe(true); + }); + + it('passes through on None', () => { + expect(filterMaybe(() => true)(none).isNone()).toBe(true); + }); + }); + + describe('filterMap', () => { + it('keeps Some', () => { + expect(filterMap((x: number) => some(x + 1))(some(10)).getOrNull()).toBe(11); + }); + + it('transitions to None', () => { + expect(filterMap(() => none)(some(10)).isNone()).toBe(true); + }); + + it('passes through on None', () => { + expect(filterMap(() => some(1))(none).isNone()).toBe(true); + }); + }); + + describe('tap', () => { + it('runs the side effect on Some', () => { + let seen = 0; + tapMaybe((x: number) => { + seen = x; + })(some(10)); + expect(seen).toBe(10); + }); + + it('does not run on None', () => { + let called = false; + tapMaybe(() => { + called = true; + })(none); + expect(called).toBe(false); + }); + }); + + describe('tapAsync', () => { + it('awaits the side effect on Some', async () => { + let seen = 0; + const out = await tapAsyncMaybe(async (x: number) => { + seen = x; + })(some(10)); + expect(seen).toBe(10); + expect(out.isSome()).toBe(true); + }); + + it('passes through on None', async () => { + const out = await tapAsyncMaybe(async () => { + /* never */ + })(none); + expect(out.isNone()).toBe(true); + }); + }); + + describe('match', () => { + it('dispatches to some on Some', () => { + expect( + matchMaybe({ + some: (v) => `s:${v}`, + none: () => 'n', + })(some(10)), + ).toBe('s:10'); + }); + + it('dispatches to none on None', () => { + expect( + matchMaybe({ + some: () => 's', + none: () => 'n', + })(none), + ).toBe('n'); + }); + }); + + describe('fold', () => { + it('dispatches to onSome', () => { + expect( + foldMaybe( + (v: number) => v + 1, + () => 0, + )(some(10)), + ).toBe(11); + }); + + it('dispatches to onNone', () => { + expect( + foldMaybe( + (v: number) => v + 1, + () => 0, + )(none), + ).toBe(0); + }); + }); + + describe('getOrElse', () => { + it('returns the value on Some', () => { + expect(getOrElseMaybe(42)(some(10))).toBe(10); + }); + + it('returns the default on None', () => { + expect(getOrElseMaybe(42)(none)).toBe(42); + }); + }); + + describe('getOrThrow', () => { + it('returns the value on Some', () => { + expect(getOrThrowMaybe('msg')(some(10))).toBe(10); + }); + + it('throws on None without message', () => { + expect(() => getOrThrowMaybe()(none)).toThrow('Expected Some but got None'); + }); + + it('throws on None with message', () => { + expect(() => getOrThrowMaybe('custom')(none)).toThrow('custom'); + }); + }); + + describe('getOrNull / getOrUndefined', () => { + it('returns the value on Some', () => { + expect(getOrNullMaybe()(some(10))).toBe(10); + expect(getOrUndefinedMaybe()(some(10))).toBe(10); + }); + + it('returns null/undefined on None', () => { + expect(getOrNullMaybe()(none)).toBe(null); + expect(getOrUndefinedMaybe()(none)).toBe(undefined); + }); + }); + + describe('get', () => { + it('projects a key on Some', () => { + const obj = { name: 'Alice', age: 30 }; + expect(getMaybe('name')(some(obj)).getOrNull()).toBe('Alice'); + }); + + it('returns None on Some with missing key', () => { + const obj: { name?: string } = {}; + expect(getMaybe('name')(some(obj)).isNone()).toBe(true); + }); + + it('returns None on None', () => { + expect(getMaybe('name')(none).isNone()).toBe(true); + }); + }); + + describe('toResult', () => { + it('produces Ok on Some', () => { + expect(toResult('e')(some(10)).isOk()).toBe(true); + }); + + it('produces Err on None', () => { + const r = toResult('e')(none); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('e'); + }); + }); + + describe('toArray / toIterable', () => { + it('produces a single-element array on Some', () => { + expect(toArray()(some(10))).toEqual([10]); + }); + + it('produces an empty array on None', () => { + expect(toArray()(none)).toEqual([]); + }); + + it('produces an iterable on Some', () => { + const out: number[] = []; + for (const x of toIterable()(some(10))) out.push(x); + expect(out).toEqual([10]); + }); + + it('produces an empty iterable on None', () => { + const out: never[] = []; + for (const x of toIterable()(none)) out.push(x); + expect(out).toEqual([]); + }); + }); + + describe('isSome / isNone', () => { + it('isSome narrows Some', () => { + const m = some(10); + if (isSome(m)) { + expect(m.value).toBe(10); + } else { + throw new Error('expected Some'); + } + }); + + it('isNone narrows None', () => { + const m = none; + if (isNone(m)) { + expect(m._tag).toBe('None'); + } else { + throw new Error('expected None'); + } + }); + }); + + // smoke: other primitives used here are exercised transitively + it('imports ok and err', () => { + expect(ok(1).isOk()).toBe(true); + expect(err('e').isErr()).toBe(true); + }); +}); diff --git a/packages/fp/tests/maybe/none-impl.test.ts b/packages/fp/tests/maybe/none-impl.test.ts new file mode 100644 index 00000000..97b5dcec --- /dev/null +++ b/packages/fp/tests/maybe/none-impl.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { none, maybe, some } from '@deessejs/fp'; + +describe('NoneImpl', () => { + describe('factory', () => { + it('produces a singleton identity', () => { + expect(none).toBe(none); + }); + + it('carries the _tag discriminator', () => { + expect(none._tag).toBe('None'); + }); + }); + + describe('map', () => { + it('passes through with the new type', () => { + expect(none.map((_v: never) => 1).isNone()).toBe(true); + }); + }); + + describe('flatMap', () => { + it('passes through', () => { + expect(none.flatMap((_v: never) => some(1)).isNone()).toBe(true); + }); + }); + + describe('filter', () => { + it('stays None', () => { + expect(none.filter((_v: never) => true).isNone()).toBe(true); + }); + }); + + describe('filterMap', () => { + it('passes through', () => { + expect(none.filterMap((_v: never) => some(1)).isNone()).toBe(true); + }); + }); + + describe('tap', () => { + it('does not invoke the function', () => { + let called = false; + none.tap(() => { + called = true; + }); + expect(called).toBe(false); + expect(none.isNone()).toBe(true); + }); + }); + + describe('tapAsync', () => { + it('returns a resolved None', async () => { + const result = await none.tapAsync(async () => { + /* never called */ + }); + expect(result.isNone()).toBe(true); + }); + }); + + describe('match', () => { + it('dispatches to none()', () => { + expect( + none.match({ + some: () => 's', + none: () => 'n', + }), + ).toBe('n'); + }); + }); + + describe('fold', () => { + it('dispatches to onNone', () => { + expect( + none.fold( + () => 's', + () => 'n', + ), + ).toBe('n'); + }); + }); + + describe('getOrElse', () => { + it('returns the default', () => { + expect(none.getOrElse(42)).toBe(42); + }); + }); + + describe('getOrThrow', () => { + it('throws with default message when no message is supplied', () => { + expect(() => none.getOrThrow()).toThrow('Expected Some but got None'); + }); + + it('throws with the supplied message', () => { + expect(() => none.getOrThrow('custom')).toThrow('custom'); + }); + }); + + describe('getOrNull / getOrUndefined', () => { + it('returns null', () => { + expect(none.getOrNull()).toBe(null); + }); + + it('returns undefined', () => { + expect(none.getOrUndefined()).toBe(undefined); + }); + }); + + describe('get', () => { + it('returns None for any key', () => { + expect(none.get('a').isNone()).toBe(true); + }); + }); + + describe('toResult', () => { + it('produces Err with the given error', () => { + const r = none.toResult('err'); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('err'); + }); + }); + + describe('toArray / toIterable', () => { + it('returns an empty array', () => { + expect(none.toArray()).toEqual([]); + }); + + it('returns an empty iterable', () => { + const out: never[] = []; + for (const x of none.toIterable()) out.push(x); + expect(out).toEqual([]); + }); + }); + + describe('isSome / isNone', () => { + it('isSome is false', () => { + expect(none.isSome()).toBe(false); + }); + + it('isNone is true', () => { + expect(none.isNone()).toBe(true); + }); + }); + + describe('maybe() factory', () => { + it('returns None for null', () => { + expect(maybe(null).isNone()).toBe(true); + }); + + it('returns None for undefined', () => { + expect(maybe(undefined).isNone()).toBe(true); + }); + + it('returns Some for a value', () => { + expect(maybe(0).isSome()).toBe(true); + }); + }); +}); diff --git a/packages/fp/tests/maybe/some-impl.test.ts b/packages/fp/tests/maybe/some-impl.test.ts new file mode 100644 index 00000000..71fb4a99 --- /dev/null +++ b/packages/fp/tests/maybe/some-impl.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from 'vitest'; +import { some, none, ok, err } from '@deessejs/fp'; + +describe('SomeImpl', () => { + describe('factory', () => { + it('carries the value and the _tag', () => { + const s = some(10); + expect(s.value).toBe(10); + expect(s._tag).toBe('Some'); + }); + }); + + describe('map', () => { + it('applies the function', () => { + expect(some(10).map((x) => x * 2).getOrNull()).toBe(20); + }); + }); + + describe('flatMap', () => { + it('binds to a Maybe', () => { + expect(some(10).flatMap((x) => some(x + 1)).getOrNull()).toBe(11); + }); + + it('flattens to None', () => { + expect(some(10).flatMap(() => none).isNone()).toBe(true); + }); + }); + + describe('filter', () => { + it('returns Some when predicate passes', () => { + expect(some(10).filter((x) => x > 5).isSome()).toBe(true); + }); + + it('returns None when predicate fails', () => { + expect(some(10).filter((x) => x > 100).isNone()).toBe(true); + }); + }); + + describe('filterMap', () => { + it('keeps Some', () => { + expect(some(10).filterMap((x) => some(x + 1)).getOrNull()).toBe(11); + }); + + it('transitions to None', () => { + expect(some(10).filterMap(() => none).isNone()).toBe(true); + }); + }); + + describe('tap', () => { + it('runs the side effect and returns the same Some', () => { + let seen = 0; + const out = some(10).tap((x) => { + seen = x; + }); + expect(seen).toBe(10); + expect(out.isSome()).toBe(true); + }); + }); + + describe('tapAsync', () => { + it('awaits the side effect and returns the same Some', async () => { + let seen = 0; + const out = await some(10).tapAsync(async (x) => { + seen = x; + }); + expect(seen).toBe(10); + expect(out.isSome()).toBe(true); + }); + }); + + describe('match', () => { + it('dispatches to some()', () => { + expect( + some(10).match({ + some: (v) => v * 2, + none: () => 0, + }), + ).toBe(20); + }); + }); + + describe('fold', () => { + it('dispatches to onSome', () => { + expect( + some(10).fold( + (v) => v + 1, + () => 0, + ), + ).toBe(11); + }); + }); + + describe('getOrElse', () => { + it('returns the value', () => { + expect(some(10).getOrElse(42)).toBe(10); + }); + }); + + describe('getOrThrow', () => { + it('returns the value', () => { + expect(some(10).getOrThrow('msg')).toBe(10); + }); + }); + + describe('getOrNull / getOrUndefined', () => { + it('returns the value', () => { + expect(some(10).getOrNull()).toBe(10); + expect(some(10).getOrUndefined()).toBe(10); + }); + }); + + describe('get (projection)', () => { + it('returns Some for a defined key', () => { + const obj = { name: 'Alice', age: 30 }; + expect(some(obj).get('name').getOrNull()).toBe('Alice'); + }); + + it('returns None for a missing key', () => { + const obj: { name?: string } = {}; + expect(some(obj).get('name').isNone()).toBe(true); + }); + + it('returns None for a key whose value is null', () => { + const obj = { x: null as null | number }; + expect(some(obj).get('x').isNone()).toBe(true); + }); + }); + + describe('toResult', () => { + it('produces Ok', () => { + const r = some(10).toResult('e'); + expect(r.isOk()).toBe(true); + if (r.isOk()) expect(r.value).toBe(10); + }); + }); + + describe('toArray / toIterable', () => { + it('returns single-element array', () => { + expect(some(10).toArray()).toEqual([10]); + }); + + it('returns iterable producing one value', () => { + const out: number[] = []; + for (const x of some(10).toIterable()) out.push(x); + expect(out).toEqual([10]); + }); + }); + + describe('isSome / isNone', () => { + it('isSome is true', () => { + expect(some(10).isSome()).toBe(true); + }); + + it('isNone is false', () => { + expect(some(10).isNone()).toBe(false); + }); + }); + + // cross-conversion sanity checks + describe('cross-conversion', () => { + it('chains Some → Result → Ok', () => { + expect(some(5).toResult('e').map((n) => n + 1).getOrNull()).toBe(6); + }); + + it('returns ok / err importers', () => { + expect(ok(1).isOk()).toBe(true); + expect(err('e').isErr()).toBe(true); + }); + }); +}); diff --git a/packages/fp/tests/result/attempt-impl.test.ts b/packages/fp/tests/result/attempt-impl.test.ts new file mode 100644 index 00000000..1446cbbd --- /dev/null +++ b/packages/fp/tests/result/attempt-impl.test.ts @@ -0,0 +1,296 @@ +import { describe, it, expect } from 'vitest'; +import { attempt, ok, err } from '@deessejs/fp'; + +class BoomError extends Error { + constructor(msg: string) { + super(msg); + this.name = 'BoomError'; + } +} + +describe('AttemptImpl', () => { + describe('execute()', () => { + it('returns Ok when the operation succeeds', async () => { + const a = attempt({ onSuccess: () => 10 }); + const out = await a.execute(); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(10); + }); + + it('returns Err with the raw cause when the operation throws', async () => { + const cause = new BoomError('boom'); + const a = attempt({ + onSuccess: () => { + throw cause; + }, + }); + const out = await a.execute(); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe(cause); + }); + + it('returns Err with the normalised cause when normalize is supplied', async () => { + const a = attempt({ + onSuccess: () => { + throw new BoomError('boom'); + }, + normalize: (e) => (e instanceof Error ? e.message : 'unknown'), + }); + const out = await a.execute(); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('boom'); + }); + + it('performs a single retry when retry.shouldRetry returns true', async () => { + let attempts = 0; + const a = attempt({ + onSuccess: () => { + attempts++; + if (attempts < 2) throw new BoomError('transient'); + return 99; + }, + retry: { + attempts: 1, + delay: { kind: 'constant', baseMs: 0 }, + shouldRetry: () => true, + }, + }); + const out = await a.execute(); + expect(attempts).toBe(2); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(99); + }); + + it('does not retry when retry.shouldRetry returns false', async () => { + let attempts = 0; + const a = attempt({ + onSuccess: () => { + attempts++; + throw new BoomError('boom'); + }, + retry: { + attempts: 1, + delay: { kind: 'constant', baseMs: 0 }, + shouldRetry: () => false, + }, + }); + const out = await a.execute(); + expect(attempts).toBe(1); + expect(out.isErr()).toBe(true); + }); + + it('does not retry when no retry config is supplied', async () => { + let attempts = 0; + const a = attempt({ + onSuccess: () => { + attempts++; + throw new BoomError('boom'); + }, + }); + await a.execute(); + expect(attempts).toBe(1); + }); + + it('does not retry when retry config exists but has no shouldRetry predicate', async () => { + let attempts = 0; + const a = attempt({ + onSuccess: () => { + attempts++; + throw new BoomError('boom'); + }, + retry: { + attempts: 3, + delay: { kind: 'exponential', baseMs: 10 }, + }, + }); + const out = await a.execute(); + expect(attempts).toBe(1); + expect(out.isErr()).toBe(true); + }); + + it('retried failure returns Err with the normalised second-attempt cause', async () => { + let attempts = 0; + const a = attempt({ + onSuccess: () => { + attempts++; + throw new BoomError(`attempt-${attempts}`); + }, + retry: { + attempts: 1, + delay: { kind: 'constant', baseMs: 0 }, + shouldRetry: () => true, + }, + normalize: (e) => (e instanceof Error ? e.message : 'unknown'), + }); + const out = await a.execute(); + expect(attempts).toBe(2); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('attempt-2'); + }); + + it('retried failure without normalize carries the raw second-attempt cause', async () => { + let attempts = 0; + const second = new BoomError('second'); + const a = attempt({ + onSuccess: () => { + attempts++; + if (attempts < 2) throw new BoomError('first'); + throw second; + }, + retry: { + attempts: 1, + delay: { kind: 'constant', baseMs: 0 }, + shouldRetry: () => true, + }, + }); + const out = await a.execute(); + expect(attempts).toBe(2); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe(second); + }); + + it('runs an async onSuccess', async () => { + const a = attempt({ + onSuccess: async () => 10, + }); + const out = await a.execute(); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(10); + }); + }); + + describe('clientSafe()', () => { + it('returns Ok on success', async () => { + const a = attempt({ onSuccess: () => 10 }); + const out = await a.clientSafe(); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(10); + }); + + it('returns Err(NormalizedError) using default on failure without normalize', async () => { + const a = attempt({ + onSuccess: () => { + throw new BoomError('boom'); + }, + }); + const out = await a.clientSafe(); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.code).toBe('INTERNAL_ERROR'); + expect(out.error.status).toBe(500); + expect(out.error.public).toBe(false); + expect(out.error.message).toBe('An unexpected error occurred'); + } + }); + + it('uses the normalize-returned NormalizedError when shape matches', async () => { + const a = attempt({ + onSuccess: () => { + throw new BoomError('boom'); + }, + normalize: () => ({ + code: 'BOOM', + message: 'safe message', + status: 503, + public: true, + }), + }); + const out = await a.clientSafe(); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.code).toBe('BOOM'); + expect(out.error.status).toBe(503); + expect(out.error.public).toBe(true); + expect(out.error.message).toBe('safe message'); + } + }); + + it('falls back to default when normalize returns a malformed shape', async () => { + const a = attempt({ + onSuccess: () => { + throw new BoomError('boom'); + }, + normalize: () => ({ wrong: 'shape' }), + }); + const out = await a.clientSafe(); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.code).toBe('INTERNAL_ERROR'); + } + }); + + it('falls back to default when normalize returns a primitive', async () => { + const a = attempt({ + onSuccess: () => { + throw new BoomError('boom'); + }, + normalize: () => 'string-not-an-error', + }); + const out = await a.clientSafe(); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.code).toBe('INTERNAL_ERROR'); + } + }); + + it('falls back to default when normalize returns null', async () => { + const a = attempt({ + onSuccess: () => { + throw new BoomError('boom'); + }, + normalize: () => null, + }); + const out = await a.clientSafe(); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.code).toBe('INTERNAL_ERROR'); + } + }); + + it('runs an async onSuccess and rejects', async () => { + const a = attempt({ + onSuccess: async () => { + throw new BoomError('async-boom'); + }, + }); + const out = await a.clientSafe(); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.code).toBe('INTERNAL_ERROR'); + } + }); + }); + + describe('laziness', () => { + it('does not run onSuccess when attempt() is called', () => { + let called = false; + attempt({ + onSuccess: () => { + called = true; + return 10; + }, + }); + expect(called).toBe(false); + }); + + it('runs onSuccess on each execute() call', async () => { + let calls = 0; + const a = attempt({ + onSuccess: () => { + calls++; + return calls; + }, + }); + await a.execute(); + await a.execute(); + expect(calls).toBe(2); + }); + }); + + describe('cross-module smoke', () => { + it('references ok and err', () => { + expect(ok(1).isOk()).toBe(true); + expect(err('e').isErr()).toBe(true); + }); + }); +}); diff --git a/packages/fp/tests/result/classify.test.ts b/packages/fp/tests/result/classify.test.ts new file mode 100644 index 00000000..ccbee539 --- /dev/null +++ b/packages/fp/tests/result/classify.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { classifyError } from '@deessejs/fp'; +import type { ClassificationRule, ErrorConstructor } from '@deessejs/fp'; + +class NetworkError extends Error {} +class TimeoutError extends Error {} +class AuthError extends Error {} + +const RULES: ClassificationRule[] = [ + { error: NetworkError as ErrorConstructor, classification: 'retryable' }, + { error: TimeoutError as ErrorConstructor, classification: 'retryable' }, + { error: AuthError as ErrorConstructor, classification: 'non-retryable' }, +]; + +describe('classifyError', () => { + it('returns non-retryable when no rules are supplied', () => { + expect(classifyError(new Error('boom'), [])).toBe('non-retryable'); + }); + + it('matches the first applicable rule', () => { + expect(classifyError(new NetworkError('boom'), RULES)).toBe('retryable'); + expect(classifyError(new TimeoutError('boom'), RULES)).toBe('retryable'); + expect(classifyError(new AuthError('boom'), RULES)).toBe('non-retryable'); + }); + + it('returns non-retryable for an Error that matches no rule', () => { + expect(classifyError(new Error('boom'), RULES)).toBe('non-retryable'); + }); + + it('returns non-retryable for a non-Error value', () => { + expect(classifyError('string', RULES)).toBe('non-retryable'); + expect(classifyError(null, RULES)).toBe('non-retryable'); + expect(classifyError(undefined, RULES)).toBe('non-retryable'); + expect(classifyError(42, RULES)).toBe('non-retryable'); + }); + + it('respects subclass instance checks', () => { + class ExtendedNetworkError extends NetworkError {} + expect(classifyError(new ExtendedNetworkError('boom'), RULES)).toBe('retryable'); + }); +}); diff --git a/packages/fp/tests/result/err-impl.test.ts b/packages/fp/tests/result/err-impl.test.ts new file mode 100644 index 00000000..552fac10 --- /dev/null +++ b/packages/fp/tests/result/err-impl.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { err, ok, some, none } from '@deessejs/fp'; + +describe('ErrImpl', () => { + describe('factory', () => { + it('carries the error and the _tag', () => { + const e = err('boom'); + expect(e.error).toBe('boom'); + expect(e._tag).toBe('Err'); + }); + }); + + describe('map', () => { + it('passes through with the new value type', () => { + const out = err('e').map((x: number) => x * 2); + expect(out.isErr()).toBe(true); + }); + }); + + describe('flatMap', () => { + it('passes through', () => { + const out = err('e').flatMap((x: number) => ok(x + 1)); + expect(out.isErr()).toBe(true); + }); + }); + + describe('mapError', () => { + it('applies the function', () => { + const out = err('e').mapError((e: string) => e.toUpperCase()); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('E'); + }); + }); + + describe('filter', () => { + it('passes through', () => { + const out = err('e').filter((x: number) => x > 0, (x: number) => 'odd'); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('e'); + }); + }); + + describe('tap', () => { + it('does not invoke the function', () => { + let called = false; + err('e').tap(() => { + called = true; + }); + expect(called).toBe(false); + }); + }); + + describe('tapAsync', () => { + it('returns a resolved Err', async () => { + const out = await err('e').tapAsync(async () => { + /* never */ + }); + expect(out.isErr()).toBe(true); + }); + }); + + describe('flatMapAsync', () => { + it('passes through', async () => { + const out = await err('e').flatMapAsync(async (x: number) => ok(x + 1)); + expect(out.isErr()).toBe(true); + }); + }); + + describe('match', () => { + it('dispatches to err()', () => { + expect( + err(42).match({ + ok: (v) => `ok:${v}`, + err: (e) => `err:${e}`, + }), + ).toBe('err:42'); + }); + }); + + describe('fold', () => { + it('dispatches to onErr', () => { + expect( + err(42).fold( + (v: string) => v, + (e: number) => `err:${e}`, + ), + ).toBe('err:42'); + }); + }); + + describe('getOrElse', () => { + it('returns the default', () => { + expect(err('e').getOrElse(42)).toBe(42); + }); + }); + + describe('getOrThrow', () => { + it('throws with default message when no message is supplied', () => { + expect(() => err('boom').getOrThrow()).toThrow('boom'); + }); + + it('throws with the supplied message', () => { + expect(() => err('boom').getOrThrow('custom')).toThrow('custom'); + }); + }); + + describe('getOrNull / getOrUndefined', () => { + it('returns null', () => { + expect(err('e').getOrNull()).toBe(null); + }); + + it('returns undefined', () => { + expect(err('e').getOrUndefined()).toBe(undefined); + }); + }); + + describe('toMaybe / toOption', () => { + it('produces None', () => { + expect(err('e').toMaybe().isNone()).toBe(true); + }); + + it('produces None via toOption', () => { + expect(err('e').toOption().isNone()).toBe(true); + }); + }); + + describe('isOk / isErr', () => { + it('isOk is false', () => { + expect(err('e').isOk()).toBe(false); + }); + + it('isErr is true', () => { + expect(err('e').isErr()).toBe(true); + }); + }); + + // cross-conversion sanity checks + describe('cross-conversion smoke', () => { + it('references ok and some', () => { + expect(ok(1).isOk()).toBe(true); + expect(some(1).isSome()).toBe(true); + expect(none.isNone()).toBe(true); + }); + }); +}); diff --git a/packages/fp/tests/result/functions.test.ts b/packages/fp/tests/result/functions.test.ts new file mode 100644 index 00000000..b6c49c16 --- /dev/null +++ b/packages/fp/tests/result/functions.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect } from 'vitest'; +import { + ok, + err, + some, + none, + map as mapR, + flatMap as flatMapR, + mapError as mapErrorR, + filter as filterR, + tap as tapR, + tapAsync as tapAsyncR, + flatMapAsync as flatMapAsyncR, + match as matchR, + fold as foldR, + getOrElse as getOrElseR, + getOrThrow as getOrThrowR, + getOrNull as getOrNullR, + getOrUndefined as getOrUndefinedR, + toMaybe as toMaybeR, + toOption as toOptionR, + isOk as isOkR, + isErr as isErrR, +} from '@deessejs/fp'; + +describe('Result pipeables', () => { + describe('map', () => { + it('applies the function on Ok', () => { + expect(mapR((x: number) => x * 2)(ok(10)).getOrNull()).toBe(20); + }); + + it('passes through on Err', () => { + expect(mapR((x: number) => x * 2)(err('e')).isErr()).toBe(true); + }); + }); + + describe('flatMap', () => { + it('binds on Ok to Ok', () => { + expect(flatMapR((x: number) => ok(x + 1))(ok(10)).getOrNull()).toBe(11); + }); + + it('binds on Ok to Err', () => { + expect(flatMapR(() => err('e'))(ok(10)).isErr()).toBe(true); + }); + + it('passes through on Err', () => { + expect(flatMapR(() => ok(1))(err('e')).isErr()).toBe(true); + }); + }); + + describe('mapError', () => { + it('applies the function on Err', () => { + const out = mapErrorR((e: string) => e.toUpperCase())(err('e')); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('E'); + }); + + it('passes through on Ok', () => { + expect(mapErrorR((e: string) => e.toUpperCase())(ok(10)).isOk()).toBe(true); + }); + }); + + describe('filter', () => { + it('keeps Ok when predicate passes', () => { + expect(filterR((x: number) => x > 5)(ok(10)).isOk()).toBe(true); + }); + + it('returns Err(errorFn) when predicate fails and errorFn supplied', () => { + const out = filterR((x: number) => x % 2 === 0, (x) => `odd:${x}`)(ok(3)); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('odd:3'); + }); + + it('passes Ok through when predicate fails and no errorFn', () => { + expect(filterR((x: number) => x % 2 === 0)(ok(3)).isOk()).toBe(true); + }); + + it('passes through on Err', () => { + expect(filterR((x: number) => true)(err('e')).isErr()).toBe(true); + }); + }); + + describe('tap', () => { + it('runs the side effect on Ok', () => { + let seen = 0; + tapR((x: number) => { + seen = x; + })(ok(10)); + expect(seen).toBe(10); + }); + + it('does not run on Err', () => { + let called = false; + tapR(() => { + called = true; + })(err('e')); + expect(called).toBe(false); + }); + }); + + describe('tapAsync', () => { + it('awaits on Ok', async () => { + let seen = 0; + const out = await tapAsyncR(async (x: number) => { + seen = x; + })(ok(10)); + expect(seen).toBe(10); + expect(out.isOk()).toBe(true); + }); + + it('passes through on Err', async () => { + const out = await tapAsyncR(async () => { + /* never */ + })(err('e')); + expect(out.isErr()).toBe(true); + }); + }); + + describe('flatMapAsync', () => { + it('binds on Ok to Promise', async () => { + const out = await flatMapAsyncR(async (x: number) => ok(x + 1))(ok(10)); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(11); + }); + + it('binds on Ok to Promise', async () => { + const out = await flatMapAsyncR(async () => err('e'))(ok(10)); + expect(out.isErr()).toBe(true); + }); + + it('passes through on Err', async () => { + const out = await flatMapAsyncR(async () => ok(1))(err('e')); + expect(out.isErr()).toBe(true); + }); + }); + + describe('match', () => { + it('dispatches to ok on Ok', () => { + expect( + matchR({ + ok: (v) => `ok:${v}`, + err: () => 'err', + })(ok(10)), + ).toBe('ok:10'); + }); + + it('dispatches to err on Err', () => { + expect( + matchR({ + ok: () => 'ok', + err: (e) => `err:${e}`, + })(err('e')), + ).toBe('err:e'); + }); + }); + + describe('fold', () => { + it('dispatches to onOk', () => { + expect( + foldR( + (v: number) => v + 1, + () => 0, + )(ok(10)), + ).toBe(11); + }); + + it('dispatches to onErr', () => { + expect( + foldR( + (v: number) => v + 1, + (e: string) => e.length, + )(err('hello')), + ).toBe(5); + }); + }); + + describe('getOrElse', () => { + it('returns the value on Ok', () => { + expect(getOrElseR(42)(ok(10))).toBe(10); + }); + + it('returns the default on Err', () => { + expect(getOrElseR(42)(err('e'))).toBe(42); + }); + }); + + describe('getOrThrow', () => { + it('returns the value on Ok', () => { + expect(getOrThrowR('msg')(ok(10))).toBe(10); + }); + + it('throws on Err', () => { + expect(() => getOrThrowR('custom')(err('boom'))).toThrow('custom'); + }); + + it('throws default on Err', () => { + expect(() => getOrThrowR()(err('boom'))).toThrow('boom'); + }); + }); + + describe('getOrNull / getOrUndefined', () => { + it('returns the value on Ok', () => { + expect(getOrNullR()(ok(10))).toBe(10); + expect(getOrUndefinedR()(ok(10))).toBe(10); + }); + + it('returns null/undefined on Err', () => { + expect(getOrNullR()(err('e'))).toBe(null); + expect(getOrUndefinedR()(err('e'))).toBe(undefined); + }); + }); + + describe('toMaybe / toOption', () => { + it('produces Some on Ok', () => { + expect(toMaybeR()(ok(10)).isSome()).toBe(true); + expect(toOptionR()(ok(10)).isSome()).toBe(true); + }); + + it('produces None on Err', () => { + expect(toMaybeR()(err('e')).isNone()).toBe(true); + expect(toOptionR()(err('e')).isNone()).toBe(true); + }); + }); + + describe('isOk / isErr', () => { + it('isOk narrows Ok', () => { + const r = ok(10); + if (isOkR(r)) { + expect(r.value).toBe(10); + } else { + throw new Error('expected Ok'); + } + }); + + it('isErr narrows Err', () => { + const r = err(42); + if (isErrR(r)) { + expect(r.error).toBe(42); + } else { + throw new Error('expected Err'); + } + }); + }); + + // smoke: other primitives used here are exercised transitively + it('imports some and none', () => { + expect(some(1).isSome()).toBe(true); + expect(none.isNone()).toBe(true); + }); +}); diff --git a/packages/fp/tests/result/index.test.ts b/packages/fp/tests/result/index.test.ts new file mode 100644 index 00000000..8a46d4ac --- /dev/null +++ b/packages/fp/tests/result/index.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { pipe } from '@deessejs/fp'; +import { + fromThrowable, + fromAsyncThrowable, + map, + getOrElse, + isOk, + isErr, + ok, + err, +} from '@deessejs/fp'; + +describe('Result wrapping integration', () => { + it('fromThrowable -> pipe -> Result pipeables', () => { + const out = pipe( + fromThrowable(() => 10), + map((n) => n * 2), + getOrElse(0), + ); + expect(out).toBe(20); + }); + + it('fromAsyncThrowable -> map -> getOrElse', async () => { + const r = await fromAsyncThrowable(() => Promise.resolve(10)); + const out = pipe(r, map((n) => n * 2), getOrElse(0)); + expect(out).toBe(20); + }); + + it('isOk narrows a fromThrowable result', () => { + const r = fromThrowable(() => 7); + if (isOk(r)) { + expect(r.value).toBe(7); + } else { + throw new Error('expected Ok'); + } + }); + + it('isErr narrows a throwing fromThrowable result', () => { + const r = fromThrowable(() => { + throw new Error('boom'); + }); + if (isErr(r)) { + expect(r.error._tag).toBe('UnhandledException'); + } else { + throw new Error('expected Err'); + } + }); + + it('ok / err factories still work', () => { + expect(ok(1).isOk()).toBe(true); + expect(err('e').isErr()).toBe(true); + }); +}); diff --git a/packages/fp/tests/result/ok-impl.test.ts b/packages/fp/tests/result/ok-impl.test.ts new file mode 100644 index 00000000..43951fec --- /dev/null +++ b/packages/fp/tests/result/ok-impl.test.ts @@ -0,0 +1,159 @@ +import { describe, it, expect } from 'vitest'; +import { ok, err, some, none } from '@deessejs/fp'; + +describe('OkImpl', () => { + describe('factory', () => { + it('carries the value and the _tag', () => { + const r = ok(10); + expect(r.value).toBe(10); + expect(r._tag).toBe('Ok'); + }); + }); + + describe('map', () => { + it('applies the function', () => { + expect(ok(10).map((x) => x * 2).getOrNull()).toBe(20); + }); + }); + + describe('flatMap', () => { + it('binds to a Result', () => { + expect(ok(10).flatMap((x) => ok(x + 1)).getOrNull()).toBe(11); + }); + + it('binds to a Result.Err', () => { + const out = ok(10).flatMap(() => err('e')); + expect(out.isErr()).toBe(true); + }); + }); + + describe('mapError', () => { + it('returns this unchanged', () => { + const src = ok(10); + const out = src.mapError((e: never) => 'other'); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(10); + }); + }); + + describe('filter', () => { + it('returns Ok when predicate passes', () => { + expect(ok(10).filter((x) => x > 5).isOk()).toBe(true); + }); + + it('returns Err(errorFn(value)) when predicate fails and errorFn supplied', () => { + const out = ok(3).filter((x) => x % 2 === 0, (x) => `odd:${x}`); + expect(out.isErr()).toBe(true); + if (out.isErr()) expect(out.error).toBe('odd:3'); + }); + + it('returns Ok when predicate fails and no errorFn supplied', () => { + expect(ok(3).filter((x) => x % 2 === 0).isOk()).toBe(true); + }); + }); + + describe('tap', () => { + it('runs the side effect and returns the same Ok', () => { + let seen = 0; + const out = ok(10).tap((x) => { + seen = x; + }); + expect(seen).toBe(10); + expect(out.isOk()).toBe(true); + }); + }); + + describe('tapAsync', () => { + it('awaits the side effect and returns the same Ok', async () => { + let seen = 0; + const out = await ok(10).tapAsync(async (x) => { + seen = x; + }); + expect(seen).toBe(10); + expect(out.isOk()).toBe(true); + }); + }); + + describe('flatMapAsync', () => { + it('binds to a Promise', async () => { + const out = await ok(10).flatMapAsync(async (x) => ok(x + 1)); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(11); + }); + + it('binds to a Promise', async () => { + const out = await ok(10).flatMapAsync(async () => err('e')); + expect(out.isErr()).toBe(true); + }); + }); + + describe('match', () => { + it('dispatches to ok()', () => { + expect( + ok(10).match({ + ok: (v) => v * 2, + err: () => 0, + }), + ).toBe(20); + }); + }); + + describe('fold', () => { + it('dispatches to onOk', () => { + expect( + ok(10).fold( + (v) => v + 1, + () => 0, + ), + ).toBe(11); + }); + }); + + describe('getOrElse', () => { + it('returns the value', () => { + expect(ok(10).getOrElse(42)).toBe(10); + }); + }); + + describe('getOrThrow', () => { + it('returns the value', () => { + expect(ok(10).getOrThrow('msg')).toBe(10); + }); + }); + + describe('getOrNull / getOrUndefined', () => { + it('returns the value', () => { + expect(ok(10).getOrNull()).toBe(10); + expect(ok(10).getOrUndefined()).toBe(10); + }); + }); + + describe('toMaybe / toOption', () => { + it('produces Some', () => { + expect(ok(10).toMaybe().isSome()).toBe(true); + }); + + it('produces Some via toOption', () => { + expect(ok(10).toOption().isSome()).toBe(true); + }); + }); + + describe('isOk / isErr', () => { + it('isOk is true', () => { + expect(ok(10).isOk()).toBe(true); + }); + + it('isErr is false', () => { + expect(ok(10).isErr()).toBe(false); + }); + }); + + // cross-conversion sanity checks + describe('cross-conversion smoke', () => { + it('references err, some, none', () => { + expect(err('e').isErr()).toBe(true); + expect(some(1).isSome()).toBe(true); + expect(none.isNone()).toBe(true); + }); + }); +}); diff --git a/packages/fp/tests/result/reporting.test.ts b/packages/fp/tests/result/reporting.test.ts new file mode 100644 index 00000000..ae04c61e --- /dev/null +++ b/packages/fp/tests/result/reporting.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi } from 'vitest'; +import { withReporting, ok, err } from '@deessejs/fp'; +import type { ErrorReporter, ErrorContext } from '@deessejs/fp'; + +describe('withReporting', () => { + it('returns Ok when the operation succeeds', async () => { + const reporter: ErrorReporter = { report: () => {} }; + const out = await withReporting(() => 10, 'op', reporter); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(10); + }); + + it('invokes the reporter with the original cause on failure', async () => { + const cause = new Error('boom'); + const report = vi.fn(); + const reporter: ErrorReporter = { report }; + const out = await withReporting( + () => { + throw cause; + }, + 'op', + reporter, + { requestId: 'r-1' }, + ); + expect(out.isErr()).toBe(true); + expect(report).toHaveBeenCalledTimes(1); + const [reportedCause, ctx] = report.mock.calls[0] as [unknown, ErrorContext]; + expect(reportedCause).toBe(cause); + expect(ctx.operation).toBe('op'); + expect(ctx.metadata).toEqual({ requestId: 'r-1' }); + expect(typeof ctx.timestamp).toBe('number'); + }); + + it('wraps the error in a ReportableError with cause and message', async () => { + const cause = new Error('boom'); + const reporter: ErrorReporter = { report: () => {} }; + const out = await withReporting( + () => { + throw cause; + }, + 'op', + reporter, + ); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error._tag).toBe('ReportableError'); + expect(out.error.message).toBe('boom'); + expect(out.error.cause).toBe(cause); + } + }); + + it('uses a fallback message when the cause is not an Error', async () => { + const reporter: ErrorReporter = { report: () => {} }; + const out = await withReporting( + () => { + throw 'string-throw'; + }, + 'op', + reporter, + ); + expect(out.isErr()).toBe(true); + if (out.isErr()) { + expect(out.error.message).toBe('Operation failed'); + expect(out.error.cause).toBe('string-throw'); + } + }); + + it('omits metadata when none is supplied', async () => { + const report = vi.fn(); + const out = await withReporting( + () => { + throw new Error('boom'); + }, + 'op', + { report }, + ); + expect(out.isErr()).toBe(true); + const [, ctx] = report.mock.calls[0] as [unknown, ErrorContext]; + expect(ctx.metadata).toBeUndefined(); + }); + + it('awaits async operations', async () => { + const reporter: ErrorReporter = { report: () => {} }; + const out = await withReporting(async () => 10, 'op', reporter); + expect(out.isOk()).toBe(true); + if (out.isOk()) expect(out.value).toBe(10); + }); + + describe('cross-module smoke', () => { + it('references ok and err', () => { + expect(ok(1).isOk()).toBe(true); + expect(err('e').isErr()).toBe(true); + }); + }); +}); diff --git a/packages/fp/tests/result/wrapping.test.ts b/packages/fp/tests/result/wrapping.test.ts new file mode 100644 index 00000000..b2007dd4 --- /dev/null +++ b/packages/fp/tests/result/wrapping.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect } from 'vitest'; +import { + ok, + err, + isOk, + isErr, + fromThrowable, + fromAsyncThrowable, +} from '@deessejs/fp'; + +describe('fromThrowable', () => { + describe('thunk overload', () => { + it('returns Ok when the thunk returns', () => { + const r = fromThrowable(() => 10); + expect(r.isOk()).toBe(true); + if (r.isOk()) expect(r.value).toBe(10); + }); + + it('returns Err(UnhandledException) when the thunk throws an Error', () => { + const r = fromThrowable(() => { + throw new Error('boom'); + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) { + expect(r.error._tag).toBe('UnhandledException'); + expect((r.error.cause as Error).message).toBe('boom'); + } + }); + + it('captures non-Error throws as-is', () => { + const r = fromThrowable(() => { + throw 'string-throw'; + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) { + expect(r.error.cause).toBe('string-throw'); + } + }); + }); + + describe('options overload', () => { + it('returns Ok when onSuccess returns', () => { + const r = fromThrowable({ + onSuccess: () => 10, + onError: () => 'e', + }); + expect(r.isOk()).toBe(true); + if (r.isOk()) expect(r.value).toBe(10); + }); + + it('returns Err mapped via onError when onSuccess throws', () => { + const r = fromThrowable({ + onSuccess: () => { + throw new Error('boom'); + }, + onError: (cause) => 'mapped:' + (cause as Error).message, + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('mapped:boom'); + }); + + it('captures non-Error throws and maps them through onError', () => { + const r = fromThrowable({ + onSuccess: () => { + throw 42; + }, + onError: (cause) => 'got:' + cause, + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('got:42'); + }); + }); + + describe('pipeability', () => { + it('Result pipeables work on fromThrowable output', () => { + const r = fromThrowable(() => 10); + const mapped = r.map((x) => x * 2); + expect(mapped.isOk()).toBe(true); + if (mapped.isOk()) expect(mapped.value).toBe(20); + }); + + it('Result match works on fromThrowable output', () => { + const r = fromThrowable(() => 10); + const out = r.match({ + ok: (v) => 'ok:' + v, + err: () => 'err', + }); + expect(out).toBe('ok:10'); + }); + }); +}); + +describe('fromAsyncThrowable', () => { + describe('thunk overload', () => { + it('returns Ok when the promise resolves', async () => { + const r = await fromAsyncThrowable(() => Promise.resolve(10)); + expect(r.isOk()).toBe(true); + if (r.isOk()) expect(r.value).toBe(10); + }); + + it('returns Err(UnhandledException) when the promise rejects with an Error', async () => { + const r = await fromAsyncThrowable(() => Promise.reject(new Error('boom'))); + expect(r.isErr()).toBe(true); + if (r.isErr()) { + expect(r.error._tag).toBe('UnhandledException'); + expect((r.error.cause as Error).message).toBe('boom'); + } + }); + + it('returns Err when the thunk throws synchronously', async () => { + const r = await fromAsyncThrowable(() => { + throw new Error('sync-throw'); + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) { + expect((r.error.cause as Error).message).toBe('sync-throw'); + } + }); + + it('captures non-Error rejections as-is', async () => { + const r = await fromAsyncThrowable(() => Promise.reject('string-reject')); + expect(r.isErr()).toBe(true); + if (r.isErr()) { + expect(r.error.cause).toBe('string-reject'); + } + }); + }); + + describe('options overload', () => { + it('returns Ok when onSuccess resolves', async () => { + const r = await fromAsyncThrowable({ + onSuccess: () => Promise.resolve(10), + onError: () => 'e', + }); + expect(r.isOk()).toBe(true); + if (r.isOk()) expect(r.value).toBe(10); + }); + + it('returns Err mapped via onError when onSuccess rejects', async () => { + const r = await fromAsyncThrowable({ + onSuccess: () => Promise.reject(new Error('boom')), + onError: (cause) => 'mapped:' + (cause as Error).message, + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('mapped:boom'); + }); + + it('returns Err mapped via async onError', async () => { + const r = await fromAsyncThrowable({ + onSuccess: () => Promise.reject(new Error('boom')), + onError: async (cause) => 'async:' + (cause as Error).message, + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('async:boom'); + }); + + it('maps synchronous throws from onSuccess through onError', async () => { + const r = await fromAsyncThrowable({ + onSuccess: () => { + throw new Error('sync'); + }, + onError: (cause) => 'caught:' + (cause as Error).message, + }); + expect(r.isErr()).toBe(true); + if (r.isErr()) expect(r.error).toBe('caught:sync'); + }); + }); + + describe('pipeability', () => { + it('Result pipeables work after await', async () => { + const r = await fromAsyncThrowable(() => Promise.resolve(10)); + const mapped = r.map((x) => x * 2); + expect(mapped.isOk()).toBe(true); + if (mapped.isOk()) expect(mapped.value).toBe(20); + }); + }); +}); + +describe('cross-module smoke', () => { + it('ok / err remain importable', () => { + expect(ok(1).isOk()).toBe(true); + expect(err('e').isErr()).toBe(true); + }); + + it('isOk narrows a fromThrowable result', () => { + const r = fromThrowable(() => 7); + if (isOk(r)) { + expect(r.value).toBe(7); + } else { + throw new Error('expected Ok'); + } + }); + + it('isErr narrows a throwing fromThrowable result', () => { + const r = fromThrowable(() => { + throw new Error('boom'); + }); + if (isErr(r)) { + expect(r.error._tag).toBe('UnhandledException'); + } else { + throw new Error('expected Err'); + } + }); +}); diff --git a/packages/fp/tests/shared/types.test.ts b/packages/fp/tests/shared/types.test.ts new file mode 100644 index 00000000..c7dcea3b --- /dev/null +++ b/packages/fp/tests/shared/types.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { isResult, isMaybe, isUnit, ok, err, some, none, unit } from '@deessejs/fp'; + +describe('isResult', () => { + it('returns true for Ok', () => { + expect(isResult(ok(1))).toBe(true); + }); + + it('returns true for Err', () => { + expect(isResult(err('e'))).toBe(true); + }); + + it('returns false for null', () => { + expect(isResult(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isResult(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isResult(1)).toBe(false); + expect(isResult('s')).toBe(false); + expect(isResult(true)).toBe(false); + }); + + it('returns false for plain objects without _tag', () => { + expect(isResult({})).toBe(false); + expect(isResult({ value: 1 })).toBe(false); + }); + + it('returns false for objects with an unknown _tag', () => { + expect(isResult({ _tag: 'Maybe' })).toBe(false); + }); +}); + +describe('isMaybe', () => { + it('returns true for Some', () => { + expect(isMaybe(some(1))).toBe(true); + }); + + it('returns true for None', () => { + expect(isMaybe(none)).toBe(true); + }); + + it('returns false for null', () => { + expect(isMaybe(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isMaybe(undefined)).toBe(false); + }); + + it('returns false for primitives', () => { + expect(isMaybe(1)).toBe(false); + expect(isMaybe('s')).toBe(false); + expect(isMaybe(true)).toBe(false); + }); + + it('returns false for plain objects without _tag', () => { + expect(isMaybe({})).toBe(false); + expect(isMaybe({ value: 1 })).toBe(false); + }); + + it('returns false for objects with an unknown _tag', () => { + expect(isMaybe({ _tag: 'Result' })).toBe(false); + }); +}); + +describe('isUnit', () => { + it('returns true for unit', () => { + expect(isUnit(unit)).toBe(true); + }); + + it('returns false for null and undefined', () => { + expect(isUnit(null)).toBe(false); + expect(isUnit(undefined)).toBe(false); + }); + + it('returns false for primitives and plain objects', () => { + expect(isUnit('s')).toBe(false); + expect(isUnit(1)).toBe(false); + expect(isUnit({})).toBe(false); + expect(isUnit({ _tag: 'Other' })).toBe(false); + }); +}); diff --git a/packages/fp/vitest.config.ts b/packages/fp/vitest.config.ts index 7192c61e..9844503a 100644 --- a/packages/fp/vitest.config.ts +++ b/packages/fp/vitest.config.ts @@ -1,8 +1,46 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; +// Tests at packages/fp/tests/* import the package as '@deessejs/fp' +// (per the published-style import). For dev tests we want to resolve +// that import to the source (not the dist build). The workspace +// pnpm symlink is in node_modules/@deessejs/fp -> packages/fp, so the +// package's package.json#exports.import maps '.' to './dist/index.js', +// which only exists after `pnpm build`. Resolve it to the source +// for tests, then the regular build pipeline resolves it back to dist. +const packageRoot = resolve(__dirname); +const sourceEntry = resolve(packageRoot, 'src/index.ts'); + export default defineConfig({ + resolve: { + alias: { + '@deessejs/fp': sourceEntry, + }, + }, test: { globals: true, - environment: 'node' - } -}); \ No newline at end of file + environment: 'node', + coverage: { + provider: 'v8', + all: true, + include: ['src/**/*.ts'], + exclude: [ + 'src/**/*.d.ts', + 'src/**/types.ts', + 'src/types.ts', + 'src/**/internal/index.ts', + 'src/**/*-class.ts', + 'src/index.ts', + ], + reporter: ['text-summary', 'html', 'lcov', 'json', 'json-summary'], + reportsDirectory: './coverage', + thresholds: { + lines: 100, + branches: 100, + functions: 100, + statements: 100, + perFile: false, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a92ae62..0f3dea13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,12 +75,12 @@ importers: packages/fp: devDependencies: - '@deessejs/errors': - specifier: ^1.0.0 - version: 1.1.1 '@eslint/js': specifier: ^9.0.0 version: 9.39.4 + '@vitest/coverage-v8': + specifier: ^4.1.10 + version: 4.1.10(vitest@4.1.10) eslint: specifier: ^9.0.0 version: 9.39.4(jiti@2.7.0) @@ -91,8 +91,8 @@ importers: specifier: ^8.61.0 version: 8.65.0(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.5)(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + specifier: ^4.1.10 + version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages: @@ -167,6 +167,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@changesets/apply-release-plan@8.0.0-next.10': resolution: {integrity: sha512-Yps335/MoZe8nKMJ8Jt4CCZ4N9zFF+5q0INfmcCuDJebrB/cvCfJMJLMVQ/Pz4lFY7fWgCVFSsvRFHiT23G08g==} engines: {node: ^22.11 || ^24 || >=26} @@ -236,9 +240,6 @@ packages: resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} engines: {node: '>= 20.12.0'} - '@deessejs/errors@1.1.1': - resolution: {integrity: sha512-iq9ei2OgoGqpuO37c7Mp4EBIFOZsyueGQLgavSwUKtExu0oSElWCzTcF568XUWRVAjrjlxWD8vcDyLRFPYSIZQ==} - '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -1684,11 +1685,20 @@ packages: cpu: [x64] os: [win32] - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/coverage-v8@4.1.10': + resolution: {integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==} + peerDependencies: + '@vitest/browser': 4.1.10 + vitest: 4.1.10 + peerDependenciesMeta: + '@vitest/browser': + optional: true - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -1698,20 +1708,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -1780,6 +1790,9 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -2524,6 +2537,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} @@ -2687,6 +2703,18 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -2698,6 +2726,9 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2851,6 +2882,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -3733,20 +3771,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3935,6 +3973,8 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bcoe/v8-coverage@1.0.2': {} + '@changesets/apply-release-plan@8.0.0-next.10': dependencies: '@changesets/config': 4.0.0-next.9 @@ -4050,10 +4090,6 @@ snapshots: fast-wrap-ansi: 0.2.2 sisteransi: 1.0.5 - '@deessejs/errors@1.1.1': - dependencies: - '@standard-schema/spec': 1.1.0 - '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -5305,44 +5341,58 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.2': optional: true - '@vitest/expect@4.1.9': + '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.10 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -5442,6 +5492,12 @@ snapshots: ast-types-flow@0.0.8: {} + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} async-function@1.0.0: {} @@ -6407,6 +6463,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + html-escaper@2.0.2: {} + html-void-elements@3.0.0: {} human-id@4.2.0: {} @@ -6563,6 +6621,19 @@ snapshots: isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -6576,6 +6647,8 @@ snapshots: jju@1.4.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -6698,6 +6771,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + markdown-extensions@2.0.0: {} markdown-table@3.0.4: {} @@ -8024,15 +8107,15 @@ snapshots: jiti: 2.7.0 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.5)(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.0.14(@types/node@25.9.5)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -8048,6 +8131,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 + '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) transitivePeerDependencies: - msw diff --git a/turbo.json b/turbo.json index 7e708346..3b1dcc41 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,11 @@ "dependsOn": ["^build"], "cache": false }, + "test:coverage": { + "dependsOn": ["^build"], + "cache": false, + "outputs": ["coverage/**"] + }, "type-check": { "dependsOn": ["^build"] }, @@ -20,4 +25,4 @@ "dependsOn": ["^build"] } } -} \ No newline at end of file +}