commit 49d8ea67ba67929c4d42c66b77cabb1c069023c3 Author: Andreas Bertsch Date: Mon Mar 30 08:34:35 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4091d0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# compiled output +dist +tmp +out-tsc + +# dependencies +node_modules + +# environment +.env +.env.* + +# coverage +coverage + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +npm-debug.log +yarn-error.log +testem.log + +# System Files +.DS_Store +Thumbs.db diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..11c309c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v24.13.1 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..9db28a2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120 +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4e26416 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md + +## Build & Test Commands + +```bash +# Build (dual ESM/CJS via tsup) +pnpm build + +# Test (Vitest runtime tests) +pnpm test + +# Run a single test file +pnpm test -- src/option/spec/map.spec.ts + +# Type-level tests (vitest typecheck, runs *.type-spec.ts files) +pnpm test:types + +# Watch mode +pnpm test:watch + +# Type-check without emitting +pnpm typecheck + +# Lint +pnpm lint + +# Format +pnpm format +pnpm format:check +``` + +## Architecture + +`native-monad` is a monad library for TypeScript that uses native JS/TS idioms (Array.prototype.map, Array.isArray, Promise.all patterns). **Do not use Rust/FP naming conventions** (no unwrap, orElse, inspect, getOrElse, etc.). It provides three monadic types with class-based implementations: + +- **Result** (`Ok | Err`) - success/failure. Factory functions: `ok(value)`, `err(error)`. +- **Option** (`Some | None`) - presence/absence. Factory functions: `some(value)`, `none()`. `none()` returns a frozen singleton. +- **Query** (`OkSome | OkNone | ErrNone`) - three-state monad combining Result and Option (success-with-value, success-without-value, error). Factory functions: `okSome(value)`, `okNone()`, `errNone(error)`. `okNone()` returns a frozen singleton. + +Each module (`src/result/`, `src/option/`, `src/query/`) follows the same pattern: +1. An **interface** defining the API contract (e.g. `ResultInterface`) +2. **Class implementations** for each variant (e.g. `Ok`, `Err`) +3. A **union type** alias (e.g. `type Result = Ok | Err`) +4. **Factory functions** as lowercase creators (e.g. `ok()`, `err()`) +5. A **namespace object** with static collection operations (`Result.all()`, `Result.from()`, `Result.partition()`, `Result.compact()`, etc.) +6. A **Promise namespace** wrapping collection operations for async usage (`ResultPromise`, `OptionPromise`, `QueryPromise`) + +The three types are independent modules with no cross-type conversions. Users convert via `match()` if needed. + +## Test Conventions + +There are two parallel test systems: +- **`*.spec.ts`** - Runtime behavior tests using Vitest with Arrange/Act/Assert pattern +- **`*.type-spec.ts`** - Compile-time type tests using vitest typecheck with expect-type (`expectTypeOf(x).toEqualTypeOf()`) + +Tests are colocated in `spec/` directories next to the module they test, organized by operation (e.g. `map.spec.ts`, `filter.spec.ts`, `is.spec.ts`). + +## Key Design Details + +- **Falsy** is defined as only `null | undefined` in `src/util/index.ts`. Values like `0`, `false`, and `""` are **not** considered falsy. `Option.from()` and `Query.from()` use this definition. +- Type narrowing is central: `isOk()`/`isErr()` are type guards (`this is Ok`) enabling direct `.value`/`.err` access after checking. +- The package ships dual ESM/CJS via tsup. Entry point: `src/index.ts`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f1a3032 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# native-monad + +TypeScript monad library using native JS/TS idioms - Result, Option, and Query types. + +## Install + +```bash +npm install native-monad +``` + +## Types + +### Result\ + +Success/failure type. Variants: `Ok` | `Err`. + +```typescript +import { ok, err, Result } from 'native-monad'; + +const success = ok(42); // Ok +const failure = err('oops'); // Err + +success.map(x => x * 2); // Ok(84) +failure.map(x => x * 2); // Err('oops') + +success.match( + value => `got ${value}`, + error => `failed: ${error}`, +); // 'got 42' + +// Collection operations +Result.all([ok(1), ok(2), ok(3)]); // Ok([1, 2, 3]) +Result.all([ok(1), err('x')]); // Err('x') +``` + +### Option\ + +Presence/absence type. Variants: `Some` | `None`. + +```typescript +import { some, none, Option } from 'native-monad'; + +const present = some('hello'); // Some +const absent = none(); // None + +present.map(s => s.toUpperCase()); // Some('HELLO') +absent.map(s => s.toUpperCase()); // None + +// Create from nullable values (only null/undefined are falsy) +Option.from(0); // Some(0) +Option.from(null); // None +Option.from(undefined); // None +``` + +### Query\ + +Three-state monad combining Result and Option. Variants: `OkSome` | `OkNone` | `ErrNone`. + +```typescript +import { okSome, okNone, errNone } from 'native-monad'; + +const found = okSome(42); // success with value +const notFound = okNone(); // success without value +const failed = errNone('error'); // failure +``` + +## Development + +```bash +pnpm install +pnpm build # Build (dual ESM/CJS) +pnpm test # Run Jest tests +pnpm test:types # Run vitest type tests +pnpm lint # ESLint +pnpm typecheck # tsc --noEmit +``` diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..eb6e26d --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,86 @@ +# native-monad benchmarks + +Comprehensive benchmark suite comparing `native-monad` against popular TypeScript monad libraries. + +## Competitor Libraries + +| Library | Result type | Option type | Async | +| -------------- | ---------------- | ------------ | --------------- | +| **neverthrow** | `Result` | — | `ResultAsync` | +| **ts-results-es** | `Result` | `Option` | — | +| **oxide.ts** | `Result` | `Option` | — | +| **fp-ts** | `Either` | `Option` | `TaskEither` | +| **true-myth** | `Result` | `Maybe` | — | + +## Prerequisites + +The parent project must be built before running benchmarks: + +```bash +cd .. && pnpm build +``` + +This happens automatically via the `prebench` script when running `pnpm bench`. + +## Running Benchmarks + +```bash +# Install dependencies (first time only) +pnpm install + +# Run all benchmarks +pnpm bench + +# Run by type +pnpm bench:result +pnpm bench:option +pnpm bench:query +pnpm bench:scenarios + +# Run a single file +npx vitest bench result/construction.bench.ts --run +``` + +## Reading Output + +Vitest bench outputs a table per `describe` group: + +| Column | Meaning | +| ------ | ------------------------------------------ | +| `hz` | Operations per second | +| `min` | Fastest single iteration (ms) | +| `max` | Slowest single iteration (ms) | +| `mean` | Average iteration time (ms) | +| `p75` | 75th percentile iteration time (ms) | +| `p99` | 99th percentile iteration time (ms) | +| `rme` | Relative margin of error (%) | + +After all groups, a **Summary** section shows the fastest library per group and relative slowdown factors. + +## Benchmark Structure + +``` +result/ — Result/Either type benchmarks +option/ — Option/Maybe type benchmarks +query/ — Query (three-state) benchmarks + nested equivalents +scenarios/ — Real-world patterns (parsing, validation, error propagation) +helpers/ — Shared config, data generators, imports +``` + +Each benchmark file covers a specific operation category: +- `construction.bench.ts` — Creating Ok/Err/Some/None instances +- `instance-methods.bench.ts` — map, flatMap, mapErr, match, filter +- `type-guards.bench.ts` — isOk/isErr/isSome/isNone +- `conversion.bench.ts` — toNullable, toUndefined, toString +- `collection.bench.ts` — all, partition, compact, some, every +- `chaining.bench.ts` — Multi-step pipelines +- `from-wrapping.bench.ts` — Try-catch wrapping +- `async.bench.ts` — Promise-based operations + +## Caveats + +- **Microbenchmarks** measure isolated operations. Real-world performance depends on usage patterns, GC pressure, and JIT behavior in context. +- **Async benchmarks** have higher variance due to event loop scheduling. Results use extended warmup/time to compensate. +- **fp-ts** uses a fundamentally different paradigm (`pipe` + standalone functions vs class methods). The benchmarks use each library's idiomatic API for fair comparison. +- **`bench.skip`** appears when a library lacks an equivalent operation — no fabricated workarounds. +- Collection benchmarks test at sizes 100 and 10K to show scaling behavior. diff --git a/benchmarks/helpers/bench-options.ts b/benchmarks/helpers/bench-options.ts new file mode 100644 index 0000000..f28851a --- /dev/null +++ b/benchmarks/helpers/bench-options.ts @@ -0,0 +1,25 @@ +import type { BenchOptions } from 'vitest'; + +/** Standard options for most benchmarks — 500ms run time, 100 min iterations */ +export const STANDARD: BenchOptions = { + time: 500, + iterations: 100, + warmupTime: 100, + warmupIterations: 5, +}; + +/** Extended options for async/expensive benchmarks — 1s run time */ +export const EXTENDED: BenchOptions = { + time: 1000, + iterations: 25, + warmupTime: 250, + warmupIterations: 3, +}; + +/** Quick options for allocation/memory pattern benchmarks */ +export const QUICK: BenchOptions = { + time: 250, + iterations: 500, + warmupTime: 50, + warmupIterations: 10, +}; diff --git a/benchmarks/helpers/data-generators.ts b/benchmarks/helpers/data-generators.ts new file mode 100644 index 0000000..e4c8e07 --- /dev/null +++ b/benchmarks/helpers/data-generators.ts @@ -0,0 +1,22 @@ +export const SIZES = [100, 10_000] as const; + +export function generateNumbers(size: number): number[] { + return Array.from({ length: size }, (_, i) => i); +} + +export function generateStrings(size: number): string[] { + return Array.from({ length: size }, (_, i) => `item-${i}`); +} + +/** + * Generates indices that should be "error" slots, deterministically distributed. + * With errRatio=0.2 and size=10, returns indices [4, 9] (every 5th element). + */ +export function errorIndices(size: number, errRatio = 0.2): Set { + const step = Math.max(1, Math.floor(1 / errRatio)); + const indices = new Set(); + for (let i = step - 1; i < size; i += step) { + indices.add(i); + } + return indices; +} diff --git a/benchmarks/helpers/imports.ts b/benchmarks/helpers/imports.ts new file mode 100644 index 0000000..4c0c869 --- /dev/null +++ b/benchmarks/helpers/imports.ts @@ -0,0 +1,53 @@ +// native-monad +export { + ok, + err, + some, + none, + okSome, + okNone, + errNone, + Result, + Option, + Query, + ResultPromise, + OptionPromise, + QueryPromise, +} from 'native-monad'; + +// neverthrow +export { + ok as ntOk, + err as ntErr, + Result as NtResult, + ResultAsync as NtResultAsync, + fromThrowable as ntFromThrowable, +} from 'neverthrow'; + +// ts-results-es +export { + Ok as TsOk, + Err as TsErr, + Some as TsSome, + None as TsNone, + Result as TsResult, + Option as TsOption, +} from 'ts-results-es'; + +// oxide.ts +export { + Ok as OxOk, + Err as OxErr, + Some as OxSome, + None as OxNone, + match as oxMatch, +} from 'oxide.ts'; + +// fp-ts +export * as E from 'fp-ts/Either'; +export * as O from 'fp-ts/Option'; +export { pipe } from 'fp-ts/function'; + +// true-myth (static functions are named exports, not on default) +export * as TmResult from 'true-myth/result'; +export * as TmMaybe from 'true-myth/maybe'; diff --git a/benchmarks/option/async.bench.ts b/benchmarks/option/async.bench.ts new file mode 100644 index 0000000..fb3524e --- /dev/null +++ b/benchmarks/option/async.bench.ts @@ -0,0 +1,42 @@ +import { bench, describe } from 'vitest'; +import { EXTENDED } from '../helpers/bench-options.js'; +import { + some, + none, + OptionPromise, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// OptionPromise.from — returns value +// --------------------------------------------------------------------------- + +describe('OptionPromise.from — returns value', () => { + bench('native-monad', async () => { + await OptionPromise.from(() => Promise.resolve(42)); + }, EXTENDED); + // No other library has an async Option wrapper +}); + +// --------------------------------------------------------------------------- +// OptionPromise.from — returns null +// --------------------------------------------------------------------------- + +describe('OptionPromise.from — returns null', () => { + bench('native-monad', async () => { + await OptionPromise.from(() => Promise.resolve(null)); + }, EXTENDED); +}); + +// --------------------------------------------------------------------------- +// OptionPromise.all — all Some +// --------------------------------------------------------------------------- + +for (const size of [10, 100] as const) { + describe(`OptionPromise.all — ${size} items, all Some`, () => { + const promises = () => Array.from({ length: size }, (_, i) => Promise.resolve(some(i))); + + bench('native-monad', async () => { + await OptionPromise.all(promises()); + }, EXTENDED); + }); +} diff --git a/benchmarks/option/chaining.bench.ts b/benchmarks/option/chaining.bench.ts new file mode 100644 index 0000000..29d8b11 --- /dev/null +++ b/benchmarks/option/chaining.bench.ts @@ -0,0 +1,132 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + some, + none, + TsSome, + TsNone, + OxSome, + OxNone, + O, + pipe, + TmMaybe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// 5-step pipeline — Some path +// --------------------------------------------------------------------------- + +describe('Option chaining — 5-step pipeline (all Some)', () => { + bench('native-monad', () => { + some(42) + .map(x => x * 2) + .flatMap(x => (x > 0 ? some(x) : none())) + .map(x => ({ value: x })) + .flatMap(x => some({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('ts-results-es', () => { + TsSome(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? TsSome(x) : TsNone)) + .map(x => ({ value: x })) + .andThen(x => TsSome({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('oxide.ts', () => { + OxSome(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? OxSome(x) : OxNone)) + .map(x => ({ value: x })) + .andThen(x => OxSome({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + O.some(42), + O.map(x => x * 2), + O.flatMap(x => (x > 0 ? O.some(x) : O.none)), + O.map(x => ({ value: x })), + O.flatMap(x => O.some({ ...x, label: 'result' })), + O.map(x => JSON.stringify(x)), + ); + }, STANDARD); + + bench('true-myth', () => { + TmMaybe.just(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? TmMaybe.just(x) : TmMaybe.nothing())) + .map(x => ({ value: x })) + .andThen(x => TmMaybe.just({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// None short-circuit — 9 no-op maps +// --------------------------------------------------------------------------- + +describe('Option chaining — None at step 1, 9 no-op maps', () => { + const nmNone = none(); + const tsNone = TsNone; + const oxNone = OxNone; + const fpNone = O.none; + const tmNone = TmMaybe.nothing(); + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + nmNone.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('ts-results-es', () => { + tsNone.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('oxide.ts', () => { + oxNone.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('fp-ts', () => { + pipe(fpNone, O.map(fn), O.map(fn), O.map(fn), O.map(fn), O.map(fn), O.map(fn), O.map(fn), O.map(fn), O.map(fn)); + }, STANDARD); + + bench('true-myth', () => { + tmNone.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// 20-step deep map chain — Some path +// --------------------------------------------------------------------------- + +describe('Option chaining — 20 maps on Some', () => { + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + let r = some(0); + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('ts-results-es', () => { + let r = TsSome(0); + for (let i = 0; i < 20; i++) r = r.map(fn) as any; + }, STANDARD); + + bench('oxide.ts', () => { + let r = OxSome(0); + for (let i = 0; i < 20; i++) r = r.map(fn) as any; + }, STANDARD); + + bench('fp-ts', () => { + let r = O.some(0) as O.Option; + for (let i = 0; i < 20; i++) r = pipe(r, O.map(fn)); + }, STANDARD); + + bench('true-myth', () => { + let r = TmMaybe.just(0); + for (let i = 0; i < 20; i++) r = r.map(fn) as any; + }, STANDARD); +}); diff --git a/benchmarks/option/collection.bench.ts b/benchmarks/option/collection.bench.ts new file mode 100644 index 0000000..bb0225b --- /dev/null +++ b/benchmarks/option/collection.bench.ts @@ -0,0 +1,144 @@ +import { bench, describe } from 'vitest'; +import { STANDARD, EXTENDED } from '../helpers/bench-options.js'; +import { SIZES, errorIndices } from '../helpers/data-generators.js'; +import { + some, + none, + Option, + TsSome, + TsNone, + TsOption, + OxSome, + OxNone, + O, + pipe, + TmMaybe, +} from '../helpers/imports.js'; +import * as RA from 'fp-ts/ReadonlyArray'; + +// --------------------------------------------------------------------------- +// Option.all — all Some +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + + const nmAll = Array.from({ length: size }, (_, i) => some(i)); + const tsAll = Array.from({ length: size }, (_, i) => TsSome(i)); + const fpAll = Array.from({ length: size }, (_, i) => O.some(i)); + const tmAll = Array.from({ length: size }, (_, i) => TmMaybe.just(i)); + + describe(`Option.all — ${size} items, all Some`, () => { + bench('native-monad', () => { Option.all(nmAll); }, opts); + bench('ts-results-es', () => { TsOption.all(...tsAll); }, opts); + bench('fp-ts', () => { pipe(fpAll, RA.sequence(O.Applicative)); }, opts); + bench('true-myth (manual)', () => { + const values: number[] = []; + for (const m of tmAll) { + if (m.isNothing) return TmMaybe.nothing(); + values.push((m as { value: number }).value); + } + return TmMaybe.just(values); + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Option.all — 20% None (short-circuit) +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const noneIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? none() : some(i), + ); + const tsMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? TsNone : TsSome(i), + ); + const fpMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? O.none : O.some(i), + ); + + describe(`Option.all — ${size} items, 20% None (short-circuit)`, () => { + bench('native-monad', () => { Option.all(nmMixed); }, opts); + bench('ts-results-es', () => { TsOption.all(...tsMixed); }, opts); + bench('fp-ts', () => { pipe(fpMixed, RA.sequence(O.Applicative)); }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Option.partition +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const noneIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? none() : some(i), + ); + const fpMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? O.none : O.some(i), + ); + + describe(`Option.partition — ${size} items`, () => { + bench('native-monad', () => { Option.partition(nmMixed); }, opts); + bench('fp-ts (manual)', () => { + const values: number[] = []; + let noneCount = 0; + for (const o of fpMixed) { + if (O.isSome(o)) values.push(o.value); + else noneCount++; + } + return { values, noneCount }; + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Option.compact +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const noneIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? none() : some(i), + ); + const fpMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? O.none : O.some(i), + ); + + describe(`Option.compact — ${size} items`, () => { + bench('native-monad', () => { Option.compact(nmMixed); }, opts); + bench('fp-ts', () => { pipe(fpMixed, RA.compact); }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Option.some / Option.every +// --------------------------------------------------------------------------- + +for (const size of [100, 1_000] as const) { + const noneIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? none() : some(i), + ); + const fpMixed = Array.from({ length: size }, (_, i) => + noneIdx.has(i) ? O.none : O.some(i), + ); + + describe(`Option.some — ${size} items`, () => { + bench('native-monad', () => { Option.some(nmMixed); }, STANDARD); + bench('fp-ts (manual)', () => { fpMixed.some(O.isSome); }, STANDARD); + }); + + describe(`Option.every — ${size} items`, () => { + bench('native-monad', () => { Option.every(nmMixed); }, STANDARD); + bench('fp-ts (manual)', () => { fpMixed.every(O.isSome); }, STANDARD); + }); +} diff --git a/benchmarks/option/construction.bench.ts b/benchmarks/option/construction.bench.ts new file mode 100644 index 0000000..6a8864f --- /dev/null +++ b/benchmarks/option/construction.bench.ts @@ -0,0 +1,47 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + some, + none, + TsSome, + TsNone, + OxSome, + OxNone, + O, + TmMaybe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// Some construction +// --------------------------------------------------------------------------- + +describe('Option construction — Some (primitive)', () => { + bench('native-monad', () => { some(42); }, STANDARD); + // neverthrow: no Option type + bench('ts-results-es', () => { TsSome(42); }, STANDARD); + bench('oxide.ts', () => { OxSome(42); }, STANDARD); + bench('fp-ts', () => { O.some(42); }, STANDARD); + bench('true-myth', () => { TmMaybe.just(42); }, STANDARD); +}); + +describe('Option construction — Some (object payload)', () => { + const value = { id: 1, name: 'Alice', roles: ['admin', 'user'] }; + + bench('native-monad', () => { some(value); }, STANDARD); + bench('ts-results-es', () => { TsSome(value); }, STANDARD); + bench('oxide.ts', () => { OxSome(value); }, STANDARD); + bench('fp-ts', () => { O.some(value); }, STANDARD); + bench('true-myth', () => { TmMaybe.just(value); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// None construction / access +// --------------------------------------------------------------------------- + +describe('Option construction — None', () => { + bench('native-monad', () => { none(); }, STANDARD); + bench('ts-results-es', () => { TsNone; }, STANDARD); + bench('oxide.ts', () => { OxNone; }, STANDARD); + bench('fp-ts', () => { O.none; }, STANDARD); + bench('true-myth', () => { TmMaybe.nothing(); }, STANDARD); +}); diff --git a/benchmarks/option/conversion.bench.ts b/benchmarks/option/conversion.bench.ts new file mode 100644 index 0000000..a40cbf4 --- /dev/null +++ b/benchmarks/option/conversion.bench.ts @@ -0,0 +1,55 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + some, + none, + OxSome, + OxNone, + O, + pipe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// toNullable — Some path +// --------------------------------------------------------------------------- + +describe('Option.toNullable — Some', () => { + const nm = some(42); + const ox = OxSome(42); + const fp = O.some(42); + + bench('native-monad', () => { nm.toNullable(); }, STANDARD); + bench('oxide.ts', () => { ox.into(); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.toNullable); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// toNullable — None path +// --------------------------------------------------------------------------- + +describe('Option.toNullable — None', () => { + const nm = none(); + const ox = OxNone; + const fp = O.none; + + bench('native-monad', () => { nm.toNullable(); }, STANDARD); + bench('oxide.ts', () => { ox.into(); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.toNullable); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// toString +// --------------------------------------------------------------------------- + +describe('Option.toString — Some', () => { + const nm = some(42); + + bench('native-monad', () => { nm.toString(); }, STANDARD); + // Most other libraries don't have toString +}); + +describe('Option.toString — None', () => { + const nm = none(); + + bench('native-monad', () => { nm.toString(); }, STANDARD); +}); diff --git a/benchmarks/option/from-wrapping.bench.ts b/benchmarks/option/from-wrapping.bench.ts new file mode 100644 index 0000000..e03c61b --- /dev/null +++ b/benchmarks/option/from-wrapping.bench.ts @@ -0,0 +1,59 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + Option, + O, + TmMaybe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// Option.from — non-nullish value +// --------------------------------------------------------------------------- + +describe('Option.from — non-nullish value', () => { + bench('native-monad', () => { Option.from(42); }, STANDARD); + bench('fp-ts', () => { O.fromNullable(42); }, STANDARD); + bench('true-myth', () => { TmMaybe.of(42); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// Option.from — null value +// --------------------------------------------------------------------------- + +describe('Option.from — null', () => { + bench('native-monad', () => { Option.from(null); }, STANDARD); + bench('fp-ts', () => { O.fromNullable(null); }, STANDARD); + bench('true-myth', () => { TmMaybe.of(null); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// Option.from — undefined value +// --------------------------------------------------------------------------- + +describe('Option.from — undefined', () => { + bench('native-monad', () => { Option.from(undefined); }, STANDARD); + bench('fp-ts', () => { O.fromNullable(undefined); }, STANDARD); + bench('true-myth', () => { TmMaybe.of(undefined); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// Option.from — function that returns value +// --------------------------------------------------------------------------- + +describe('Option.from — function returning value', () => { + const fn = () => 42; + + bench('native-monad', () => { Option.from(fn); }, STANDARD); + // fp-ts: no function overload for fromNullable + // true-myth: no function overload for of +}); + +// --------------------------------------------------------------------------- +// Option.from — function that returns null +// --------------------------------------------------------------------------- + +describe('Option.from — function returning null', () => { + const fn = () => null; + + bench('native-monad', () => { Option.from(fn); }, STANDARD); +}); diff --git a/benchmarks/option/instance-methods.bench.ts b/benchmarks/option/instance-methods.bench.ts new file mode 100644 index 0000000..fc0d62a --- /dev/null +++ b/benchmarks/option/instance-methods.bench.ts @@ -0,0 +1,151 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + some, + none, + TsSome, + TsNone, + OxSome, + OxNone, + oxMatch, + O, + pipe, + TmMaybe, +} from '../helpers/imports.js'; + +const double = (x: number) => x * 2; + +// --------------------------------------------------------------------------- +// map — Some path +// --------------------------------------------------------------------------- + +describe('Option.map — Some path', () => { + const nm = some(42); + const ts = TsSome(42); + const ox = OxSome(42); + const fp = O.some(42); + const tm = TmMaybe.just(42); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('ts-results-es', () => { ts.map(double); }, STANDARD); + bench('oxide.ts', () => { ox.map(double); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.map(double)); }, STANDARD); + bench('true-myth', () => { tm.map(double); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// map — None path (no-op) +// --------------------------------------------------------------------------- + +describe('Option.map — None path (no-op)', () => { + const nm = none(); + const ts = TsNone; + const ox = OxNone; + const fp = O.none; + const tm = TmMaybe.nothing(); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('ts-results-es', () => { ts.map(double); }, STANDARD); + bench('oxide.ts', () => { ox.map(double); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.map(double)); }, STANDARD); + bench('true-myth', () => { tm.map(double); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// flatMap — Some path +// --------------------------------------------------------------------------- + +describe('Option.flatMap — Some path', () => { + const nm = some(42); + const ts = TsSome(42); + const ox = OxSome(42); + const fp = O.some(42); + const tm = TmMaybe.just(42); + + bench('native-monad', () => { nm.flatMap(x => some(x * 2)); }, STANDARD); + bench('ts-results-es', () => { ts.andThen(x => TsSome(x * 2)); }, STANDARD); + bench('oxide.ts', () => { ox.andThen(x => OxSome(x * 2)); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.flatMap(x => O.some(x * 2))); }, STANDARD); + bench('true-myth', () => { tm.andThen(x => TmMaybe.just(x * 2)); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// flatMap — None path (no-op) +// --------------------------------------------------------------------------- + +describe('Option.flatMap — None path (no-op)', () => { + const nm = none(); + const ts = TsNone; + const ox = OxNone; + const fp = O.none; + const tm = TmMaybe.nothing(); + + bench('native-monad', () => { nm.flatMap(x => some(x * 2)); }, STANDARD); + bench('ts-results-es', () => { ts.andThen(x => TsSome(x * 2)); }, STANDARD); + bench('oxide.ts', () => { ox.andThen(x => OxSome(x * 2)); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.flatMap(x => O.some(x * 2))); }, STANDARD); + bench('true-myth', () => { tm.andThen(x => TmMaybe.just(x * 2)); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// match — Some path +// --------------------------------------------------------------------------- + +describe('Option.match — Some path', () => { + const nm = some(42); + const ox = OxSome(42); + const fp = O.some(42); + const tm = TmMaybe.just(42); + + bench('native-monad', () => { nm.match(x => x * 2, () => 0); }, STANDARD); + // ts-results-es: no match + bench('oxide.ts', () => { oxMatch(ox, { Some: (x: number) => x * 2, None: () => 0 }); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.match(() => 0, x => x * 2)); }, STANDARD); + bench('true-myth', () => { tm.match({ Just: x => x * 2, Nothing: () => 0 }); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// match — None path +// --------------------------------------------------------------------------- + +describe('Option.match — None path', () => { + const nm = none(); + const ox = OxNone; + const fp = O.none; + const tm = TmMaybe.nothing(); + + bench('native-monad', () => { nm.match(x => x * 2, () => 0); }, STANDARD); + bench('oxide.ts', () => { oxMatch(ox, { Some: (x: number) => x * 2, None: () => 0 }); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.match(() => 0, x => x * 2)); }, STANDARD); + bench('true-myth', () => { tm.match({ Just: x => x * 2, Nothing: () => 0 }); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// filter — Some path (predicate passes) +// --------------------------------------------------------------------------- + +describe('Option.filter — predicate passes', () => { + const nm = some(42); + const ox = OxSome(42); + const fp = O.some(42); + + bench('native-monad', () => { nm.filter(x => x > 0); }, STANDARD); + // ts-results-es: no filter + bench('oxide.ts', () => { ox.filter(x => x > 0); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.filter(x => x > 0)); }, STANDARD); + // true-myth: no filter on Maybe +}); + +// --------------------------------------------------------------------------- +// filter — predicate fails +// --------------------------------------------------------------------------- + +describe('Option.filter — predicate fails', () => { + const nm = some(-1); + const ox = OxSome(-1); + const fp = O.some(-1); + + bench('native-monad', () => { nm.filter(x => x > 0); }, STANDARD); + bench('oxide.ts', () => { ox.filter(x => x > 0); }, STANDARD); + bench('fp-ts', () => { pipe(fp, O.filter(x => x > 0)); }, STANDARD); +}); diff --git a/benchmarks/option/type-guards.bench.ts b/benchmarks/option/type-guards.bench.ts new file mode 100644 index 0000000..e8f31fa --- /dev/null +++ b/benchmarks/option/type-guards.bench.ts @@ -0,0 +1,66 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + some, + none, + TsSome, + TsNone, + OxSome, + OxNone, + O, + TmMaybe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// isSome — on Some +// --------------------------------------------------------------------------- + +describe('Option.isSome — on Some', () => { + const nm = some(42); + const ts = TsSome(42); + const ox = OxSome(42); + const fp = O.some(42); + const tm = TmMaybe.just(42); + + bench('native-monad', () => { nm.isSome(); }, STANDARD); + bench('ts-results-es', () => { ts.isSome(); }, STANDARD); + bench('oxide.ts', () => { ox.isSome(); }, STANDARD); + bench('fp-ts', () => { O.isSome(fp); }, STANDARD); + bench('true-myth', () => { tm.isJust; }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// isSome — on None +// --------------------------------------------------------------------------- + +describe('Option.isSome — on None', () => { + const nm = none(); + const ts = TsNone; + const ox = OxNone; + const fp = O.none; + const tm = TmMaybe.nothing(); + + bench('native-monad', () => { nm.isSome(); }, STANDARD); + bench('ts-results-es', () => { ts.isSome(); }, STANDARD); + bench('oxide.ts', () => { ox.isSome(); }, STANDARD); + bench('fp-ts', () => { O.isSome(fp); }, STANDARD); + bench('true-myth', () => { tm.isJust; }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// isNone — on None +// --------------------------------------------------------------------------- + +describe('Option.isNone — on None', () => { + const nm = none(); + const ts = TsNone; + const ox = OxNone; + const fp = O.none; + const tm = TmMaybe.nothing(); + + bench('native-monad', () => { nm.isNone(); }, STANDARD); + bench('ts-results-es', () => { ts.isNone(); }, STANDARD); + bench('oxide.ts', () => { ox.isNone(); }, STANDARD); + bench('fp-ts', () => { O.isNone(fp); }, STANDARD); + bench('true-myth', () => { tm.isNothing; }, STANDARD); +}); diff --git a/benchmarks/package.json b/benchmarks/package.json new file mode 100644 index 0000000..d3d9e8c --- /dev/null +++ b/benchmarks/package.json @@ -0,0 +1,25 @@ +{ + "name": "native-monad-benchmarks", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "prebench": "cd .. && pnpm build", + "bench": "vitest bench", + "bench:parallel": "vitest bench result/ > results/result.txt 2>&1 & vitest bench option/ > results/option.txt 2>&1 & vitest bench query/ > results/query.txt 2>&1 & vitest bench scenarios/ > results/scenarios.txt 2>&1 & wait && cat results/result.txt results/option.txt results/query.txt results/scenarios.txt", + "bench:result": "vitest bench result/", + "bench:option": "vitest bench option/", + "bench:query": "vitest bench query/", + "bench:scenarios": "vitest bench scenarios/" + }, + "devDependencies": { + "native-monad": "file:..", + "neverthrow": "^8.0.0", + "ts-results-es": "^4.0.0", + "oxide.ts": "^1.1.0", + "fp-ts": "^2.16.0", + "true-myth": "^7.0.0", + "vitest": "^3.2.4", + "typescript": "^5.7.0" + } +} diff --git a/benchmarks/pnpm-lock.yaml b/benchmarks/pnpm-lock.yaml new file mode 100644 index 0000000..964cf22 --- /dev/null +++ b/benchmarks/pnpm-lock.yaml @@ -0,0 +1,1046 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + fp-ts: + specifier: ^2.16.0 + version: 2.16.11 + native-monad: + specifier: file:.. + version: file:.. + neverthrow: + specifier: ^8.0.0 + version: 8.2.0 + oxide.ts: + specifier: ^1.1.0 + version: 1.1.0 + true-myth: + specifier: ^7.0.0 + version: 7.4.0 + ts-results-es: + specifier: ^4.0.0 + version: 4.2.0 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4 + +packages: + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fp-ts@2.16.11: + resolution: {integrity: sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + native-monad@file:..: + resolution: {directory: .., type: directory} + engines: {node: '>=18.0.0'} + + neverthrow@8.2.0: + resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} + engines: {node: '>=18'} + + oxide.ts@1.1.0: + resolution: {integrity: sha512-+MkqFRQVHEe/x4/cJ6KuYz2m2VpnoBi7aKLbttGYTxmpNZalQ2RbKH2HxyfsTqXJhjh9DYxulPWfQV/hWMmzCg==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + true-myth@7.4.0: + resolution: {integrity: sha512-HRaZmfP3Y6JY2qb6M2gndkfb8dpwk6M4xSWD3i1JXUabmur9SaBNcf+oZ1ARU+51qoDVLcLCsjKN9UQwiW2ojQ==} + engines: {node: 18.* || >= 20.*} + + ts-results-es@4.2.0: + resolution: {integrity: sha512-GfpRk+qvHxa/6gADH8WMN/jXvs5oHYbKtMQc6X9L3VhToy5Lri3iQowyYSytaRcvPDiTT2z3vurzQZXFQFXKRA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fp-ts@2.16.11: {} + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + native-monad@file:..: {} + + neverthrow@8.2.0: + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': 4.60.0 + + oxide.ts@1.1.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + true-myth@7.4.0: {} + + ts-results-es@4.2.0: {} + + typescript@5.9.3: {} + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1: + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.4: + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/benchmarks/query/async.bench.ts b/benchmarks/query/async.bench.ts new file mode 100644 index 0000000..a039798 --- /dev/null +++ b/benchmarks/query/async.bench.ts @@ -0,0 +1,42 @@ +import { bench, describe } from 'vitest'; +import { EXTENDED } from '../helpers/bench-options.js'; +import { + okSome, + okNone, + QueryPromise, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// QueryPromise.from — returns value +// --------------------------------------------------------------------------- + +describe('QueryPromise.from — returns value', () => { + bench('native-monad', async () => { + await QueryPromise.from(() => Promise.resolve(42)); + }, EXTENDED); + // No other library has a three-state async wrapper +}); + +// --------------------------------------------------------------------------- +// QueryPromise.from — returns null +// --------------------------------------------------------------------------- + +describe('QueryPromise.from — returns null', () => { + bench('native-monad', async () => { + await QueryPromise.from(() => Promise.resolve(null)); + }, EXTENDED); +}); + +// --------------------------------------------------------------------------- +// QueryPromise.all — all OkSome +// --------------------------------------------------------------------------- + +for (const size of [10, 100] as const) { + describe(`QueryPromise.all — ${size} items, all OkSome`, () => { + const promises = () => Array.from({ length: size }, (_, i) => Promise.resolve(okSome(i))); + + bench('native-monad', async () => { + await QueryPromise.all(promises()); + }, EXTENDED); + }); +} diff --git a/benchmarks/query/chaining.bench.ts b/benchmarks/query/chaining.bench.ts new file mode 100644 index 0000000..a8e4ab6 --- /dev/null +++ b/benchmarks/query/chaining.bench.ts @@ -0,0 +1,128 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + okSome, + okNone, + errNone, + E, + O, + pipe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// 5-step pipeline — OkSome path +// --------------------------------------------------------------------------- + +describe('Query chaining — 5-step pipeline (all OkSome)', () => { + bench('native-monad', () => { + okSome(42) + .map(x => x * 2) + .flatMap(x => (x > 0 ? okSome(x) : okNone())) + .map(x => ({ value: x })) + .flatMap(x => okSome({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('fp-ts (nested)', () => { + pipe( + E.right(O.some(42)) as E.Either>, + E.map(O.map(x => x * 2)), + E.flatMap(inner => + pipe( + inner, + O.match( + () => E.right(O.none) as E.Either>, + v => (v > 0 ? E.right(O.some(v)) : E.right(O.none)) as E.Either>, + ), + ), + ), + E.map(O.map(x => ({ value: x }))), + E.flatMap(inner => + pipe( + inner, + O.match( + () => E.right(O.none) as E.Either>, + v => E.right(O.some({ ...v, label: 'result' })), + ), + ), + ), + E.map(O.map(x => JSON.stringify(x))), + ); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// ErrNone short-circuit — 9 no-op maps +// --------------------------------------------------------------------------- + +describe('Query chaining — ErrNone at step 1, 9 no-op maps', () => { + const nm = errNone('fail'); + const fp = E.left('fail') as E.Either>; + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + nm.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('fp-ts (nested)', () => { + pipe( + fp, + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + ); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// OkNone short-circuit — 9 no-op maps +// --------------------------------------------------------------------------- + +describe('Query chaining — OkNone, 9 no-op maps', () => { + const nm = okNone(); + const fp = E.right(O.none) as E.Either>; + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + nm.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('fp-ts (nested)', () => { + pipe( + fp, + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + E.map(O.map(fn)), + ); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// 20-step deep map chain — OkSome path +// --------------------------------------------------------------------------- + +describe('Query chaining — 20 maps on OkSome', () => { + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + let r = okSome(0); + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('fp-ts (nested)', () => { + let r = E.right(O.some(0)) as E.Either>; + for (let i = 0; i < 20; i++) r = pipe(r, E.map(O.map(fn))); + }, STANDARD); +}); diff --git a/benchmarks/query/collection.bench.ts b/benchmarks/query/collection.bench.ts new file mode 100644 index 0000000..e153660 --- /dev/null +++ b/benchmarks/query/collection.bench.ts @@ -0,0 +1,144 @@ +import { bench, describe } from 'vitest'; +import { STANDARD, EXTENDED } from '../helpers/bench-options.js'; +import { SIZES, errorIndices } from '../helpers/data-generators.js'; +import { + okSome, + okNone, + errNone, + Query, + E, + O, + pipe, +} from '../helpers/imports.js'; +import * as RA from 'fp-ts/ReadonlyArray'; + +// --------------------------------------------------------------------------- +// Query.all — all OkSome +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + + const nmAll = Array.from({ length: size }, (_, i) => okSome(i)); + const fpAll = Array.from({ length: size }, (_, i) => + E.right(O.some(i)) as E.Either>, + ); + + describe(`Query.all — ${size} items, all OkSome`, () => { + bench('native-monad', () => { Query.all(nmAll); }, opts); + bench('fp-ts (nested, manual)', () => { + const values: number[] = []; + for (const item of fpAll) { + if (E.isLeft(item)) return item; + if (O.isNone(item.right)) continue; + values.push(item.right.value); + } + return E.right(O.some(values)); + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Query.all — mixed (OkSome + OkNone + ErrNone) +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const errIdx = errorIndices(size, 0.1); + const noneIdx = errorIndices(size, 0.15); + + const nmMixed = Array.from({ length: size }, (_, i) => { + if (errIdx.has(i)) return errNone('fail'); + if (noneIdx.has(i)) return okNone(); + return okSome(i); + }); + + const fpMixed = Array.from({ length: size }, (_, i) => { + if (errIdx.has(i)) return E.left('fail') as E.Either>; + if (noneIdx.has(i)) return E.right(O.none) as E.Either>; + return E.right(O.some(i)) as E.Either>; + }); + + describe(`Query.all — ${size} items, mixed (short-circuit)`, () => { + bench('native-monad', () => { Query.all(nmMixed); }, opts); + bench('fp-ts (nested, manual)', () => { + const values: number[] = []; + for (const item of fpMixed) { + if (E.isLeft(item)) return item; + if (O.isNone(item.right)) continue; + values.push(item.right.value); + } + return E.right(O.some(values)); + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Query.partition +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const errIdx = errorIndices(size, 0.1); + const noneIdx = errorIndices(size, 0.15); + + const nmMixed = Array.from({ length: size }, (_, i) => { + if (errIdx.has(i)) return errNone('fail'); + if (noneIdx.has(i)) return okNone(); + return okSome(i); + }); + + const fpMixed = Array.from({ length: size }, (_, i) => { + if (errIdx.has(i)) return E.left('fail') as E.Either>; + if (noneIdx.has(i)) return E.right(O.none) as E.Either>; + return E.right(O.some(i)) as E.Either>; + }); + + describe(`Query.partition — ${size} items`, () => { + bench('native-monad', () => { Query.partition(nmMixed); }, opts); + bench('fp-ts (nested, manual)', () => { + const values: number[] = []; + const errors: string[] = []; + let noneCount = 0; + for (const item of fpMixed) { + if (E.isLeft(item)) { errors.push(item.left); continue; } + if (O.isNone(item.right)) { noneCount++; continue; } + values.push(item.right.value); + } + return { values, noneCount, errors }; + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Query.compact +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const errIdx = errorIndices(size, 0.1); + const noneIdx = errorIndices(size, 0.15); + + const nmMixed = Array.from({ length: size }, (_, i) => { + if (errIdx.has(i)) return errNone('fail'); + if (noneIdx.has(i)) return okNone(); + return okSome(i); + }); + + const fpMixed = Array.from({ length: size }, (_, i) => { + if (errIdx.has(i)) return E.left('fail') as E.Either>; + if (noneIdx.has(i)) return E.right(O.none) as E.Either>; + return E.right(O.some(i)) as E.Either>; + }); + + describe(`Query.compact — ${size} items`, () => { + bench('native-monad', () => { Query.compact(nmMixed); }, opts); + bench('fp-ts (nested, manual)', () => { + const values: number[] = []; + for (const item of fpMixed) { + if (E.isRight(item) && O.isSome(item.right)) values.push(item.right.value); + } + return values; + }, opts); + }); +} diff --git a/benchmarks/query/construction.bench.ts b/benchmarks/query/construction.bench.ts new file mode 100644 index 0000000..064b6bb --- /dev/null +++ b/benchmarks/query/construction.bench.ts @@ -0,0 +1,65 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + okSome, + okNone, + errNone, + TsOk, + TsErr, + TsSome, + TsNone, + OxOk, + OxErr, + OxSome, + OxNone, + E, + O, + TmResult, + TmMaybe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// OkSome — vs nested Result, E> +// --------------------------------------------------------------------------- + +describe('Query construction — OkSome (primitive)', () => { + bench('native-monad', () => { okSome(42); }, STANDARD); + bench('ts-results-es (nested)', () => { TsOk(TsSome(42)); }, STANDARD); + bench('oxide.ts (nested)', () => { OxOk(OxSome(42)); }, STANDARD); + bench('fp-ts (nested)', () => { E.right(O.some(42)); }, STANDARD); + bench('true-myth (nested)', () => { TmResult.ok(TmMaybe.just(42)); }, STANDARD); +}); + +describe('Query construction — OkSome (object payload)', () => { + const value = { id: 1, name: 'Alice' }; + + bench('native-monad', () => { okSome(value); }, STANDARD); + bench('ts-results-es (nested)', () => { TsOk(TsSome(value)); }, STANDARD); + bench('oxide.ts (nested)', () => { OxOk(OxSome(value)); }, STANDARD); + bench('fp-ts (nested)', () => { E.right(O.some(value)); }, STANDARD); + bench('true-myth (nested)', () => { TmResult.ok(TmMaybe.just(value)); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// OkNone — vs nested Result, E> +// --------------------------------------------------------------------------- + +describe('Query construction — OkNone (singleton)', () => { + bench('native-monad', () => { okNone(); }, STANDARD); + bench('ts-results-es (nested)', () => { TsOk(TsNone); }, STANDARD); + bench('oxide.ts (nested)', () => { OxOk(OxNone); }, STANDARD); + bench('fp-ts (nested)', () => { E.right(O.none); }, STANDARD); + bench('true-myth (nested)', () => { TmResult.ok(TmMaybe.nothing()); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// ErrNone — vs nested Err +// --------------------------------------------------------------------------- + +describe('Query construction — ErrNone', () => { + bench('native-monad', () => { errNone('fail'); }, STANDARD); + bench('ts-results-es (nested)', () => { TsErr('fail'); }, STANDARD); + bench('oxide.ts (nested)', () => { OxErr('fail'); }, STANDARD); + bench('fp-ts (nested)', () => { E.left('fail'); }, STANDARD); + bench('true-myth (nested)', () => { TmResult.err('fail'); }, STANDARD); +}); diff --git a/benchmarks/query/instance-methods.bench.ts b/benchmarks/query/instance-methods.bench.ts new file mode 100644 index 0000000..5f10977 --- /dev/null +++ b/benchmarks/query/instance-methods.bench.ts @@ -0,0 +1,172 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + okSome, + okNone, + errNone, + E, + O, + pipe, +} from '../helpers/imports.js'; + +const double = (x: number) => x * 2; +const toUpper = (e: string) => e.toUpperCase(); + +// --------------------------------------------------------------------------- +// map — OkSome path +// --------------------------------------------------------------------------- + +describe('Query.map — OkSome path', () => { + const nm = okSome(42); + const fp = E.right(O.some(42)); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('fp-ts (nested)', () => { + pipe(fp, E.map(O.map(double))); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// map — OkNone path (no-op) +// --------------------------------------------------------------------------- + +describe('Query.map — OkNone path (no-op)', () => { + const nm = okNone(); + const fp = E.right(O.none); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('fp-ts (nested)', () => { + pipe(fp, E.map(O.map(double))); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// map — ErrNone path (no-op) +// --------------------------------------------------------------------------- + +describe('Query.map — ErrNone path (no-op)', () => { + const nm = errNone('fail'); + const fp = E.left('fail'); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('fp-ts (nested)', () => { + pipe(fp, E.map(O.map(double))); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// flatMap — OkSome path +// --------------------------------------------------------------------------- + +describe('Query.flatMap — OkSome path', () => { + const nm = okSome(42); + const fp = E.right(O.some(42)); + + bench('native-monad', () => { nm.flatMap(x => okSome(x * 2)); }, STANDARD); + bench('fp-ts (nested)', () => { + pipe( + fp, + E.flatMap(inner => + pipe( + inner, + O.match( + () => E.right(O.none) as E.Either>, + v => E.right(O.some(v * 2)), + ), + ), + ), + ); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// mapErr — ErrNone path +// --------------------------------------------------------------------------- + +describe('Query.mapErr — ErrNone path', () => { + const nm = errNone('fail'); + const fp = E.left('fail'); + + bench('native-monad', () => { nm.mapErr(toUpper); }, STANDARD); + bench('fp-ts (nested)', () => { + pipe(fp, E.mapLeft(toUpper)); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// match — three-way +// --------------------------------------------------------------------------- + +describe('Query.match — OkSome (three-way dispatch)', () => { + const nm = okSome(42); + const fp = E.right(O.some(42)); + + bench('native-monad', () => { + nm.match(v => v * 2, () => 0, () => -1); + }, STANDARD); + + bench('fp-ts (nested, two match calls)', () => { + pipe( + fp, + E.match( + () => -1, + O.match(() => 0, v => v * 2), + ), + ); + }, STANDARD); +}); + +describe('Query.match — OkNone (three-way dispatch)', () => { + const nm = okNone(); + const fp = E.right(O.none); + + bench('native-monad', () => { + nm.match(() => 1, () => 0, () => -1); + }, STANDARD); + + bench('fp-ts (nested)', () => { + pipe( + fp, + E.match( + () => -1, + O.match(() => 0, () => 1), + ), + ); + }, STANDARD); +}); + +describe('Query.match — ErrNone (three-way dispatch)', () => { + const nm = errNone('fail'); + const fp = E.left('fail'); + + bench('native-monad', () => { + nm.match(() => 1, () => 0, e => e.length); + }, STANDARD); + + bench('fp-ts (nested)', () => { + pipe( + fp, + E.match( + e => e.length, + O.match(() => 0, () => 1), + ), + ); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// filter — OkSome path +// --------------------------------------------------------------------------- + +describe('Query.filter — predicate passes', () => { + const nm = okSome(42); + + bench('native-monad', () => { nm.filter(x => x > 0); }, STANDARD); + // No other library has a three-state filter +}); + +describe('Query.filter — predicate fails', () => { + const nm = okSome(-1); + + bench('native-monad', () => { nm.filter(x => x > 0); }, STANDARD); +}); diff --git a/benchmarks/result/async.bench.ts b/benchmarks/result/async.bench.ts new file mode 100644 index 0000000..2c0ad6c --- /dev/null +++ b/benchmarks/result/async.bench.ts @@ -0,0 +1,70 @@ +import { bench, describe } from 'vitest'; +import { EXTENDED } from '../helpers/bench-options.js'; +import { SIZES } from '../helpers/data-generators.js'; +import { + ok, + err, + Result, + ResultPromise, + ntOk, + ntErr, + NtResultAsync, + E, + pipe, +} from '../helpers/imports.js'; +import * as TE from 'fp-ts/TaskEither'; + +// --------------------------------------------------------------------------- +// ResultPromise.from — success +// --------------------------------------------------------------------------- + +describe('ResultPromise.from — success', () => { + bench('native-monad', async () => { + await ResultPromise.from(() => Promise.resolve(42)); + }, EXTENDED); + + bench('neverthrow', async () => { + await NtResultAsync.fromPromise(Promise.resolve(42), () => 'err'); + }, EXTENDED); + + bench('fp-ts', async () => { + await TE.tryCatch(() => Promise.resolve(42), () => 'err')(); + }, EXTENDED); +}); + +// --------------------------------------------------------------------------- +// ResultPromise.from — failure +// --------------------------------------------------------------------------- + +describe('ResultPromise.from — failure', () => { + bench('native-monad', async () => { + await ResultPromise.from(() => Promise.reject(new Error('fail')), () => 'err'); + }, EXTENDED); + + bench('neverthrow', async () => { + await NtResultAsync.fromPromise(Promise.reject(new Error('fail')), () => 'err'); + }, EXTENDED); + + bench('fp-ts', async () => { + await TE.tryCatch(() => Promise.reject(new Error('fail')), () => 'err')(); + }, EXTENDED); +}); + +// --------------------------------------------------------------------------- +// ResultPromise.all — all Ok +// --------------------------------------------------------------------------- + +for (const size of [10, 100] as const) { + describe(`ResultPromise.all — ${size} items, all Ok`, () => { + const nmPromises = () => Array.from({ length: size }, (_, i) => Promise.resolve(ok(i))); + const ntPromises = () => Array.from({ length: size }, (_, i) => NtResultAsync.fromSafePromise(Promise.resolve(i))); + + bench('native-monad', async () => { + await ResultPromise.all(nmPromises()); + }, EXTENDED); + + bench('neverthrow', async () => { + await NtResultAsync.combine(ntPromises()); + }, EXTENDED); + }); +} diff --git a/benchmarks/result/chaining.bench.ts b/benchmarks/result/chaining.bench.ts new file mode 100644 index 0000000..3d1da25 --- /dev/null +++ b/benchmarks/result/chaining.bench.ts @@ -0,0 +1,153 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + ntOk, + ntErr, + TsOk, + TsErr, + OxOk, + OxErr, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// 5-step pipeline — success path +// --------------------------------------------------------------------------- + +describe('Result chaining — 5-step pipeline (all succeed)', () => { + bench('native-monad', () => { + ok(42) + .map(x => x * 2) + .flatMap(x => (x > 0 ? ok(x) : err('negative'))) + .map(x => ({ value: x })) + .flatMap(x => ok({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('neverthrow', () => { + ntOk(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? ntOk(x) : ntErr('negative'))) + .map(x => ({ value: x })) + .andThen(x => ntOk({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('ts-results-es', () => { + TsOk(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? TsOk(x) : TsErr('negative'))) + .map(x => ({ value: x })) + .andThen(x => TsOk<{ value: number; label: string }, string>({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('oxide.ts', () => { + OxOk(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? OxOk(x) : OxErr('negative'))) + .map(x => ({ value: x })) + .andThen(x => OxOk({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + E.right(42), + E.map(x => x * 2), + E.flatMap(x => (x > 0 ? E.right(x) : E.left('negative'))), + E.map(x => ({ value: x })), + E.flatMap(x => E.right({ ...x, label: 'result' })), + E.map(x => JSON.stringify(x)), + ); + }, STANDARD); + + bench('true-myth', () => { + TmResult.ok(42) + .map(x => x * 2) + .andThen(x => (x > 0 ? TmResult.ok(x) : TmResult.err('negative'))) + .map(x => ({ value: x })) + .andThen(x => TmResult.ok({ ...x, label: 'result' })) + .map(x => JSON.stringify(x)); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// 10-step pipeline — error at step 1 (9 no-ops) +// --------------------------------------------------------------------------- + +describe('Result chaining — error at step 1, 9 no-op maps', () => { + const nmErr = err('fail'); + const ntErrVal = ntErr('fail'); + const tsErr = TsErr('fail'); + const oxErr = OxErr('fail'); + const fpErr = E.left('fail'); + const tmErr = TmResult.err('fail'); + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + nmErr.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('neverthrow', () => { + ntErrVal.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('ts-results-es', () => { + tsErr.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('oxide.ts', () => { + oxErr.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); + + bench('fp-ts', () => { + pipe(fpErr, E.map(fn), E.map(fn), E.map(fn), E.map(fn), E.map(fn), E.map(fn), E.map(fn), E.map(fn), E.map(fn)); + }, STANDARD); + + bench('true-myth', () => { + tmErr.map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn).map(fn); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// 20-step deep map chain — success path +// --------------------------------------------------------------------------- + +describe('Result chaining — 20 maps on Ok', () => { + const fn = (x: number) => x + 1; + + bench('native-monad', () => { + let r = ok(0); + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('neverthrow', () => { + let r = ntOk(0); + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('ts-results-es', () => { + let r = TsOk(0); + for (let i = 0; i < 20; i++) r = r.map(fn) as any; + }, STANDARD); + + bench('oxide.ts', () => { + let r = OxOk(0); + for (let i = 0; i < 20; i++) r = r.map(fn) as any; + }, STANDARD); + + bench('fp-ts', () => { + let r = E.right(0) as E.Either; + for (let i = 0; i < 20; i++) r = pipe(r, E.map(fn)); + }, STANDARD); + + bench('true-myth', () => { + let r = TmResult.ok(0); + for (let i = 0; i < 20; i++) r = r.map(fn) as any; + }, STANDARD); +}); diff --git a/benchmarks/result/collection.bench.ts b/benchmarks/result/collection.bench.ts new file mode 100644 index 0000000..6b3980d --- /dev/null +++ b/benchmarks/result/collection.bench.ts @@ -0,0 +1,177 @@ +import { bench, describe } from 'vitest'; +import { STANDARD, EXTENDED } from '../helpers/bench-options.js'; +import { SIZES, errorIndices } from '../helpers/data-generators.js'; +import { + ok, + err, + Result, + ntOk, + ntErr, + NtResult, + TsOk, + TsErr, + TsResult, + OxOk, + OxErr, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; +import * as RA from 'fp-ts/ReadonlyArray'; + +// --------------------------------------------------------------------------- +// Result.all — all Ok +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + + const nmAll = Array.from({ length: size }, (_, i) => ok(i)); + const ntAll = Array.from({ length: size }, (_, i) => ntOk(i)); + const tsAll = Array.from({ length: size }, (_, i) => TsOk(i)); + const oxAll = Array.from({ length: size }, (_, i) => OxOk(i)); + const fpAll = Array.from({ length: size }, (_, i) => E.right(i)); + const tmAll = Array.from({ length: size }, (_, i) => TmResult.ok(i)); + + describe(`Result.all — ${size} items, all Ok`, () => { + bench('native-monad', () => { Result.all(nmAll); }, opts); + bench('neverthrow', () => { NtResult.combine(ntAll); }, opts); + bench('ts-results-es', () => { TsResult.all(...tsAll); }, opts); + // oxide.ts: no built-in all/combine + bench('fp-ts', () => { pipe(fpAll, RA.sequence(E.Applicative)); }, opts); + // true-myth: no built-in all + bench('true-myth (manual)', () => { + const values: number[] = []; + for (const r of tmAll) { + if (TmResult.isErr(r)) return r; + values.push((r as { value: number }).value); + } + return TmResult.ok(values); + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Result.all — 20% Err (short-circuit) +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const errIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? err('fail') : ok(i), + ); + const ntMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? ntErr('fail') : ntOk(i), + ); + const tsMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? TsErr('fail') : TsOk(i), + ); + const fpMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? E.left('fail' as const) : E.right(i), + ); + const tmMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? TmResult.err('fail') : TmResult.ok(i), + ); + + describe(`Result.all — ${size} items, 20% Err (short-circuit)`, () => { + bench('native-monad', () => { Result.all(nmMixed); }, opts); + bench('neverthrow', () => { NtResult.combine(ntMixed as any); }, opts); + bench('ts-results-es', () => { TsResult.all(...tsMixed); }, opts); + bench('fp-ts', () => { pipe(fpMixed, RA.sequence(E.Applicative)); }, opts); + bench('true-myth (manual)', () => { + const values: number[] = []; + for (const r of tmMixed) { + if (TmResult.isErr(r)) return r; + values.push((r as { value: number }).value); + } + return TmResult.ok(values); + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Result.partition +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const errIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? err('fail') : ok(i), + ); + const ntMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? ntErr('fail') : ntOk(i), + ); + const fpMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? E.left('fail' as const) : E.right(i), + ); + + describe(`Result.partition — ${size} items`, () => { + bench('native-monad', () => { Result.partition(nmMixed); }, opts); + bench('neverthrow (manual)', () => { + const values: number[] = []; + const errors: string[] = []; + for (const r of ntMixed) { + if (r.isOk()) values.push(r.value); + else errors.push(r.error); + } + return { values, errors }; + }, opts); + bench('fp-ts', () => { RA.separate(fpMixed); }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Result.compact +// --------------------------------------------------------------------------- + +for (const size of SIZES) { + const opts = size >= 10_000 ? EXTENDED : STANDARD; + const errIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? err('fail') : ok(i), + ); + const ntMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? ntErr('fail') : ntOk(i), + ); + + describe(`Result.compact — ${size} items`, () => { + bench('native-monad', () => { Result.compact(nmMixed); }, opts); + bench('neverthrow (manual)', () => { + const values: number[] = []; + for (const r of ntMixed) { + if (r.isOk()) values.push(r.value); + } + return values; + }, opts); + }); +} + +// --------------------------------------------------------------------------- +// Result.some / Result.every +// --------------------------------------------------------------------------- + +for (const size of [100, 1_000]) { + const errIdx = errorIndices(size, 0.2); + + const nmMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? err('fail') : ok(i), + ); + const ntMixed = Array.from({ length: size }, (_, i) => + errIdx.has(i) ? ntErr('fail') : ntOk(i), + ); + + describe(`Result.some — ${size} items`, () => { + bench('native-monad', () => { Result.some(nmMixed); }, STANDARD); + bench('neverthrow (manual)', () => { ntMixed.some(r => r.isOk()); }, STANDARD); + }); + + describe(`Result.every — ${size} items`, () => { + bench('native-monad', () => { Result.every(nmMixed); }, STANDARD); + bench('neverthrow (manual)', () => { ntMixed.every(r => r.isOk()); }, STANDARD); + }); +} diff --git a/benchmarks/result/construction.bench.ts b/benchmarks/result/construction.bench.ts new file mode 100644 index 0000000..d735cea --- /dev/null +++ b/benchmarks/result/construction.bench.ts @@ -0,0 +1,59 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + ntOk, + ntErr, + TsOk, + TsErr, + OxOk, + OxErr, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// Ok / Right / success construction +// --------------------------------------------------------------------------- + +describe('Result construction — Ok (primitive)', () => { + bench('native-monad', () => { ok(42); }, STANDARD); + bench('neverthrow', () => { ntOk(42); }, STANDARD); + bench('ts-results-es', () => { TsOk(42); }, STANDARD); + bench('oxide.ts', () => { OxOk(42); }, STANDARD); + bench('fp-ts', () => { E.right(42); }, STANDARD); + bench('true-myth', () => { TmResult.ok(42); }, STANDARD); +}); + +describe('Result construction — Err (primitive)', () => { + bench('native-monad', () => { err('fail'); }, STANDARD); + bench('neverthrow', () => { ntErr('fail'); }, STANDARD); + bench('ts-results-es', () => { TsErr('fail'); }, STANDARD); + bench('oxide.ts', () => { OxErr('fail'); }, STANDARD); + bench('fp-ts', () => { E.left('fail'); }, STANDARD); + bench('true-myth', () => { TmResult.err('fail'); }, STANDARD); +}); + +describe('Result construction — Ok (object payload)', () => { + const value = { id: 1, name: 'Alice', roles: ['admin', 'user'] }; + + bench('native-monad', () => { ok(value); }, STANDARD); + bench('neverthrow', () => { ntOk(value); }, STANDARD); + bench('ts-results-es', () => { TsOk(value); }, STANDARD); + bench('oxide.ts', () => { OxOk(value); }, STANDARD); + bench('fp-ts', () => { E.right(value); }, STANDARD); + bench('true-myth', () => { TmResult.ok(value); }, STANDARD); +}); + +describe('Result construction — Err (Error object)', () => { + const error = new Error('something went wrong'); + + bench('native-monad', () => { err(error); }, STANDARD); + bench('neverthrow', () => { ntErr(error); }, STANDARD); + bench('ts-results-es', () => { TsErr(error); }, STANDARD); + bench('oxide.ts', () => { OxErr(error); }, STANDARD); + bench('fp-ts', () => { E.left(error); }, STANDARD); + bench('true-myth', () => { TmResult.err(error); }, STANDARD); +}); diff --git a/benchmarks/result/conversion.bench.ts b/benchmarks/result/conversion.bench.ts new file mode 100644 index 0000000..c55a813 --- /dev/null +++ b/benchmarks/result/conversion.bench.ts @@ -0,0 +1,68 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + ntOk, + ntErr, + TsOk, + TsErr, + OxOk, + OxErr, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// toNullable — success path +// --------------------------------------------------------------------------- + +describe('Result.toNullable — Ok', () => { + const nm = ok(42); + const ox = OxOk(42); + + bench('native-monad', () => { nm.toNullable(); }, STANDARD); + // neverthrow: no toNullable — use match or _unsafeUnwrap + // ts-results-es: no toNullable + bench('oxide.ts', () => { ox.into(); }, STANDARD); + bench('fp-ts', () => { pipe(E.right(42), E.toUnion); }, STANDARD); + bench('true-myth', () => { TmResult.ok(42).isOk ? 42 : null; }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// toNullable — failure path +// --------------------------------------------------------------------------- + +describe('Result.toNullable — Err', () => { + const nm = err('fail'); + const ox = OxErr('fail'); + + bench('native-monad', () => { nm.toNullable(); }, STANDARD); + bench('oxide.ts', () => { ox.into(); }, STANDARD); + bench('fp-ts', () => { pipe(E.left('fail'), E.toUnion); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// toString +// --------------------------------------------------------------------------- + +describe('Result.toString — Ok', () => { + const nm = ok(42); + const ox = OxOk(42); + + bench('native-monad', () => { nm.toString(); }, STANDARD); + // neverthrow: no toString + // ts-results-es: no toString + bench('oxide.ts', () => { ox.toString(); }, STANDARD); + // fp-ts: no toString + // true-myth: no toString +}); + +describe('Result.toString — Err', () => { + const nm = err('fail'); + const ox = OxErr('fail'); + + bench('native-monad', () => { nm.toString(); }, STANDARD); + bench('oxide.ts', () => { ox.toString(); }, STANDARD); +}); diff --git a/benchmarks/result/from-wrapping.bench.ts b/benchmarks/result/from-wrapping.bench.ts new file mode 100644 index 0000000..bdd2dd8 --- /dev/null +++ b/benchmarks/result/from-wrapping.bench.ts @@ -0,0 +1,39 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + Result, + ntFromThrowable, + E, + TmResult, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// Result.from — success (no throw) +// --------------------------------------------------------------------------- + +describe('Result.from — success (no throw)', () => { + const fn = () => 42; + + bench('native-monad', () => { Result.from(fn); }, STANDARD); + bench('neverthrow', () => { ntFromThrowable(fn)(); }, STANDARD); + bench('fp-ts', () => { E.tryCatch(fn, () => 'error'); }, STANDARD); + bench('true-myth', () => { TmResult.tryOr('error', fn); }, STANDARD); + // ts-results-es: no from/tryCatch + // oxide.ts: no from/tryCatch +}); + +// --------------------------------------------------------------------------- +// Result.from — failure (throws) +// --------------------------------------------------------------------------- + +describe('Result.from — failure (throws)', () => { + const thrower = () => { + throw new Error('fail'); + }; + const mapErr = () => 'error' as const; + + bench('native-monad', () => { Result.from(thrower, mapErr); }, STANDARD); + bench('neverthrow', () => { ntFromThrowable(thrower, mapErr)(); }, STANDARD); + bench('fp-ts', () => { E.tryCatch(thrower, mapErr); }, STANDARD); + bench('true-myth', () => { TmResult.tryOr('error', thrower); }, STANDARD); +}); diff --git a/benchmarks/result/instance-methods.bench.ts b/benchmarks/result/instance-methods.bench.ts new file mode 100644 index 0000000..ddb0ed2 --- /dev/null +++ b/benchmarks/result/instance-methods.bench.ts @@ -0,0 +1,208 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + ntOk, + ntErr, + TsOk, + TsErr, + OxOk, + OxErr, + oxMatch, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; + +const double = (x: number) => x * 2; +const toUpper = (e: string) => e.toUpperCase(); + +// --------------------------------------------------------------------------- +// map — success path +// --------------------------------------------------------------------------- + +describe('Result.map — success path', () => { + const nm = ok(42); + const nt = ntOk(42); + const ts = TsOk(42); + const ox = OxOk(42); + const fp = E.right(42); + const tm = TmResult.ok(42); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('neverthrow', () => { nt.map(double); }, STANDARD); + bench('ts-results-es', () => { ts.map(double); }, STANDARD); + bench('oxide.ts', () => { ox.map(double); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.map(double)); }, STANDARD); + bench('true-myth', () => { tm.map(double); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// map — failure path (no-op) +// --------------------------------------------------------------------------- + +describe('Result.map — failure path (no-op)', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail'); + const tm = TmResult.err('fail'); + + bench('native-monad', () => { nm.map(double); }, STANDARD); + bench('neverthrow', () => { nt.map(double); }, STANDARD); + bench('ts-results-es', () => { ts.map(double); }, STANDARD); + bench('oxide.ts', () => { ox.map(double); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.map(double)); }, STANDARD); + bench('true-myth', () => { tm.map(double); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// flatMap — success path +// --------------------------------------------------------------------------- + +describe('Result.flatMap — success path', () => { + const nm = ok(42); + const nt = ntOk(42); + const ts = TsOk(42); + const ox = OxOk(42); + const fp = E.right(42); + const tm = TmResult.ok(42); + + bench('native-monad', () => { nm.flatMap(x => ok(x * 2)); }, STANDARD); + bench('neverthrow', () => { nt.andThen(x => ntOk(x * 2)); }, STANDARD); + bench('ts-results-es', () => { ts.andThen(x => TsOk(x * 2)); }, STANDARD); + bench('oxide.ts', () => { ox.andThen(x => OxOk(x * 2)); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.flatMap(x => E.right(x * 2))); }, STANDARD); + bench('true-myth', () => { tm.andThen(x => TmResult.ok(x * 2)); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// flatMap — failure path (no-op) +// --------------------------------------------------------------------------- + +describe('Result.flatMap — failure path (no-op)', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail'); + const tm = TmResult.err('fail'); + + bench('native-monad', () => { nm.flatMap(x => ok(x * 2)); }, STANDARD); + bench('neverthrow', () => { nt.andThen(x => ntOk(x * 2)); }, STANDARD); + bench('ts-results-es', () => { ts.andThen(x => TsOk(x * 2)); }, STANDARD); + bench('oxide.ts', () => { ox.andThen(x => OxOk(x * 2)); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.flatMap(x => E.right(x * 2))); }, STANDARD); + bench('true-myth', () => { tm.andThen(x => TmResult.ok(x * 2)); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// mapErr — error path +// --------------------------------------------------------------------------- + +describe('Result.mapErr — error path', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail'); + const tm = TmResult.err('fail'); + + bench('native-monad', () => { nm.mapErr(toUpper); }, STANDARD); + bench('neverthrow', () => { nt.mapErr(toUpper); }, STANDARD); + bench('ts-results-es', () => { ts.mapErr(toUpper); }, STANDARD); + bench('oxide.ts', () => { ox.mapErr(toUpper); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.mapLeft(toUpper)); }, STANDARD); + bench('true-myth', () => { tm.mapErr(toUpper); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// mapErr — success path (no-op) +// --------------------------------------------------------------------------- + +describe('Result.mapErr — success path (no-op)', () => { + const nm = ok(42); + const nt = ntOk(42); + const ts = TsOk(42); + const ox = OxOk(42); + const fp = E.right(42); + const tm = TmResult.ok(42); + + bench('native-monad', () => { nm.mapErr(toUpper); }, STANDARD); + bench('neverthrow', () => { nt.mapErr(toUpper); }, STANDARD); + bench('ts-results-es', () => { ts.mapErr(toUpper); }, STANDARD); + bench('oxide.ts', () => { ox.mapErr(toUpper); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.mapLeft(toUpper)); }, STANDARD); + bench('true-myth', () => { tm.mapErr(toUpper); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// match — success path +// --------------------------------------------------------------------------- + +describe('Result.match — success path', () => { + const nm = ok(42); + const nt = ntOk(42); + const ox = OxOk(42); + const fp = E.right(42); + const tm = TmResult.ok(42); + + bench('native-monad', () => { nm.match(x => x * 2, () => 0); }, STANDARD); + bench('neverthrow', () => { nt.match(x => x * 2, () => 0); }, STANDARD); + // ts-results-es has no match — skip + bench('oxide.ts', () => { oxMatch(ox, { Ok: (x: number) => x * 2, Err: () => 0 }); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.match(() => 0, x => x * 2)); }, STANDARD); + bench('true-myth', () => { tm.match({ Ok: x => x * 2, Err: () => 0 }); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// match — failure path +// --------------------------------------------------------------------------- + +describe('Result.match — failure path', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail'); + const tm = TmResult.err('fail'); + + bench('native-monad', () => { nm.match(() => 0, e => e.length); }, STANDARD); + bench('neverthrow', () => { nt.match(() => 0, e => e.length); }, STANDARD); + bench('oxide.ts', () => { oxMatch(ox, { Ok: () => 0, Err: (e: string) => e.length }); }, STANDARD); + bench('fp-ts', () => { pipe(fp, E.match(e => e.length, () => 0)); }, STANDARD); + bench('true-myth', () => { tm.match({ Ok: () => 0, Err: e => e.length }); }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// filter — success path (predicate passes) +// --------------------------------------------------------------------------- + +describe('Result.filter — predicate passes', () => { + const nm = ok(42); + const fp = E.right(42); + + bench('native-monad', () => { nm.filter(x => x > 0, () => 'negative'); }, STANDARD); + // neverthrow: no filter — skip + // ts-results-es: no filter — skip + // oxide.ts: filter returns Option, not Result — different semantics, skip + bench('fp-ts', () => { + pipe(fp, E.filterOrElse(x => x > 0, () => 'negative' as const)); + }, STANDARD); + // true-myth: no filter — skip +}); + +// --------------------------------------------------------------------------- +// filter — predicate fails +// --------------------------------------------------------------------------- + +describe('Result.filter — predicate fails', () => { + const nm = ok(-1); + const fp = E.right(-1); + + bench('native-monad', () => { nm.filter(x => x > 0, () => 'negative'); }, STANDARD); + bench('fp-ts', () => { + pipe(fp, E.filterOrElse(x => x > 0, () => 'negative' as const)); + }, STANDARD); +}); diff --git a/benchmarks/result/type-guards.bench.ts b/benchmarks/result/type-guards.bench.ts new file mode 100644 index 0000000..f7b086e --- /dev/null +++ b/benchmarks/result/type-guards.bench.ts @@ -0,0 +1,74 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + ntOk, + ntErr, + TsOk, + TsErr, + OxOk, + OxErr, + E, + TmResult, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// isOk — on Ok variant +// --------------------------------------------------------------------------- + +describe('Result.isOk — on Ok', () => { + const nm = ok(42); + const nt = ntOk(42); + const ts = TsOk(42); + const ox = OxOk(42); + const fp = E.right(42); + const tm = TmResult.ok(42); + + bench('native-monad', () => { nm.isOk(); }, STANDARD); + bench('neverthrow', () => { nt.isOk(); }, STANDARD); + bench('ts-results-es', () => { ts.isOk(); }, STANDARD); + bench('oxide.ts', () => { ox.isOk(); }, STANDARD); + bench('fp-ts', () => { E.isRight(fp); }, STANDARD); + bench('true-myth', () => { tm.isOk; }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// isOk — on Err variant +// --------------------------------------------------------------------------- + +describe('Result.isOk — on Err', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail'); + const tm = TmResult.err('fail'); + + bench('native-monad', () => { nm.isOk(); }, STANDARD); + bench('neverthrow', () => { nt.isOk(); }, STANDARD); + bench('ts-results-es', () => { ts.isOk(); }, STANDARD); + bench('oxide.ts', () => { ox.isOk(); }, STANDARD); + bench('fp-ts', () => { E.isRight(fp); }, STANDARD); + bench('true-myth', () => { tm.isOk; }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// isErr — on Err variant +// --------------------------------------------------------------------------- + +describe('Result.isErr — on Err', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail'); + const tm = TmResult.err('fail'); + + bench('native-monad', () => { nm.isErr(); }, STANDARD); + bench('neverthrow', () => { nt.isErr(); }, STANDARD); + bench('ts-results-es', () => { ts.isErr(); }, STANDARD); + bench('oxide.ts', () => { ox.isErr(); }, STANDARD); + bench('fp-ts', () => { E.isLeft(fp); }, STANDARD); + bench('true-myth', () => { tm.isErr; }, STANDARD); +}); diff --git a/benchmarks/scenarios/error-propagation.bench.ts b/benchmarks/scenarios/error-propagation.bench.ts new file mode 100644 index 0000000..2518c35 --- /dev/null +++ b/benchmarks/scenarios/error-propagation.bench.ts @@ -0,0 +1,145 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + none, + ntOk, + ntErr, + TsOk, + TsErr, + TsNone, + OxOk, + OxErr, + OxNone, + E, + O, + pipe, + TmResult, + TmMaybe, +} from '../helpers/imports.js'; + +const fn = (x: number) => x + 1; + +// --------------------------------------------------------------------------- +// Error propagation through 20 map calls +// --------------------------------------------------------------------------- + +describe('Scenario: Err through 20 map calls', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail') as E.Either; + const tm = TmResult.err('fail'); + + bench('native-monad', () => { + let r = nm as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('neverthrow', () => { + let r = nt as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('ts-results-es', () => { + let r = ts as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('oxide.ts', () => { + let r = ox as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('fp-ts', () => { + let r = fp; + for (let i = 0; i < 20; i++) r = pipe(r, E.map(fn)); + }, STANDARD); + + bench('true-myth', () => { + let r = tm as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// Error propagation through 20 flatMap calls +// --------------------------------------------------------------------------- + +describe('Scenario: Err through 20 flatMap calls', () => { + const nm = err('fail'); + const nt = ntErr('fail'); + const ts = TsErr('fail'); + const ox = OxErr('fail'); + const fp = E.left('fail') as E.Either; + const tm = TmResult.err('fail'); + + bench('native-monad', () => { + let r = nm as any; + for (let i = 0; i < 20; i++) r = r.flatMap((x: number) => ok(x + 1)); + }, STANDARD); + + bench('neverthrow', () => { + let r = nt as any; + for (let i = 0; i < 20; i++) r = r.andThen((x: number) => ntOk(x + 1)); + }, STANDARD); + + bench('ts-results-es', () => { + let r = ts as any; + for (let i = 0; i < 20; i++) r = r.andThen((x: number) => TsOk(x + 1)); + }, STANDARD); + + bench('oxide.ts', () => { + let r = ox as any; + for (let i = 0; i < 20; i++) r = r.andThen((x: number) => OxOk(x + 1)); + }, STANDARD); + + bench('fp-ts', () => { + let r = fp; + for (let i = 0; i < 20; i++) r = pipe(r, E.flatMap(x => E.right(x + 1))); + }, STANDARD); + + bench('true-myth', () => { + let r = tm as any; + for (let i = 0; i < 20; i++) r = r.andThen((x: number) => TmResult.ok(x + 1)); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// None propagation through 20 map calls (Option) +// --------------------------------------------------------------------------- + +describe('Scenario: None through 20 map calls', () => { + const nm = none(); + const ts = TsNone; + const ox = OxNone; + const fp = O.none as O.Option; + const tm = TmMaybe.nothing(); + + bench('native-monad', () => { + let r = nm as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('ts-results-es', () => { + let r = ts as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('oxide.ts', () => { + let r = ox as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); + + bench('fp-ts', () => { + let r = fp; + for (let i = 0; i < 20; i++) r = pipe(r, O.map(fn)); + }, STANDARD); + + bench('true-myth', () => { + let r = tm as any; + for (let i = 0; i < 20; i++) r = r.map(fn); + }, STANDARD); +}); diff --git a/benchmarks/scenarios/memory.bench.ts b/benchmarks/scenarios/memory.bench.ts new file mode 100644 index 0000000..806d265 --- /dev/null +++ b/benchmarks/scenarios/memory.bench.ts @@ -0,0 +1,171 @@ +import { bench, describe } from 'vitest'; +import { QUICK } from '../helpers/bench-options.js'; +import { + ok, + err, + some, + none, + okSome, + okNone, + errNone, + ntOk, + ntErr, + TsOk, + TsErr, + TsSome, + TsNone, + OxOk, + OxErr, + OxSome, + OxNone, + E, + O, + TmResult, + TmMaybe, +} from '../helpers/imports.js'; + +// --------------------------------------------------------------------------- +// Singleton reuse: None / OkNone +// --------------------------------------------------------------------------- + +describe('Memory: 1000x None creation (singleton vs constructor)', () => { + bench('native-monad none() — frozen singleton', () => { + for (let i = 0; i < 1000; i++) none(); + }, QUICK); + + bench('ts-results-es None — constant', () => { + for (let i = 0; i < 1000; i++) { const _ = TsNone; } + }, QUICK); + + bench('oxide.ts None — constant', () => { + for (let i = 0; i < 1000; i++) { const _ = OxNone; } + }, QUICK); + + bench('fp-ts O.none — constant', () => { + for (let i = 0; i < 1000; i++) { const _ = O.none; } + }, QUICK); + + bench('true-myth Maybe.nothing() — constructor', () => { + for (let i = 0; i < 1000; i++) TmMaybe.nothing(); + }, QUICK); +}); + +describe('Memory: 1000x OkNone creation', () => { + bench('native-monad okNone() — frozen singleton', () => { + for (let i = 0; i < 1000; i++) okNone(); + }, QUICK); + + bench('fp-ts E.right(O.none) — two allocations', () => { + for (let i = 0; i < 1000; i++) E.right(O.none); + }, QUICK); +}); + +// --------------------------------------------------------------------------- +// Constructor allocation: Ok / Some / OkSome +// --------------------------------------------------------------------------- + +describe('Memory: 1000x Ok construction', () => { + bench('native-monad ok(i) — class instance', () => { + for (let i = 0; i < 1000; i++) ok(i); + }, QUICK); + + bench('neverthrow ok(i) — class instance', () => { + for (let i = 0; i < 1000; i++) ntOk(i); + }, QUICK); + + bench('ts-results-es Ok(i) — class instance', () => { + for (let i = 0; i < 1000; i++) TsOk(i); + }, QUICK); + + bench('oxide.ts Ok(i) — tagged object', () => { + for (let i = 0; i < 1000; i++) OxOk(i); + }, QUICK); + + bench('fp-ts E.right(i) — tagged object', () => { + for (let i = 0; i < 1000; i++) E.right(i); + }, QUICK); + + bench('true-myth Result.ok(i) — class instance', () => { + for (let i = 0; i < 1000; i++) TmResult.ok(i); + }, QUICK); +}); + +describe('Memory: 1000x Some construction', () => { + bench('native-monad some(i)', () => { + for (let i = 0; i < 1000; i++) some(i); + }, QUICK); + + bench('ts-results-es Some(i)', () => { + for (let i = 0; i < 1000; i++) TsSome(i); + }, QUICK); + + bench('oxide.ts Some(i)', () => { + for (let i = 0; i < 1000; i++) OxSome(i); + }, QUICK); + + bench('fp-ts O.some(i)', () => { + for (let i = 0; i < 1000; i++) O.some(i); + }, QUICK); + + bench('true-myth Maybe.just(i)', () => { + for (let i = 0; i < 1000; i++) TmMaybe.just(i); + }, QUICK); +}); + +describe('Memory: 1000x Err construction', () => { + bench('native-monad err(i)', () => { + for (let i = 0; i < 1000; i++) err(`err-${i}`); + }, QUICK); + + bench('neverthrow err(i)', () => { + for (let i = 0; i < 1000; i++) ntErr(`err-${i}`); + }, QUICK); + + bench('ts-results-es Err(i)', () => { + for (let i = 0; i < 1000; i++) TsErr(`err-${i}`); + }, QUICK); + + bench('oxide.ts Err(i)', () => { + for (let i = 0; i < 1000; i++) OxErr(`err-${i}`); + }, QUICK); + + bench('fp-ts E.left(i)', () => { + for (let i = 0; i < 1000; i++) E.left(`err-${i}`); + }, QUICK); + + bench('true-myth Result.err(i)', () => { + for (let i = 0; i < 1000; i++) TmResult.err(`err-${i}`); + }, QUICK); +}); + +// --------------------------------------------------------------------------- +// Three-state: OkSome vs nested allocation +// --------------------------------------------------------------------------- + +describe('Memory: 1000x OkSome (single vs nested allocation)', () => { + bench('native-monad okSome(i) — single class', () => { + for (let i = 0; i < 1000; i++) okSome(i); + }, QUICK); + + bench('fp-ts E.right(O.some(i)) — two objects', () => { + for (let i = 0; i < 1000; i++) E.right(O.some(i)); + }, QUICK); + + bench('ts-results-es Ok(Some(i)) — two classes', () => { + for (let i = 0; i < 1000; i++) TsOk(TsSome(i)); + }, QUICK); +}); + +describe('Memory: 1000x ErrNone (single vs nested allocation)', () => { + bench('native-monad errNone(i) — single class', () => { + for (let i = 0; i < 1000; i++) errNone(`err-${i}`); + }, QUICK); + + bench('fp-ts E.left(i) — single object', () => { + for (let i = 0; i < 1000; i++) E.left(`err-${i}`); + }, QUICK); + + bench('ts-results-es Err(i) — single class', () => { + for (let i = 0; i < 1000; i++) TsErr(`err-${i}`); + }, QUICK); +}); diff --git a/benchmarks/scenarios/parsing.bench.ts b/benchmarks/scenarios/parsing.bench.ts new file mode 100644 index 0000000..7064b84 --- /dev/null +++ b/benchmarks/scenarios/parsing.bench.ts @@ -0,0 +1,127 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + Result, + ntOk, + ntErr, + ntFromThrowable, + TsOk, + TsErr, + OxOk, + OxErr, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; + +const validJson = '{"name":"Alice","age":30,"email":"alice@example.com"}'; +const invalidJson = 'not json {{{'; + +// --------------------------------------------------------------------------- +// JSON parsing — valid input +// --------------------------------------------------------------------------- + +describe('Scenario: JSON parse + extract field — valid input', () => { + bench('native-monad', () => { + Result.from(() => JSON.parse(validJson), () => 'parse error') + .map(obj => obj.name as string) + .flatMap(name => (name.length > 0 ? ok(name) : err('empty name'))); + }, STANDARD); + + bench('neverthrow', () => { + ntFromThrowable(() => JSON.parse(validJson), () => 'parse error')() + .map(obj => obj.name as string) + .andThen(name => (name.length > 0 ? ntOk(name) : ntErr('empty name'))); + }, STANDARD); + + bench('ts-results-es', () => { + let r; + try { r = TsOk(JSON.parse(validJson)); } + catch { r = TsErr('parse error'); } + r.map((obj: any) => obj.name as string) + .andThen((name: string) => (name.length > 0 ? TsOk(name) : TsErr('empty name'))); + }, STANDARD); + + bench('oxide.ts', () => { + let r; + try { r = OxOk(JSON.parse(validJson)); } + catch { r = OxErr('parse error'); } + r.map((obj: any) => obj.name as string) + .andThen((name: string) => (name.length > 0 ? OxOk(name) : OxErr('parse error'))); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + E.tryCatch(() => JSON.parse(validJson), () => 'parse error'), + E.map(obj => obj.name as string), + E.flatMap(name => (name.length > 0 ? E.right(name) : E.left('empty name'))), + ); + }, STANDARD); + + bench('true-myth', () => { + TmResult.tryOr('parse error', () => JSON.parse(validJson)) + .map(obj => obj.name as string) + .andThen(name => (name.length > 0 ? TmResult.ok(name) : TmResult.err('empty name'))); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// JSON parsing — invalid input +// --------------------------------------------------------------------------- + +describe('Scenario: JSON parse + extract field — invalid input', () => { + bench('native-monad', () => { + Result.from(() => JSON.parse(invalidJson), () => 'parse error') + .map(obj => obj.name as string) + .flatMap(name => (name.length > 0 ? ok(name) : err('empty name'))); + }, STANDARD); + + bench('neverthrow', () => { + ntFromThrowable(() => JSON.parse(invalidJson), () => 'parse error')() + .map(obj => obj.name as string) + .andThen(name => (name.length > 0 ? ntOk(name) : ntErr('empty name'))); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + E.tryCatch(() => JSON.parse(invalidJson), () => 'parse error'), + E.map(obj => obj.name as string), + E.flatMap(name => (name.length > 0 ? E.right(name) : E.left('empty name'))), + ); + }, STANDARD); + + bench('true-myth', () => { + TmResult.tryOr('parse error', () => JSON.parse(invalidJson)) + .map(obj => obj.name as string) + .andThen(name => (name.length > 0 ? TmResult.ok(name) : TmResult.err('empty name'))); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// Number parsing — valid +// --------------------------------------------------------------------------- + +describe('Scenario: number parsing — valid', () => { + const input = '42.5'; + + bench('native-monad', () => { + Result.from(() => { + const n = Number(input); + if (Number.isNaN(n)) throw new Error('NaN'); + return n; + }).flatMap(n => (n > 0 ? ok(n) : err('not positive'))); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + E.tryCatch(() => { + const n = Number(input); + if (Number.isNaN(n)) throw new Error('NaN'); + return n; + }, () => 'parse error'), + E.flatMap(n => (n > 0 ? E.right(n) : E.left('not positive'))), + ); + }, STANDARD); +}); diff --git a/benchmarks/scenarios/validation-pipeline.bench.ts b/benchmarks/scenarios/validation-pipeline.bench.ts new file mode 100644 index 0000000..ea872f0 --- /dev/null +++ b/benchmarks/scenarios/validation-pipeline.bench.ts @@ -0,0 +1,148 @@ +import { bench, describe } from 'vitest'; +import { STANDARD } from '../helpers/bench-options.js'; +import { + ok, + err, + ntOk, + ntErr, + TsOk, + TsErr, + OxOk, + OxErr, + E, + pipe, + TmResult, +} from '../helpers/imports.js'; + +interface User { + name: string; + email: string; + age: number; +} + +const validUser: User = { name: 'Alice', email: 'alice@example.com', age: 30 }; +const invalidUser: User = { name: '', email: 'alice@example.com', age: 30 }; + +// --------------------------------------------------------------------------- +// 5-check validation — all pass +// --------------------------------------------------------------------------- + +describe('Scenario: 5-check validation pipeline — all pass', () => { + bench('native-monad', () => { + ok(validUser) + .flatMap(u => (u.name ? ok(u) : err('name required'))) + .flatMap(u => (u.email.includes('@') ? ok(u) : err('invalid email'))) + .flatMap(u => (u.age >= 18 ? ok(u) : err('must be 18+'))) + .flatMap(u => (u.age <= 120 ? ok(u) : err('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('neverthrow', () => { + ntOk(validUser) + .andThen(u => (u.name ? ntOk(u) : ntErr('name required'))) + .andThen(u => (u.email.includes('@') ? ntOk(u) : ntErr('invalid email'))) + .andThen(u => (u.age >= 18 ? ntOk(u) : ntErr('must be 18+'))) + .andThen(u => (u.age <= 120 ? ntOk(u) : ntErr('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('ts-results-es', () => { + TsOk(validUser) + .andThen(u => (u.name ? TsOk(u) : TsErr('name required'))) + .andThen(u => (u.email.includes('@') ? TsOk(u) : TsErr('invalid email'))) + .andThen(u => (u.age >= 18 ? TsOk(u) : TsErr('must be 18+'))) + .andThen(u => (u.age <= 120 ? TsOk(u) : TsErr('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('oxide.ts', () => { + OxOk(validUser) + .andThen(u => (u.name ? OxOk(u) : OxErr('name required'))) + .andThen(u => (u.email.includes('@') ? OxOk(u) : OxErr('invalid email'))) + .andThen(u => (u.age >= 18 ? OxOk(u) : OxErr('must be 18+'))) + .andThen(u => (u.age <= 120 ? OxOk(u) : OxErr('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + E.right(validUser), + E.flatMap(u => (u.name ? E.right(u) : E.left('name required'))), + E.flatMap(u => (u.email.includes('@') ? E.right(u) : E.left('invalid email'))), + E.flatMap(u => (u.age >= 18 ? E.right(u) : E.left('must be 18+'))), + E.flatMap(u => (u.age <= 120 ? E.right(u) : E.left('unrealistic age'))), + E.map(u => ({ ...u, validated: true })), + ); + }, STANDARD); + + bench('true-myth', () => { + TmResult.ok(validUser) + .andThen(u => (u.name ? TmResult.ok(u) : TmResult.err('name required'))) + .andThen(u => (u.email.includes('@') ? TmResult.ok(u) : TmResult.err('invalid email'))) + .andThen(u => (u.age >= 18 ? TmResult.ok(u) : TmResult.err('must be 18+'))) + .andThen(u => (u.age <= 120 ? TmResult.ok(u) : TmResult.err('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); +}); + +// --------------------------------------------------------------------------- +// 5-check validation — fails at step 1 (early exit) +// --------------------------------------------------------------------------- + +describe('Scenario: 5-check validation pipeline — fails at step 1', () => { + bench('native-monad', () => { + ok(invalidUser) + .flatMap(u => (u.name ? ok(u) : err('name required'))) + .flatMap(u => (u.email.includes('@') ? ok(u) : err('invalid email'))) + .flatMap(u => (u.age >= 18 ? ok(u) : err('must be 18+'))) + .flatMap(u => (u.age <= 120 ? ok(u) : err('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('neverthrow', () => { + ntOk(invalidUser) + .andThen(u => (u.name ? ntOk(u) : ntErr('name required'))) + .andThen(u => (u.email.includes('@') ? ntOk(u) : ntErr('invalid email'))) + .andThen(u => (u.age >= 18 ? ntOk(u) : ntErr('must be 18+'))) + .andThen(u => (u.age <= 120 ? ntOk(u) : ntErr('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('ts-results-es', () => { + TsOk(invalidUser) + .andThen(u => (u.name ? TsOk(u) : TsErr('name required'))) + .andThen(u => (u.email.includes('@') ? TsOk(u) : TsErr('invalid email'))) + .andThen(u => (u.age >= 18 ? TsOk(u) : TsErr('must be 18+'))) + .andThen(u => (u.age <= 120 ? TsOk(u) : TsErr('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('oxide.ts', () => { + OxOk(invalidUser) + .andThen(u => (u.name ? OxOk(u) : OxErr('name required'))) + .andThen(u => (u.email.includes('@') ? OxOk(u) : OxErr('invalid email'))) + .andThen(u => (u.age >= 18 ? OxOk(u) : OxErr('must be 18+'))) + .andThen(u => (u.age <= 120 ? OxOk(u) : OxErr('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); + + bench('fp-ts', () => { + pipe( + E.right(invalidUser), + E.flatMap(u => (u.name ? E.right(u) : E.left('name required'))), + E.flatMap(u => (u.email.includes('@') ? E.right(u) : E.left('invalid email'))), + E.flatMap(u => (u.age >= 18 ? E.right(u) : E.left('must be 18+'))), + E.flatMap(u => (u.age <= 120 ? E.right(u) : E.left('unrealistic age'))), + E.map(u => ({ ...u, validated: true })), + ); + }, STANDARD); + + bench('true-myth', () => { + TmResult.ok(invalidUser) + .andThen(u => (u.name ? TmResult.ok(u) : TmResult.err('name required'))) + .andThen(u => (u.email.includes('@') ? TmResult.ok(u) : TmResult.err('invalid email'))) + .andThen(u => (u.age >= 18 ? TmResult.ok(u) : TmResult.err('must be 18+'))) + .andThen(u => (u.age <= 120 ? TmResult.ok(u) : TmResult.err('unrealistic age'))) + .map(u => ({ ...u, validated: true })); + }, STANDARD); +}); diff --git a/benchmarks/tsconfig.json b/benchmarks/tsconfig.json new file mode 100644 index 0000000..8c8016e --- /dev/null +++ b/benchmarks/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["es2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["**/*.bench.ts", "helpers/**/*.ts"] +} diff --git a/benchmarks/vitest.config.ts b/benchmarks/vitest.config.ts new file mode 100644 index 0000000..0a455b0 --- /dev/null +++ b/benchmarks/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + benchmark: { + include: ['**/*.bench.ts'], + }, + }, +}); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..5a08091 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,14 @@ +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage', 'node_modules'] }, + ...tseslint.configs.recommended, + { + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-namespace': 'off', + 'max-len': ['warn', { code: 120 }], + }, + }, +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ddc3f95 --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "native-monad", + "version": "0.0.5", + "description": "TypeScript monad library using native JS/TS idioms - Result, Option, and Query types", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "files": [ + "dist" + ], + "engines": { + "node": ">=18.0.0" + }, + "scripts": { + "build": "tsup", + "test": "vitest run --project unit", + "test:types": "vitest run --project types", + "test:watch": "vitest", + "lint": "eslint .", + "typecheck": "tsc --noEmit", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepublishOnly": "rm -rf dist && tsup" + }, + "devDependencies": { + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@types/node": "^22.0.0", + "eslint": "^9.0.0", + "prettier": "^3.0.0", + "tsup": "^8.0.0", + "typescript": "^5.7.0", + "typescript-eslint": "^8.0.0", + "vitest": "^3.2.4" + }, + "keywords": [ + "monad", + "result", + "option", + "typescript", + "functional" + ], + "license": "MIT", + "sideEffects": false, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..63556ce --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2289 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@rolldown/binding-linux-x64-gnu': + specifier: 1.0.0-rc.12 + version: 1.0.0-rc.12 + '@types/node': + specifier: ^22.0.0 + version: 22.19.15 + eslint: + specifier: ^9.0.0 + version: 9.39.4 + prettier: + specifier: ^3.0.0 + version: 3.8.1 + tsup: + specifier: ^8.0.0 + version: 8.5.1(postcss@8.5.8)(typescript@5.9.3) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + typescript-eslint: + specifier: ^8.0.0 + version: 8.57.2(eslint@9.39.4)(typescript@5.9.3) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.19.15)(lightningcss@1.32.0) + +packages: + + '@esbuild/aix-ppc64@0.27.4': + resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.4': + resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.4': + resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.4': + resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.4': + resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.4': + resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.4': + resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.4': + resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.4': + resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.4': + resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.4': + resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.4': + resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.4': + resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.4': + resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.4': + resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.4': + resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.4': + resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.4': + resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.4': + resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.4': + resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.4': + resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.4': + resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.4': + resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.4': + resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.4': + resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.4': + resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': + resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + + '@typescript-eslint/eslint-plugin@8.57.2': + resolution: {integrity: sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.57.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.57.2': + resolution: {integrity: sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.57.2': + resolution: {integrity: sha512-FuH0wipFywXRTHf+bTTjNyuNQQsQC3qh/dYzaM4I4W0jrCqjCVuUh99+xd9KamUfmCGPvbO8NDngo/vsnNVqgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.57.2': + resolution: {integrity: sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.57.2': + resolution: {integrity: sha512-3Lm5DSM+DCowsUOJC+YqHHnKEfFh5CoGkj5Z31NQSNF4l5wdOwqGn99wmwN/LImhfY3KJnmordBq/4+VDe2eKw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.57.2': + resolution: {integrity: sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.57.2': + resolution: {integrity: sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.57.2': + resolution: {integrity: sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.57.2': + resolution: {integrity: sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.57.2': + resolution: {integrity: sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.4: + resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.57.2: + resolution: {integrity: sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.27.4': + optional: true + + '@esbuild/android-arm64@0.27.4': + optional: true + + '@esbuild/android-arm@0.27.4': + optional: true + + '@esbuild/android-x64@0.27.4': + optional: true + + '@esbuild/darwin-arm64@0.27.4': + optional: true + + '@esbuild/darwin-x64@0.27.4': + optional: true + + '@esbuild/freebsd-arm64@0.27.4': + optional: true + + '@esbuild/freebsd-x64@0.27.4': + optional: true + + '@esbuild/linux-arm64@0.27.4': + optional: true + + '@esbuild/linux-arm@0.27.4': + optional: true + + '@esbuild/linux-ia32@0.27.4': + optional: true + + '@esbuild/linux-loong64@0.27.4': + optional: true + + '@esbuild/linux-mips64el@0.27.4': + optional: true + + '@esbuild/linux-ppc64@0.27.4': + optional: true + + '@esbuild/linux-riscv64@0.27.4': + optional: true + + '@esbuild/linux-s390x@0.27.4': + optional: true + + '@esbuild/linux-x64@0.27.4': + optional: true + + '@esbuild/netbsd-arm64@0.27.4': + optional: true + + '@esbuild/netbsd-x64@0.27.4': + optional: true + + '@esbuild/openbsd-arm64@0.27.4': + optional: true + + '@esbuild/openbsd-x64@0.27.4': + optional: true + + '@esbuild/openharmony-arm64@0.27.4': + optional: true + + '@esbuild/sunos-x64@0.27.4': + optional: true + + '@esbuild/win32-arm64@0.27.4': + optional: true + + '@esbuild/win32-ia32@0.27.4': + optional: true + + '@esbuild/win32-x64@0.27.4': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/type-utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + + '@typescript-eslint/tsconfig-utils@8.57.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.57.2': {} + + '@typescript-eslint/typescript-estree@8.57.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.57.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/visitor-keys': 8.57.2 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.57.2(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.57.2 + '@typescript-eslint/types': 8.57.2 + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.57.2': + dependencies: + '@typescript-eslint/types': 8.57.2 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(lightningcss@1.32.0) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.13: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.4): + dependencies: + esbuild: 0.27.4 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + detect-libc@2.1.2: + optional: true + + es-module-lexer@1.7.0: {} + + esbuild@0.27.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.4 + '@esbuild/android-arm': 0.27.4 + '@esbuild/android-arm64': 0.27.4 + '@esbuild/android-x64': 0.27.4 + '@esbuild/darwin-arm64': 0.27.4 + '@esbuild/darwin-x64': 0.27.4 + '@esbuild/freebsd-arm64': 0.27.4 + '@esbuild/freebsd-x64': 0.27.4 + '@esbuild/linux-arm': 0.27.4 + '@esbuild/linux-arm64': 0.27.4 + '@esbuild/linux-ia32': 0.27.4 + '@esbuild/linux-loong64': 0.27.4 + '@esbuild/linux-mips64el': 0.27.4 + '@esbuild/linux-ppc64': 0.27.4 + '@esbuild/linux-riscv64': 0.27.4 + '@esbuild/linux-s390x': 0.27.4 + '@esbuild/linux-x64': 0.27.4 + '@esbuild/netbsd-arm64': 0.27.4 + '@esbuild/netbsd-x64': 0.27.4 + '@esbuild/openbsd-arm64': 0.27.4 + '@esbuild/openbsd-x64': 0.27.4 + '@esbuild/openharmony-arm64': 0.27.4 + '@esbuild/sunos-x64': 0.27.4 + '@esbuild/win32-arm64': 0.27.4 + '@esbuild/win32-ia32': 0.27.4 + '@esbuild/win32-x64': 0.27.4 + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + expect-type@1.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.60.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + optional: true + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.13 + + mlly@1.8.2: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + object-assign@4.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.8): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.8 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.1: {} + + punycode@2.3.1: {} + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + semver@7.7.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.8)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.4) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.4 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.8) + resolve-from: 5.0.0 + rollup: 4.60.0 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.8 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.57.2(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.57.2(@typescript-eslint/parser@8.57.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.57.2(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4(@types/node@22.19.15)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.15)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.15)(lightningcss@1.32.0): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + lightningcss: 1.32.0 + + vitest@3.2.4(@types/node@22.19.15)(lightningcss@1.32.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(lightningcss@1.32.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.15)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@22.19.15)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..61a7756 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +export * from './result'; +export * from './option'; +export * from './query'; diff --git a/src/option/index.ts b/src/option/index.ts new file mode 100644 index 0000000..6105b25 --- /dev/null +++ b/src/option/index.ts @@ -0,0 +1,533 @@ +import { isNullish } from '../util'; + +/** + * @template T The type of the contained value. + */ +export interface OptionInterface { + /** + * Determines whether this option contains a value. + * @returns `true` if this option contains a value, `false` otherwise. + * ```ts + * some(42).isSome() // true + * none().isSome() // false + * ``` + */ + isSome(): this is Some; + + /** + * Determines whether this option is empty. + * @returns `true` if this option is empty, `false` otherwise. + * ```ts + * some(42).isNone() // false + * none().isNone() // true + * ``` + */ + isNone(): this is None; + + /** + * Transforms the contained value by applying a function to it. + * @param fn Function to apply to the contained value. + * @returns A new option containing the transformed value, or None if this option is empty. + * @remarks When called on a known `Some`, returns `Some`. When called on a known `None`, returns `None` — the function is never invoked. + * ```ts + * some(5).map(x => x * 2) // Some(10) + * none().map(x => x * 2) // None + * ``` + */ + map(fn: (value: T) => U): Option; + + /** + * Applies a function that returns an option to the contained value and flattens the result. + * @param fn Function that takes the contained value and returns an option. + * @returns The option returned by the function, or None if this option is empty. + * ```ts + * some(5).flatMap(x => some(x * 2)) // Some(10) + * some(5).flatMap(x => none()) // None + * none().flatMap(x => some(x * 2)) // None + * ``` + */ + flatMap(fn: (value: T) => Option): Option; + + /** + * Executes one of two functions based on whether this option contains a value. + * @param onSome Function to execute if this option contains a value. + * @param onNone Function to execute if this option is empty. + * @returns The return value of whichever function is executed. `U` is inferred as the union of both callback return types. + * ```ts + * some(42).match(x => `Value: ${x}`, () => 'No value') // 'Value: 42' + * none().match(x => `Value: ${x}`, () => 'No value') // 'No value' + * ``` + */ + match(onSome: (value: T) => U, onNone: () => U): U; + + /** + * Filters this option based on a type predicate, narrowing the value type on success. + * @param predicate Type predicate function to test the contained value. + * @returns Some with the narrowed type if the predicate passes, None otherwise. A no-op on None. + * @remarks This overload resolves when the predicate is a type guard (`value is U`), narrowing `T` to `U`. + * ```ts + * some(42 as number | string).filter((x): x is number => typeof x === 'number') // Some(42): Option + * some('hello' as number | string).filter((x): x is number => typeof x === 'number') // None + * ``` + */ + filter(predicate: (value: T) => value is U): Option; + + /** + * Filters this option based on a boolean predicate. + * @param predicate Function to test the contained value. + * @returns Some if the value satisfies the predicate, None otherwise. A no-op on None. + * @remarks This overload resolves when the predicate returns `boolean` (not a type guard). The value type `T` is preserved. + * ```ts + * some(5).filter(x => x > 3) // Some(5) + * some(2).filter(x => x > 3) // None + * none().filter(x => x > 3) // None + * ``` + */ + filter(predicate: (value: T) => boolean): Option; + + /** + * Returns the contained value or null. + * @returns The contained value if this option is Some, null otherwise. + * ```ts + * some(42).toNullable() // 42 + * none().toNullable() // null + * ``` + */ + toNullable(): T | null; + + /** + * Returns the contained value or undefined. + * @returns The contained value if this option is Some, undefined otherwise. + * ```ts + * some(42).toUndefined() // 42 + * none().toUndefined() // undefined + * ``` + */ + toUndefined(): T | undefined; + + /** + * Returns a string representation of this option. + * @returns A string indicating whether this option is Some or None, and the contained value if applicable. + * ```ts + * some(42).toString() // 'Some(42)' + * none().toString() // 'None()' + * ``` + */ + toString(): string; +} + +/** + * Represents an option containing a value of type `T`. + * + * All methods that operate on the value (`map`, `flatMap`, `filter`) invoke their callbacks. + * @remarks The contained value is accessible via the `.value` property, either directly or after narrowing with `isSome()`. + */ +export class Some implements OptionInterface { + constructor(readonly value: T) {} + + isSome(): this is Some { + return true; + } + + isNone(): this is never { + return false; + } + + map(fn: (value: T) => U): Some { + return some(fn(this.value)); + } + + flatMap>(fn: (value: T) => R): R { + return fn(this.value); + } + + match(onSome: (value: T) => U, onNone: () => U): U { + return onSome(this.value); + } + + filter(predicate: (value: T) => value is U): Option; + filter(predicate: (value: T) => boolean): Option; + filter(predicate: (value: T) => boolean): Option { + return predicate(this.value) ? this : none(); + } + + toNullable(): T { + return this.value; + } + + toUndefined(): T { + return this.value; + } + + toString(): string { + return `Some(${this.value})`; + } +} + +/** + * Represents an empty option containing no value. + * + * All transformation methods (`map`, `flatMap`, `filter`) are no-ops that return the `None` singleton + * without invoking their callbacks. Instantiated via `none()`, which always returns the same frozen instance. + */ +export class None implements OptionInterface { + isSome(): this is never { + return false; + } + + isNone(): this is None { + return true; + } + + map(): None { + return this; + } + + flatMap(): this { + return this; + } + + match(onSome: (value: never) => U, onNone: () => U): U { + return onNone(); + } + + filter(): None { + return this; + } + + toNullable(): null { + return null; + } + + toUndefined(): undefined { + return undefined; + } + + toString(): string { + return 'None()'; + } +} + +/** + * Represents an optional value that is either Some containing a value or None. + * @remarks Use `isSome()` or `isNone()` to check the option type and access the contained value via `.value`. + * ```ts + * const option = some(42); + * if (option.isSome()) { + * console.log(option.value); // 42 + * } + * + * const empty = none(); + * if (empty.isNone()) { + * console.log('No value'); + * } + * ``` + */ +export type Option = Some | None; + +/** + * Represents a promise that resolves to an Option. + */ +export type OptionPromise = Promise>; + +/** + * Represents a promise that resolves to Some. + */ +export type SomePromise = Promise>; + +/** + * Represents a promise that resolves to None. + */ +export type NonePromise = Promise; + +/** + * Represents the result of partitioning an array of options into values and none count. + */ +export type OptionPartitionResult = { values: T[]; noneCount: number }; + +/** + * Conditional type that maps a value type to its `Option.from()` return type. + * + * When `T` is only `null | undefined`, evaluates to `None`. Otherwise evaluates to `Option>`, + * where `NonNullable` strips `null` and `undefined` from the union. + */ +export type OptionFromType = [NonNullable] extends [never] ? None : Option>; + +/** + * Creates an option from a value, treating null/undefined as None. + */ +const optionFromValue = (value: T): OptionFromType => { + return isNullish(value) + ? (none() as OptionFromType) + : (some(value as NonNullable) as OptionFromType); +}; + +/** + * Creates an option containing the specified value. + * @param value The value to wrap in an option. + * @returns A Some containing the value. + * ```ts + * const option = some(42); + * if (option.isSome()) { + * console.log(option.value); // 42 + * } + * ``` + */ +export const some = (value: T): Some => new Some(value); + +const NONE_INSTANCE = Object.freeze(new None()); + +/** + * Returns the singleton None instance representing an empty option. + * @returns The None instance. + * ```ts + * none() // None() + * none() === none() // true (same instance) + * ``` + */ +export const none = (): None => NONE_INSTANCE; + +/** + * Creates an option from a nullable value or by executing a function. + * @param valueOrFn The value to convert to an option, or a function to execute whose return value is converted. + * @returns Some containing the value if it is not null or undefined, None otherwise. + * @remarks Only null and undefined are considered falsy. Empty strings, 0, and false create Some instances. + * When passed a function, executes it and wraps the return value — errors are not caught (Option has no error channel). + * ```ts + * Option.from(42) // Some(42) + * Option.from(0) // Some(0) + * Option.from(false) // Some(false) + * Option.from("") // Some("") — empty string is not considered falsy + * Option.from("hello" as string | null) // Some("hello") or None depending on runtime value — typed as Option + * Option.from(null) // None + * Option.from(undefined) // None + * Option.from(() => 42) // Some(42) — function overload + * Option.from(() => null) // None — function overload + * ``` + */ +/** + * @internal Type-level guard: use `OptionPromise.from()` for async functions. + */ +function optionFrom(fn: () => PromiseLike): 'ERROR: Option.from() is synchronous — use OptionPromise.from() for async functions'; +function optionFrom(fn: () => T): OptionFromType; +function optionFrom(value: T): OptionFromType; +function optionFrom(valueOrFn: T | (() => T)): any { + const value = typeof valueOrFn === 'function' ? (valueOrFn as () => T)() : valueOrFn; + return optionFromValue(value); +} + +/** + * Combines an array of options into a single option containing an array of values. + * @param options The array of options to combine. + * @returns Some containing an array of all values if all options are Some, None otherwise. + * @remarks Short-circuits on the first None encountered — remaining options are not evaluated. When all inputs are known `Some`, the return type narrows to `Some`. + * ```ts + * Option.all([some(1), some(2), some(3)]) // Some([1, 2, 3]) + * Option.all([some(1), none(), some(3)]) // None + * Option.all([]) // Some([]) + * ``` + */ +function optionAll(options: Some[]): Some; +function optionAll(options: Option[]): Option; +function optionAll(options: Option[]): Option { + const values = new Array(options.length); + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.isNone()) return none(); + values[i] = option.value; + } + return some(values); +} + +/** + * Combines an array of option promises into a single promise of an option containing an array of values. + * @param promises The array of option promises to combine. + * @returns A promise that resolves to Some containing an array of all values if all options are Some, None otherwise. + * @remarks Rejects if any input promise rejects. When all inputs are known `Promise`, the return type narrows to `Promise>`. + * ```ts + * await OptionPromise.all([Promise.resolve(some(1)), Promise.resolve(some(2))]) // Some([1, 2]) + * await OptionPromise.all([Promise.resolve(some(1)), Promise.resolve(none())]) // None + * ``` + */ +async function optionPromiseAll(promises: Promise>[]): Promise>; +async function optionPromiseAll(promises: Promise>[]): Promise>; +async function optionPromiseAll(promises: Promise>[]): Promise> { + const results = await Promise.all(promises); + return optionAll(results); +} + +/** + * Namespace providing static utility methods for working with collections of options. + */ +export const Option = { + from: optionFrom, + + /** + * Determines whether a value is an Option. + * @param value The value to test. + * @returns `true` if the value is an Option, `false` otherwise. + * @remarks This is a runtime `instanceof` check — it narrows to `Option` by default. The generic parameter `T` can be specified explicitly (e.g., `Option.isOption(x)`) but is caller-asserted, not runtime-validated. + * ```ts + * Option.isOption(some(42)) // true + * Option.isOption(none()) // true + * Option.isOption(42) // false + * ``` + */ + isOption(value: unknown): value is Option { + return value instanceof Some || value instanceof None; + }, + + all: optionAll, + + /** + * Partitions an array of options into values and a count of None instances. + * @param options The array of options to partition. + * @returns An object containing the extracted values and the count of None instances. + * ```ts + * Option.partition([some(1), none(), some(3)]) // { values: [1, 3], noneCount: 1 } + * Option.partition([some(1), some(2)]) // { values: [1, 2], noneCount: 0 } + * ``` + */ + partition(options: Option[]): OptionPartitionResult { + const values: T[] = []; + let noneCount = 0; + + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.isSome()) { + values.push(option.value); + } else { + noneCount++; + } + } + + return { values, noneCount }; + }, + + /** + * Extracts all values from Some instances in an array of options. + * @param options The array of options to compact. + * @returns An array containing only the values from Some instances. + * ```ts + * Option.compact([some(1), none(), some(3)]) // [1, 3] + * Option.compact([none(), none()]) // [] + * ``` + */ + compact(options: Option[]): T[] { + const values: T[] = []; + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + if (opt.isSome()) values.push(opt.value); + } + return values; + }, + + /** + * Determines whether any option in an array contains a value. + * @param options The array of options to test. + * @returns `true` if at least one option is Some, `false` otherwise. + * ```ts + * Option.some([some(1), none(), some(3)]) // true + * Option.some([none(), none()]) // false + * Option.some([]) // false + * ``` + */ + some(options: Option[]): boolean { + return options.some((opt) => opt.isSome()); + }, + + /** + * Determines whether all options in an array contain a value. + * @param options The array of options to test. + * @returns `true` if all options are Some, `false` otherwise. + * ```ts + * Option.every([some(1), some(2)]) // true + * Option.every([some(1), none()]) // false + * Option.every([]) // true (empty array is considered valid) + * ``` + */ + every(options: Option[]): boolean { + return options.every((opt) => opt.isSome()); + }, +} as const; + +/** + * Namespace providing async utility methods for working with collections of option promises. + */ +export const OptionPromise = { + /** + * Creates an option promise by evaluating an async function and wrapping the resolved value. + * @param fn Function to execute that returns a promise. + * @returns A promise that resolves to Some containing the resolved value if not null/undefined, None otherwise. + * @remarks Does not catch errors. If the promise rejects, the rejection propagates. + * ```ts + * await OptionPromise.from(() => Promise.resolve(42)) // Some(42) + * await OptionPromise.from(() => Promise.resolve(null)) // None + * await OptionPromise.from(() => Promise.resolve(undefined)) // None + * ``` + */ + async from(fn: () => Promise): Promise> { + const value = await fn(); + return optionFromValue(value); + }, + + all: optionPromiseAll, + + /** + * Partitions an array of option promises into values and a count of None instances. + * @param promises The array of option promises to partition. + * @returns A promise that resolves to an object containing the extracted values and the count of None instances. + * @remarks Rejects if any input promise rejects. + * ```ts + * await OptionPromise.partition([Promise.resolve(some(1)), Promise.resolve(none())]) // { values: [1], noneCount: 1 } + * ``` + */ + async partition(promises: Promise>[]): Promise> { + const resolved = await Promise.all(promises); + return Option.partition(resolved); + }, + + /** + * Extracts all values from Some instances in an array of option promises. + * @param promises The array of option promises to compact. + * @returns A promise that resolves to an array containing only the values from Some instances. + * @remarks Rejects if any input promise rejects. + * ```ts + * await OptionPromise.compact([Promise.resolve(some(1)), Promise.resolve(none())]) // [1] + * ``` + */ + async compact(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Option.compact(resolved); + }, + + /** + * Determines whether any option promise in an array resolves to a value. + * @param promises The array of option promises to test. + * @returns A promise that resolves to `true` if at least one option is Some, `false` otherwise. + * @remarks Rejects if any input promise rejects. + * ```ts + * await OptionPromise.some([Promise.resolve(some(1)), Promise.resolve(none())]) // true + * await OptionPromise.some([Promise.resolve(none()), Promise.resolve(none())]) // false + * ``` + */ + async some(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Option.some(resolved); + }, + + /** + * Determines whether all option promises in an array resolve to values. + * @param promises The array of option promises to test. + * @returns A promise that resolves to `true` if all options are Some, `false` otherwise. + * @remarks Rejects if any input promise rejects. + * ```ts + * await OptionPromise.every([Promise.resolve(some(1)), Promise.resolve(some(2))]) // true + * await OptionPromise.every([Promise.resolve(some(1)), Promise.resolve(none())]) // false + * ``` + */ + async every(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Option.every(resolved); + }, +} as const; diff --git a/src/option/spec/filter.spec.ts b/src/option/spec/filter.spec.ts new file mode 100644 index 0000000..b96ba5b --- /dev/null +++ b/src/option/spec/filter.spec.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Option, Some, None, some, none } from '../'; + +describe('Option.filter - Runtime Tests', () => { + describe('filter with Some', () => { + it('should return Some when predicate returns true', () => { + // Arrange + const someOption = some(42); + const predicate = vi.fn((x: number) => x > 0); + + // Act + const result = someOption.filter(predicate); + + // Assert + expect(predicate).toHaveBeenCalledWith(42); + expect(predicate).toHaveBeenCalledTimes(1); + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should return None when predicate returns false', () => { + // Arrange + const someOption = some(42) as Option; + const predicate = vi.fn((x: number) => x < 0); + + // Act + const result = someOption.filter(predicate); + + // Assert + expect(predicate).toHaveBeenCalledWith(42); + expect(predicate).toHaveBeenCalledTimes(1); + expect(result.isNone()).toBe(true); + }); + + it('should work with various predicate conditions', () => { + // Arrange + const testCases = [ + { value: 5, predicate: (x: number) => x > 3, expected: true }, + { value: 2, predicate: (x: number) => x > 3, expected: false }, + { value: 'hello', predicate: (x: string) => x.length > 3, expected: true }, + { value: 'hi', predicate: (x: string) => x.length > 3, expected: false }, + { value: [1, 2, 3], predicate: (x: number[]) => x.length > 0, expected: true }, + { value: [], predicate: (x: number[]) => x.length > 0, expected: false }, + ]; + + testCases.forEach(({ value, predicate, expected }) => { + // Arrange + const option = some(value); + + // Act + // @ts-expect-error // TypeScript will infer the type based on the value + const result = option.filter(predicate); + + // Assert + if (expected) { + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(value); + } + } else { + expect(result.isNone()).toBe(true); + } + }); + }); + + it('should handle complex object filtering', () => { + // Arrange + const users = [ + { name: 'Alice', age: 25, active: true }, + { name: 'Bob', age: 17, active: true }, + { name: 'Charlie', age: 30, active: false }, + ]; + + users.forEach((user) => { + // Act + const activeAdult = some(user).filter((u) => u.active && u.age >= 18); + + // Assert + if (user.name === 'Alice') { + expect(activeAdult.isSome()).toBe(true); + if (activeAdult.isSome()) { + expect(activeAdult.value).toBe(user); + } + } else { + expect(activeAdult.isNone()).toBe(true); + } + }); + }); + + it('should handle type predicate filtering', () => { + // Arrange + const mixedValues: (string | number)[] = [42, 'hello', 100, 'world']; + + mixedValues.forEach((value) => { + // Arrange + const option = some(value); + + // Act + const numberResult = option.filter((x): x is number => typeof x === 'number'); + const stringResult = option.filter((x): x is string => typeof x === 'string'); + + // Assert + if (typeof value === 'number') { + expect(numberResult.isSome()).toBe(true); + expect(stringResult.isNone()).toBe(true); + if (numberResult.isSome()) { + expect(numberResult.value).toBe(value); + } + } else { + expect(numberResult.isNone()).toBe(true); + expect(stringResult.isSome()).toBe(true); + if (stringResult.isSome()) { + expect(stringResult.value).toBe(value); + } + } + }); + }); + + it('should handle null and undefined values correctly', () => { + // Arrange + const nullOption = some(null); + const undefinedOption = some(undefined); + + // Act + const nullFiltered = nullOption.filter((x) => x === null); + const undefinedFiltered = undefinedOption.filter((x) => x === undefined); + const nullRejected = nullOption.filter((x) => x !== null); + const undefinedRejected = undefinedOption.filter((x) => x !== undefined); + + // Assert + expect(nullFiltered.isSome()).toBe(true); + expect(undefinedFiltered.isSome()).toBe(true); + expect(nullRejected.isNone()).toBe(true); + expect(undefinedRejected.isNone()).toBe(true); + }); + }); + + describe('filter with None', () => { + it('should return None without calling predicate', () => { + // Arrange + const noneOption = none() as Option; + const predicate = vi.fn((x: number) => x > 0); + + // Act + const result = noneOption.filter(predicate); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(result.isNone()).toBe(true); + }); + + it('should return None for any predicate', () => { + // Arrange + const noneOption = none() as Option; + + // Act + const result1 = noneOption.filter((x) => x.length > 0); + const result2 = noneOption.filter((x) => x.length === 0); + const result3 = noneOption.filter((x): x is string => typeof x === 'string'); + + // Assert + expect(result1.isNone()).toBe(true); + expect(result2.isNone()).toBe(true); + expect(result3.isNone()).toBe(true); + }); + }); + + describe('filter chaining', () => { + it('should allow chaining multiple filters', () => { + // Arrange + const someOption = some(42); + + // Act + const result = someOption + .filter((x) => x > 0) + .filter((x) => x < 100) + .filter((x) => x % 2 === 0); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should short-circuit on first failed filter', () => { + // Arrange + const someOption = some(42); + const predicate1 = vi.fn((x: number) => x > 0); + const predicate2 = vi.fn((x: number) => x < 10); // This should fail + const predicate3 = vi.fn((x: number) => x % 2 === 0); // This should not be called + + // Act + const result = someOption.filter(predicate1).filter(predicate2).filter(predicate3); + + // Assert + expect(predicate1).toHaveBeenCalledWith(42); + expect(predicate2).toHaveBeenCalledWith(42); + expect(predicate3).not.toHaveBeenCalled(); + expect(result.isNone()).toBe(true); + }); + }); + + describe('filter error handling', () => { + it('should propagate errors from predicate function', () => { + // Arrange + const someOption = some(42); + const error = new Error('Predicate error'); + const predicate = vi.fn(() => { + throw error; + }); + + // Act & Assert + expect(() => { + someOption.filter(predicate); + }).toThrow('Predicate error'); + + expect(predicate).toHaveBeenCalledWith(42); + }); + + it('should not call predicate on None even if it would throw', () => { + // Arrange + const noneOption = none() as Option; + const predicate = vi.fn(() => { + throw new Error('Should not be called'); + }); + + // Act + const result = noneOption.filter(predicate); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(result.isNone()).toBe(true); + }); + }); + + describe('filter with edge cases', () => { + it('should handle falsy values correctly', () => { + // Arrange + const falsyValues = [0, false, '', NaN]; + + falsyValues.forEach((value) => { + // Arrange + const option = some(value); + + // Act + const truthyResult = option.filter((x) => !!x); + const falsyResult = option.filter((x) => !x); + + // Assert + expect(truthyResult.isNone()).toBe(true); + expect(falsyResult.isSome()).toBe(true); + if (falsyResult.isSome()) { + expect(falsyResult.value).toBe(value); + } + }); + }); + + it('should handle complex nested structures', () => { + // Arrange + const complexObject = { + data: { + items: [ + { id: 1, name: 'Item 1', tags: ['tag1', 'tag2'] }, + { id: 2, name: 'Item 2', tags: ['tag3'] }, + ], + }, + }; + const option = some(complexObject); + + // Act + const filtered = option.filter( + (obj) => obj.data.items.length > 0 && obj.data.items.every((item) => item.tags.length > 0), + ); + + // Assert + expect(filtered.isSome()).toBe(true); + if (filtered.isSome()) { + expect(filtered.value).toBe(complexObject); + } + }); + }); +}); diff --git a/src/option/spec/filter.type-spec.ts b/src/option/spec/filter.type-spec.ts new file mode 100644 index 0000000..5d0977b --- /dev/null +++ b/src/option/spec/filter.type-spec.ts @@ -0,0 +1,172 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none } from "../"; + +describe("Option.filter - Type Tests", () => { + describe("filter with type predicate", () => { + it("should narrow type with type predicate on Some", () => { + // Arrange + const someValue = some(42 as string | number); + + // Act + const numberResult = someValue.filter((x): x is number => typeof x === 'number'); + const stringResult = someValue.filter((x): x is string => typeof x === 'string'); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf>(); + expectTypeOf(stringResult).toEqualTypeOf>(); + }); + + it("should handle complex type predicates on Some", () => { + // Arrange + interface User { type: 'user'; name: string; } + interface Admin { type: 'admin'; name: string; permissions: string[]; } + type Person = User | Admin; + + const personSome = some({ type: 'user', name: 'John' } as Person); + + // Act + const adminResult = personSome.filter((p): p is Admin => p.type === 'admin'); + const userResult = personSome.filter((p): p is User => p.type === 'user'); + + // Assert + expectTypeOf(adminResult).toEqualTypeOf>(); + expectTypeOf(userResult).toEqualTypeOf>(); + }); + + it("should handle nullable type predicates on Some", () => { + // Arrange + const nullableSome = some("hello" as string | null); + + // Act + const nonNullResult = nullableSome.filter((x): x is string => x !== null); + + // Assert + expectTypeOf(nonNullResult).toEqualTypeOf>(); + }); + + it("should handle array type predicates on Some", () => { + // Arrange + const arraySome = some([1, 2, 3] as unknown[]); + + // Act + const numberArrayResult = arraySome.filter((arr): arr is number[] => + arr.every(item => typeof item === 'number') + ); + + // Assert + expectTypeOf(numberArrayResult).toEqualTypeOf>(); + }); + }); + + describe("filter with boolean predicate", () => { + it("should preserve original type with boolean predicate", () => { + // Arrange + const numberOption = some(42); + const stringOption = some("hello"); + + // Act + const filteredNumber = numberOption.filter(x => x > 0); + const filteredString = stringOption.filter(x => x.length > 0); + + // Assert + expectTypeOf(filteredNumber).toEqualTypeOf>(); + expectTypeOf(filteredString).toEqualTypeOf>(); + }); + + it("should work with complex boolean predicates", () => { + // Arrange + const objectOption = some({ id: 1, name: "test", active: true }); + + // Act + const filteredObject = objectOption.filter(obj => obj.active && obj.name.length > 0); + + // Assert + expectTypeOf(filteredObject).toEqualTypeOf>(); + }); + }); + + describe("filter with None", () => { + it("should preserve None type regardless of predicate", () => { + // Arrange + const noneOption = none() as Option; + + // Act + const filteredWithBoolean = noneOption.filter(x => x > 0); + const filteredWithTypePredicate = noneOption.filter((x): x is number => typeof x === 'number'); + + // Assert + expectTypeOf(filteredWithBoolean).toEqualTypeOf>(); + expectTypeOf(filteredWithTypePredicate).toEqualTypeOf>(); + }); + }); + + describe("filter function signature validation", () => { + it("should be callable with correct predicate signatures", () => { + // Arrange + const someOption = some(42); + const booleanPredicate = (x: number) => x > 0; + const typePredicate = (x: number): x is number => typeof x === 'number'; + + // Assert + expectTypeOf(someOption.filter).toBeCallableWith(booleanPredicate); + expectTypeOf(someOption.filter).toBeCallableWith(typePredicate); + }); + + it("should not be callable with incorrect predicate signatures", () => { + // Arrange + const someOption = some(42); + + // Assert + // @ts-expect-error - wrong parameter type + someOption.filter((x: string) => x.length > 0); + // @ts-expect-error - wrong return type + someOption.filter((x: number) => x.toString()); + }); + + it("should not be callable without predicate", () => { + // Arrange + const someOption = some(42); + + // Assert + // @ts-expect-error - missing predicate argument + someOption.filter(); + }); + }); + + describe("filter overload resolution", () => { + it("should resolve to type predicate overload when type predicate is provided", () => { + // Arrange - use Some directly to test the type predicate overload + const someValue = some(42 as string | number); + + // Act + const result = someValue.filter((x): x is number => typeof x === 'number'); + + // Assert - should be Option, not Option + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should resolve to boolean predicate overload when boolean predicate is provided", () => { + // Arrange + const numberOption = some(42); + + // Act + const result = numberOption.filter(x => x > 0); + + // Assert - should preserve original type + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("filter with generic constraints", () => { + it("should work with generic type constraints", () => { + // Arrange - use Some directly to test the type predicate overload + const lengthableSome = some("hello" as { length: number }); + + // Act + const result = lengthableSome.filter((x): x is string => typeof x === 'string'); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/option/spec/is.spec.ts b/src/option/spec/is.spec.ts new file mode 100644 index 0000000..0abe579 --- /dev/null +++ b/src/option/spec/is.spec.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from 'vitest'; +import { Option, Some, None, some, none } from '../'; +import {fail} from "node:assert"; + +describe('Option isSome/isNone - Runtime Tests', () => { + describe('isSome() method', () => { + it('should return true for Some instances', () => { + // Arrange + const testCases = [ + some(42), + some('hello'), + some(true), + some({ id: 1 }), + some([1, 2, 3]), + some(null), + some(undefined), + some(0), + some(false), + some('') + ]; + + testCases.forEach(option => { + // Act & Assert + expect(option.isSome()).toBe(true); + }); + }); + + it('should return false for None instances', () => { + // Arrange + const noneOption = none(); + + // Act & Assert + expect(noneOption.isSome()).toBe(false); + }); + + it('should enable access to value property after type narrowing', () => { + // Arrange + const option: Option = some('hello') as Option; + + // Act & Assert + if (option.isSome()) { + expect(option.value).toBe('hello'); + expect(typeof option.value).toBe('string'); + } else { + fail('Expected option to be Some'); + } + }); + + it('should work with complex objects', () => { + // Arrange + const complexObject = { + id: 1, + name: 'test', + nested: { data: [1, 2, 3] }, + fn: (x: number) => x * 2 + }; + const option: Option = some(complexObject) as Option; + + // Act & Assert + if (option.isSome()) { + expect(option.value).toBe(complexObject); + expect(option.value.id).toBe(1); + expect(option.value.name).toBe('test'); + expect(option.value.nested.data).toEqual([1, 2, 3]); + expect(option.value.fn(5)).toBe(10); + } else { + fail('Expected option to be Some'); + } + }); + + it('should work with falsy values that are still Some', () => { + // Arrange + const falsy: Option[] = [ + some(0) as Option, + some(false) as Option, + some('') as Option, + some(null) as Option, + some(undefined) as Option + ]; + + falsy.forEach(option => { + // Act & Assert + expect(option.isSome()).toBe(true); + if (option.isSome()) { + // Value should be accessible even if falsy + expect(option.value !== undefined || option.value === undefined).toBe(true); + } + }); + }); + }); + + describe('isNone() method', () => { + it('should return true for None instances', () => { + // Arrange + const noneOption = none(); + const noneAsOption: Option = none() as Option; + + // Act & Assert + expect(noneOption.isNone()).toBe(true); + expect(noneAsOption.isNone()).toBe(true); + }); + + it('should return false for Some instances', () => { + // Arrange + const testCases = [ + some(42), + some('hello'), + some(true), + some({ id: 1 }), + some([1, 2, 3]), + some(null), + some(undefined), + some(0), + some(false), + some('') + ]; + + testCases.forEach(option => { + // Act & Assert + expect(option.isNone()).toBe(false); + }); + }); + + it('should work with different generic types', () => { + // Arrange + const numberNone: Option = none() as Option; + const stringNone: Option = none() as Option; + const objectNone: Option<{ id: number }> = none() as Option<{ id: number }>; + + // Act & Assert + expect(numberNone.isNone()).toBe(true); + expect(stringNone.isNone()).toBe(true); + expect(objectNone.isNone()).toBe(true); + }); + }); + + describe('Mutual exclusivity', () => { + it('should be mutually exclusive for Some instances', () => { + // Arrange + const testCases = [ + some(42), + some('hello'), + some(null), + some(undefined), + some(false), + some(0) + ]; + + testCases.forEach(option => { + // Act & Assert + expect(option.isSome()).toBe(true); + expect(option.isNone()).toBe(false); + expect(option.isSome() && option.isNone()).toBe(false); + // @ts-expect-error - TypeScript should not allow this + expect(option.isSome() || option.isNone()).toBe(true); + }); + }); + + it('should be mutually exclusive for None instances', () => { + // Arrange + const noneOption = none(); + + // Act & Assert + expect(noneOption.isSome()).toBe(false); + expect(noneOption.isNone()).toBe(true); + // @ts-expect-error - TypeScript should not allow this + expect(noneOption.isSome() && noneOption.isNone()).toBe(false); + expect(noneOption.isSome() || noneOption.isNone()).toBe(true); + }); + }); + + describe('Type narrowing in control flow', () => { + it('should enable type-safe value access in if statements', () => { + // Arrange + const option: Option<{ name: string; age: number }> = some({ name: 'John', age: 30 }) as Option<{ name: string; age: number }>; + + // Act & Assert + if (option.isSome()) { + expect(option.value.name).toBe('John'); + expect(option.value.age).toBe(30); + expect(typeof option.value.name).toBe('string'); + expect(typeof option.value.age).toBe('number'); + } else { + fail('Expected option to be Some'); + } + }); + + it('should work with early returns', () => { + // Arrange + function processOption(option: Option): string { + if (option.isNone()) { + return 'none'; + } + return `value: ${option.value}`; + } + + const someOption: Option = some(42) as Option; + const noneOption: Option = none() as Option; + + // Act + const someResult = processOption(someOption); + const noneResult = processOption(noneOption); + + // Assert + expect(someResult).toBe('value: 42'); + expect(noneResult).toBe('none'); + }); + + it('should work in complex conditional logic', () => { + // Arrange + const option1: Option = some(10) as Option; + const option2: Option = some('test') as Option; + const option3: Option = none() as Option; + + // Act & Assert + if (option1.isSome() && option2.isSome()) { + expect(option1.value + option2.value.length).toBe(14); + } else { + fail('Expected both options to be Some'); + } + + if (option1.isSome() && option3.isNone()) { + expect(option1.value).toBe(10); + } else { + fail('Expected option1 to be Some and option3 to be None'); + } + }); + + it('should work with negated conditions', () => { + // Arrange + const someOption: Option = some('hello') as Option; + const noneOption: Option = none() as Option; + + // Act & Assert + if (!someOption.isNone()) { + expect(someOption.value).toBe('hello'); + } else { + fail('Expected option to be Some'); + } + + if (!noneOption.isSome()) { + expect(noneOption.isNone()).toBe(true); + } else { + fail('Expected option to be None'); + } + }); + }); + + describe('Performance and consistency', () => { + it('should return consistent results across multiple calls', () => { + // Arrange + const someOption = some(42); + const noneOption = none(); + + // Act & Assert - Multiple calls should return same result + expect(someOption.isSome()).toBe(someOption.isSome()); + expect(someOption.isNone()).toBe(someOption.isNone()); + expect(noneOption.isSome()).toBe(noneOption.isSome()); + expect(noneOption.isNone()).toBe(noneOption.isNone()); + }); + + it('should be callable without side effects', () => { + // Arrange + const option = some({ counter: 0 }); + + // Act + const before = option.value.counter; + option.isSome(); + option.isNone(); + option.isSome(); + const after = option.value.counter; + + // Assert + expect(before).toBe(after); + }); + }); + + describe('Edge cases', () => { + it('should handle deeply nested objects', () => { + // Arrange + const deepObject = { + level1: { + level2: { + level3: { + value: 'deep' + } + } + } + }; + const option: Option = some(deepObject) as Option; + + // Act & Assert + if (option.isSome()) { + expect(option.value.level1.level2.level3.value).toBe('deep'); + } else { + fail('Expected option to be Some'); + } + }); + + it('should handle arrays with mixed types', () => { + // Arrange + const mixedArray = [1, 'string', true, null, { id: 1 }]; + const option: Option = some(mixedArray) as Option; + + // Act & Assert + if (option.isSome()) { + expect(option.value).toHaveLength(5); + expect(option.value[0]).toBe(1); + expect(option.value[1]).toBe('string'); + expect(option.value[2]).toBe(true); + expect(option.value[3]).toBeNull(); + expect(option.value[4]).toEqual({ id: 1 }); + } else { + fail('Expected option to be Some'); + } + }); + }); +}); diff --git a/src/option/spec/is.type-spec.ts b/src/option/spec/is.type-spec.ts new file mode 100644 index 0000000..d59d0c2 --- /dev/null +++ b/src/option/spec/is.type-spec.ts @@ -0,0 +1,206 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none } from "../"; + +describe("Option isSome/isNone - Type Tests", () => { + describe("isSome() type guard", () => { + it("should act as type guard narrowing Option to Some", () => { + // Arrange + const option: Option = some(42) as Option; + + // Act & Assert - Type narrowing should work + if (option.isSome()) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is Some'", () => { + // Arrange + const numberOption: Option = some(42) as Option; + const stringOption: Option = some("hello") as Option; + const objectOption: Option<{ id: number }> = some({ id: 1 }) as Option<{ id: number }>; + + // Act + const numberResult = numberOption.isSome(); + const stringResult = stringOption.isSome(); + const objectResult = objectOption.isSome(); + + // Assert - Should return boolean but enable type narrowing + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(objectResult).toEqualTypeOf(); + }); + + it("should enable access to value property after type narrowing", () => { + // Arrange + const option: Option<{ name: string; age: number }> = some({ name: "John", age: 30 }) as Option<{ name: string; age: number }>; + + // Act & Assert + if (option.isSome()) { + expectTypeOf(option.value).toEqualTypeOf<{ name: string; age: number }>(); + expectTypeOf(option.value.name).toEqualTypeOf(); + expectTypeOf(option.value.age).toEqualTypeOf(); + } + }); + + it("should work with complex generic types", () => { + // Arrange + type ComplexType = Array<{ id: number; tags: string[] }>; + const option: Option = some([{ id: 1, tags: ["a", "b"] }]) as Option; + + // Act & Assert + if (option.isSome()) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + expectTypeOf(option.value[0]).toEqualTypeOf<{ id: number; tags: string[] }>(); + } + }); + + it("should work with union types", () => { + // Arrange + const option: Option = some("hello") as Option; + + // Act & Assert + if (option.isSome()) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + } + }); + + it("should work with nullable types", () => { + // Arrange + const nullOption: Option = some(null) as Option; + const undefinedOption: Option = some(undefined) as Option; + + // Act & Assert + if (nullOption.isSome()) { + expectTypeOf(nullOption).toEqualTypeOf>(); + expectTypeOf(nullOption.value).toEqualTypeOf(); + } + + if (undefinedOption.isSome()) { + expectTypeOf(undefinedOption).toEqualTypeOf>(); + expectTypeOf(undefinedOption.value).toEqualTypeOf(); + } + }); + }); + + describe("isNone() type guard", () => { + it("should act as type guard narrowing Option to None", () => { + // Arrange + const option: Option = none() as Option; + + // Act & Assert - Type narrowing should work + if (option.isNone()) { + expectTypeOf(option).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is None'", () => { + // Arrange + const numberOption: Option = none() as Option; + const stringOption: Option = none() as Option; + const objectOption: Option<{ id: number }> = none() as Option<{ id: number }>; + + // Act + const numberResult = numberOption.isNone(); + const stringResult = stringOption.isNone(); + const objectResult = objectOption.isNone(); + + // Assert - Should return boolean but enable type narrowing + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(objectResult).toEqualTypeOf(); + }); + + it("should narrow to None regardless of original generic type", () => { + // Arrange + const numberOption: Option = none() as Option; + const complexOption: Option<{ data: Array }> = none() as Option<{ data: Array }>; + + // Act & Assert + if (numberOption.isNone()) { + expectTypeOf(numberOption).toEqualTypeOf(); + } + + if (complexOption.isNone()) { + expectTypeOf(complexOption).toEqualTypeOf(); + } + }); + }); + + describe("Type narrowing patterns", () => { + it("should enable exhaustive type checking with if-else", () => { + // Arrange + const option: Option = some("test") as Option; + + // Act & Assert - Both branches should be type-safe + if (option.isSome()) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + } else { + expectTypeOf(option).toEqualTypeOf(); + } + }); + + it("should work with early returns", () => { + // Arrange + function processOption(option: Option): number { + if (option.isNone()) { + return 0; // Type narrowed to None + } + + // Type narrowed to Some here + return option.value * 2; + } + + const testOption: Option = some(5) as Option; + const result = processOption(testOption); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should work in complex conditional logic", () => { + // Arrange + const option1: Option = some(1) as Option; + const option2: Option = some("test") as Option; + + // Act & Assert + if (option1.isSome() && option2.isSome()) { + expectTypeOf(option1).toEqualTypeOf>(); + expectTypeOf(option2).toEqualTypeOf>(); + expectTypeOf(option1.value).toEqualTypeOf(); + expectTypeOf(option2.value).toEqualTypeOf(); + } + }); + + it("should work with negated conditions", () => { + // Arrange + const option: Option = some(true) as Option; + + // Act & Assert + if (!option.isNone()) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + } + + if (!option.isSome()) { + expectTypeOf(option).toEqualTypeOf(); + } + }); + }); + + describe("Method chaining with type narrowing", () => { + it("should preserve type information in method chains", () => { + // Arrange + const option: Option = some(42) as Option; + + // Act & Assert - Type narrowing should work before method calls + if (option.isSome()) { + const mapped = option.map(x => x.toString()); + expectTypeOf(mapped).toEqualTypeOf>(); + } + }); + }); +}); diff --git a/src/option/spec/map.spec.ts b/src/option/spec/map.spec.ts new file mode 100644 index 0000000..8e3019a --- /dev/null +++ b/src/option/spec/map.spec.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Option, Some, None, some, none } from '../'; + +describe('Option map and flatMap - Runtime Tests', () => { + describe('map method', () => { + describe('when Option is Some', () => { + it('should transform the value using the provided function', () => { + // Arrange + const option = some(5); + const mapper = (x: number) => x * 2; + + // Act + const result = option.map(mapper); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(10); + } + }); + + it('should handle different type transformations', () => { + // Arrange + const numberOption = some(42); + const stringOption = some("hello"); + const booleanOption = some(true); + + // Act + const numberToString = numberOption.map(x => x.toString()); + const stringToNumber = stringOption.map(s => s.length); + const booleanToString = booleanOption.map(b => b ? "yes" : "no"); + + // Assert + expect(numberToString.isSome()).toBe(true); + expect(stringToNumber.isSome()).toBe(true); + expect(booleanToString.isSome()).toBe(true); + + if (numberToString.isSome()) expect(numberToString.value).toBe("42"); + if (stringToNumber.isSome()) expect(stringToNumber.value).toBe(5); + if (booleanToString.isSome()) expect(booleanToString.value).toBe("yes"); + }); + + it('should handle complex object transformations', () => { + // Arrange + const user = { id: 1, name: "John", age: 30 }; + const userOption = some(user); + + // Act + const userSummary = userOption.map(u => ({ + displayName: `${u.name} (${u.age})`, + isAdult: u.age >= 18 + })); + + // Assert + expect(userSummary.isSome()).toBe(true); + if (userSummary.isSome()) { + expect(userSummary.value).toEqual({ + displayName: "John (30)", + isAdult: true + }); + } + }); + + it('should handle function transformations', () => { + // Arrange + const addOne = (x: number) => x + 1; + const funcOption = some(addOne); + + // Act + const wrappedFunc = funcOption.map(fn => (y: number) => fn(y) * 2); + + // Assert + expect(wrappedFunc.isSome()).toBe(true); + if (wrappedFunc.isSome()) { + const result = wrappedFunc.value(5); + expect(result).toBe(12); // (5 + 1) * 2 + } + }); + + it('should not mutate the original option', () => { + // Arrange + const original = some({ count: 5 }); + const originalValue = original.value; + + // Act + const mapped = original.map(obj => ({ ...obj, count: obj.count * 2 })); + + // Assert + expect(original.value).toBe(originalValue); + expect(original.value.count).toBe(5); + if (mapped.isSome()) { + expect(mapped.value.count).toBe(10); + } + }); + }); + + describe('when Option is None', () => { + it('should return None without calling the mapper function', () => { + // Arrange + const noneOption: Option = none() as Option; + const mapperSpy = vi.fn((x: number) => x * 2); + + // Act + const result = noneOption.map(mapperSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(mapperSpy).not.toHaveBeenCalled(); + }); + + it('should preserve None through multiple map operations', () => { + // Arrange + const noneOption: Option = none() as Option; + + // Act + const result = noneOption + .map(x => x * 2) + .map(x => x.toString()) + .map(s => s.length); + + // Assert + expect(result.isNone()).toBe(true); + }); + }); + }); + + describe('flatMap method', () => { + describe('when Option is Some', () => { + it('should apply function and flatten the result when function returns Some', () => { + // Arrange + const option = some(5); + const flatMapper = (x: number) => some(x * 2); + + // Act + const result = option.flatMap(flatMapper); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(10); + } + }); + + it('should return None when function returns None', () => { + // Arrange + const option = some(5); + const flatMapper = (x: number) => none() as Option; + + // Act + const result = option.flatMap(flatMapper); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should handle conditional logic in flatMapper', () => { + // Arrange + const positiveOption = some(5); + const negativeOption = some(-3); + const flatMapper = (x: number) => + x > 0 ? some(x * 2) : none() as Option; + + // Act + const positiveResult = positiveOption.flatMap(flatMapper); + const negativeResult = negativeOption.flatMap(flatMapper); + + // Assert + expect(positiveResult.isSome()).toBe(true); + expect(negativeResult.isNone()).toBe(true); + if (positiveResult.isSome()) { + expect(positiveResult.value).toBe(10); + } + }); + + it('should handle type transformations', () => { + // Arrange + const stringOption = some("42"); + const parseNumber = (str: string): Option => { + const num = parseInt(str); + return isNaN(num) ? none() as Option : some(num); + }; + + // Act + const validResult = stringOption.flatMap(parseNumber); + const invalidResult = some("invalid").flatMap(parseNumber); + + // Assert + expect(validResult.isSome()).toBe(true); + expect(invalidResult.isNone()).toBe(true); + if (validResult.isSome()) { + expect(validResult.value).toBe(42); + } + }); + + it('should work with complex business logic', () => { + // Arrange + type User = { id: number; email: string; verified: boolean }; + type AuthToken = { token: string; userId: number }; + + const user: User = { id: 1, email: "test@example.com", verified: true }; + const userOption = some(user); + + const generateToken = (u: User): Option => { + return u.verified + ? some({ token: `token_${u.id}`, userId: u.id }) + : none() as Option; + }; + + // Act + const tokenResult = userOption.flatMap(generateToken); + const unverifiedResult = some({ ...user, verified: false }).flatMap(generateToken); + + // Assert + expect(tokenResult.isSome()).toBe(true); + expect(unverifiedResult.isNone()).toBe(true); + if (tokenResult.isSome()) { + expect(tokenResult.value).toEqual({ token: "token_1", userId: 1 }); + } + }); + + it('should not mutate the original option', () => { + // Arrange + const original = some({ value: 10 }); + const originalValue = original.value; + + // Act + const flatMapped = original.flatMap(obj => some({ ...obj, value: obj.value * 2 })); + + // Assert + expect(original.value).toBe(originalValue); + expect(original.value.value).toBe(10); + if (flatMapped.isSome()) { + expect(flatMapped.value.value).toBe(20); + } + }); + }); + + describe('when Option is None', () => { + it('should return None without calling the flatMapper function', () => { + // Arrange + const noneOption: Option = none() as Option; + const flatMapperSpy = vi.fn((x: number) => some(x * 2)); + + // Act + const result = noneOption.flatMap(flatMapperSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(flatMapperSpy).not.toHaveBeenCalled(); + }); + + it('should preserve None through multiple flatMap operations', () => { + // Arrange + const noneOption: Option = none() as Option; + + // Act + const result = noneOption + .flatMap(x => some(x * 2)) + .flatMap(x => some(x.toString())) + .flatMap(s => some(s.length)); + + // Assert + expect(result.isNone()).toBe(true); + }); + }); + }); + + describe('map and flatMap integration', () => { + it('should work together in complex chains', () => { + // Arrange + const input = some("123"); + + // Act + const result = input + .map(str => str.trim()) + .flatMap(str => { + const num = parseInt(str); + return isNaN(num) ? none() as Option : some(num); + }) + .map(num => num * 2) + .flatMap(num => num > 100 ? some(`Large: ${num}`) : some(`Small: ${num}`)) + .map(str => str.toUpperCase()); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe("LARGE: 246"); + } + }); + + it('should short-circuit on first None in chain', () => { + // Arrange + const mapSpy = vi.fn((x: string) => x.toUpperCase()); + const flatMapSpy = vi.fn((x: string) => some(x.length)); + + // Act + const result = some("test") + .flatMap(x => none() as Option) // This returns None + .map(mapSpy) // Should not be called + .flatMap(flatMapSpy); // Should not be called + + // Assert + expect(result.isNone()).toBe(true); + expect(mapSpy).not.toHaveBeenCalled(); + expect(flatMapSpy).not.toHaveBeenCalled(); + }); + + it('should handle alternating map and flatMap operations', () => { + // Arrange + const startValue = 5; + + // Act + const result = some(startValue) + .map(x => x * 2) // 10 + .flatMap(x => x > 5 ? some(x.toString()) : none() as Option) // "10" + .map(s => s.length) // 2 + .flatMap(len => len > 1 ? some(`Length: ${len}`) : none() as Option) // "Length: 2" + .map(s => s.split(' ')[1]); // "2" + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe("2"); + } + }); + + it('should handle edge cases with empty/falsy values', () => { + // Arrange + const emptyStringOption = some(""); + const zeroOption = some(0); + const falseOption = some(false); + + // Act + const stringResult = emptyStringOption.map(s => s.length).flatMap(len => some(`Length: ${len}`)); + const numberResult = zeroOption.flatMap(n => some(n.toString())).map(s => s || "zero"); + const booleanResult = falseOption.map(b => !b).flatMap(b => some(b ? "true" : "false")); + + // Assert + expect(stringResult.isSome()).toBe(true); + expect(numberResult.isSome()).toBe(true); + expect(booleanResult.isSome()).toBe(true); + + if (stringResult.isSome()) expect(stringResult.value).toBe("Length: 0"); + if (numberResult.isSome()) expect(numberResult.value).toBe("0"); + if (booleanResult.isSome()) expect(booleanResult.value).toBe("true"); + }); + }); +}); diff --git a/src/option/spec/map.type-spec.ts b/src/option/spec/map.type-spec.ts new file mode 100644 index 0000000..7ab6934 --- /dev/null +++ b/src/option/spec/map.type-spec.ts @@ -0,0 +1,249 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none } from "../"; + +describe("Option map and flatMap - Type Tests", () => { + describe("map method", () => { + it("should transform Some to Some with correct type inference", () => { + // Arrange + const numberOption = some(42); + const stringOption = some("hello"); + + // Act + const doubledNumber = numberOption.map(x => x * 2); + const uppercaseString = stringOption.map(s => s.toUpperCase()); + const numberToString = numberOption.map(x => x.toString()); + + // Assert + expectTypeOf(doubledNumber).toEqualTypeOf>(); + expectTypeOf(uppercaseString).toEqualTypeOf>(); + expectTypeOf(numberToString).toEqualTypeOf>(); + }); + + it("should transform None to None with correct return type", () => { + // Arrange + const noneOption: Option = none() as Option; + + // Act + const mapped = noneOption.map(x => x * 2); + + // Assert + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should handle complex type transformations", () => { + // Arrange + type User = { id: number; name: string }; + type UserDto = { userId: number; displayName: string }; + const userOption = some({ id: 1, name: "John" } as User); + + // Act + const userDtoOption = userOption.map((user): UserDto => ({ + userId: user.id, + displayName: user.name.toUpperCase() + })); + + // Assert + expectTypeOf(userDtoOption).toEqualTypeOf>(); + }); + + it("should preserve literal types when appropriate", () => { + // Arrange + const literalOption = some("success" as const); + + // Act + const mappedLiteral = literalOption.map(x => x); + const transformedLiteral = literalOption.map(x => `status: ${x}` as const); + + // Assert + expectTypeOf(mappedLiteral).toEqualTypeOf>(); + expectTypeOf(transformedLiteral).toEqualTypeOf>(); + }); + + it("should handle function type transformations", () => { + // Arrange + const funcOption = some((x: number) => x * 2); + + // Act + const wrappedFunc = funcOption.map(fn => (y: number) => fn(y) + 1); + + // Assert + expectTypeOf(wrappedFunc).toEqualTypeOf number>>(); + }); + + it("should work with union types", () => { + // Arrange + const unionOption: Option = some("hello" as string | number); + + // Act + const stringified = unionOption.map(x => String(x)); + + // Assert + expectTypeOf(stringified).toEqualTypeOf>(); + }); + + it("should be callable with correct mapper function signatures", () => { + // Arrange + const numberOption = some(42); + const stringOption = some("hello"); + + // Assert + expectTypeOf(numberOption.map).toBeCallableWith((x: number) => x * 2); + expectTypeOf(numberOption.map).toBeCallableWith((x: number) => x.toString()); + expectTypeOf(stringOption.map).toBeCallableWith((s: string) => s.length); + // @ts-expect-error - wrong parameter type + numberOption.map((s: string) => s.length); + }); + }); + + describe("narrow return types on concrete variants", () => { + it("should return Some when map is called on a known Some", () => { + const mapped = some(42).map(x => x.toString()); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return None when map is called on a known None", () => { + const mapped = none().map(x => String(x)); + expectTypeOf(mapped).toEqualTypeOf(); + }); + + it("should return None when flatMap is called on a known None", () => { + const mapped = none().flatMap(x => some(String(x))); + expectTypeOf(mapped).toEqualTypeOf(); + }); + + it("should return None when filter is called on a known None", () => { + const filtered = none().filter(); + expectTypeOf(filtered).toEqualTypeOf(); + }); + + it("should return Some when flatMap callback returns Some", () => { + const result = some(5).flatMap(x => some(x.toString())); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Option when flatMap callback returns Option", () => { + const result = some(5).flatMap(x => (x > 0 ? some(x) : none()) as Option); + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("flatMap method", () => { + it("should flatten Some with function returning Option", () => { + // Arrange + const numberOption = some(42); + + // Act + const flatMappedSome = numberOption.flatMap(x => some(x * 2)); + const flatMappedNone = numberOption.flatMap(x => none() as Option); + + // Assert + expectTypeOf(flatMappedSome).toEqualTypeOf>(); + expectTypeOf(flatMappedNone).toEqualTypeOf>(); + }); + + it("should handle None input correctly", () => { + // Arrange + const noneOption: Option = none() as Option; + + // Act + const flatMapped = noneOption.flatMap(x => some(x * 2)); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should transform types correctly with flatMap", () => { + // Arrange + const stringOption = some("42"); + + // Act + const parsed = stringOption.flatMap(str => { + const num = parseInt(str); + return isNaN(num) ? none() as Option : some(num); + }); + + // Assert + expectTypeOf(parsed).toEqualTypeOf>(); + }); + + it("should handle complex type transformations", () => { + // Arrange + type ParseResult = { value: number; valid: boolean }; + const inputOption = some("123"); + + // Act + const result = inputOption.flatMap((str): Option => { + const num = parseInt(str); + return isNaN(num) + ? none() as Option + : some({ value: num, valid: true }); + }); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should work with chained flatMap operations", () => { + // Arrange + const initialOption = some(5); + + // Act + const chained = initialOption + .flatMap(x => x > 0 ? some(x * 2) : none() as Option) + .flatMap(x => x < 20 ? some(x.toString()) : none() as Option); + + // Assert + expectTypeOf(chained).toEqualTypeOf>(); + }); + + it("should be callable with correct flatMapper function signatures", () => { + // Arrange + const numberOption = some(42); + const stringOption = some("hello"); + + // Assert + expectTypeOf(numberOption.flatMap).toBeCallableWith((x: number) => some(x * 2)); + expectTypeOf(numberOption.flatMap).toBeCallableWith((x: number) => none() as Option); + expectTypeOf(stringOption.flatMap).toBeCallableWith((s: string) => some(s.length)); + // @ts-expect-error - Should return Option, not raw value + numberOption.flatMap((x: number) => x * 2); + // @ts-expect-error - Wrong input type + numberOption.flatMap((s: string) => some(s.length)); + }); + }); + + describe("map and flatMap interaction", () => { + it("should work together in chains with correct type inference", () => { + // Arrange + const option = some(5); + + // Act + const result = option + .map(x => x * 2) + .flatMap(x => x > 5 ? some(x.toString()) : none() as Option) + .map(s => s.length); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should handle mixed transformations correctly", () => { + // Arrange + type Input = { value: string }; + type Output = { parsed: number; length: number }; + const inputOption = some({ value: "42" }); + + // Act + const result = inputOption + .map(input => input.value) + .flatMap((str): Option => { + const num = parseInt(str); + return isNaN(num) ? none() as Option : some(num); + }) + .map((num): Output => ({ parsed: num, length: num.toString().length })); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/option/spec/match.spec.ts b/src/option/spec/match.spec.ts new file mode 100644 index 0000000..8681146 --- /dev/null +++ b/src/option/spec/match.spec.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Option, Some, None, some, none } from '../'; + +describe('Option.match - Runtime Tests', () => { + describe('match with Some', () => { + it('should execute onSome callback with the contained value', () => { + // Arrange + const someOption = some(42); + const onSome = vi.fn((value: number) => `Value: ${value}`); + const onNone = vi.fn(() => 'No value'); + + // Act + const result = someOption.match(onSome, onNone); + + // Assert + expect(onSome).toHaveBeenCalledWith(42); + expect(onSome).toHaveBeenCalledTimes(1); + expect(onNone).not.toHaveBeenCalled(); + expect(result).toBe('Value: 42'); + }); + + it('should return the result of onSome callback', () => { + // Arrange + const someOption = some(10); + + // Act + const result = someOption.match( + (value) => value * 2, + () => 0 + ); + + // Assert + expect(result).toBe(20); + }); + + it('should work with various data types', () => { + // Arrange + const testCases = [ + { option: some(42), expected: 'number: 42' }, + { option: some('hello'), expected: 'string: "hello"' }, + { option: some(true), expected: 'boolean: true' }, + { option: some({ id: 1 }), expected: 'object: {"id":1}' }, + { option: some([1, 2, 3]), expected: 'object: [1,2,3]' }, + ]; + + testCases.forEach(({ option, expected }) => { + // Act + const result = option.match( + (value) => `${typeof value}: ${JSON.stringify(value)}`, + () => 'none' + ); + + // Assert + expect(result).toBe(expected); + }); + }); + + it('should handle null and undefined values correctly', () => { + // Arrange + const nullOption = some(null); + const undefinedOption = some(undefined); + + // Act + const nullResult = nullOption.match( + (value) => `Got null: ${value}`, + () => 'No value' + ); + + const undefinedResult = undefinedOption.match( + (value) => `Got undefined: ${value}`, + () => 'No value' + ); + + // Assert + expect(nullResult).toBe('Got null: null'); + expect(undefinedResult).toBe('Got undefined: undefined'); + }); + + it('should handle complex transformations', () => { + // Arrange + const someOption = some({ name: 'John', age: 30 }); + + // Act + const result = someOption.match( + (person) => ({ + greeting: `Hello, ${person.name}!`, + isAdult: person.age >= 18, + category: person.age < 18 ? 'minor' : person.age < 65 ? 'adult' : 'senior' + }), + () => ({ greeting: 'Hello, stranger!', isAdult: false, category: 'unknown' }) + ); + + // Assert + expect(result).toEqual({ + greeting: 'Hello, John!', + isAdult: true, + category: 'adult' + }); + }); + }); + + describe('match with None', () => { + it('should execute onNone callback', () => { + // Arrange + const noneOption: Option = none(); + const onSome = vi.fn((value: number) => `Value: ${value}`); + const onNone = vi.fn(() => 'No value'); + + // Act + const result = noneOption.match(onSome, onNone); + + // Assert + expect(onNone).toHaveBeenCalledWith(); + expect(onNone).toHaveBeenCalledTimes(1); + expect(onSome).not.toHaveBeenCalled(); + expect(result).toBe('No value'); + }); + + it('should return the result of onNone callback', () => { + // Arrange + const noneOption = none() as Option; + + // Act + const result = noneOption.match( + (value) => value.toUpperCase(), + () => 'DEFAULT' + ); + + // Assert + expect(result).toBe('DEFAULT'); + }); + + it('should handle different return types from onNone', () => { + // Arrange + const noneOption = none() as Option; + + // Act + const stringResult = noneOption.match( + (value) => value.toString(), + () => 'empty' + ); + + const numberResult = noneOption.match( + (value) => value, + () => -1 + ); + + const objectResult = noneOption.match( + (value): { value: number | null } => ({ value }), + (): { value: number | null } => ({ value: null }) + ); + + // Assert + expect(stringResult).toBe('empty'); + expect(numberResult).toBe(-1); + expect(objectResult).toEqual({ value: null }); + }); + }); + + describe('match with mixed Option types', () => { + it('should handle dynamic option types correctly', () => { + // Arrange + const options: Option[] = [ + some('hello') as Option, + none() as Option, + some('world') as Option + ]; + + // Act + const results = options.map(option => + option.match( + (value) => value.toUpperCase(), + () => 'EMPTY' + ) + ); + + // Assert + expect(results).toEqual(['HELLO', 'EMPTY', 'WORLD']); + }); + + it('should work in conditional contexts', () => { + // Arrange + const getValue = (hasValue: boolean): Option => + hasValue ? some(42) as Option : none() as Option; + + // Act + const withValue = getValue(true).match( + (value) => `Found: ${value}`, + () => 'Not found' + ); + + const withoutValue = getValue(false).match( + (value) => `Found: ${value}`, + () => 'Not found' + ); + + // Assert + expect(withValue).toBe('Found: 42'); + expect(withoutValue).toBe('Not found'); + }); + }); + + describe('match error handling', () => { + it('should propagate errors from onSome callback', () => { + // Arrange + const someOption = some(42); + const error = new Error('onSome error'); + + // Act & Assert + expect(() => { + someOption.match( + () => { throw error; }, + () => 'fallback' + ); + }).toThrow('onSome error'); + }); + + it('should propagate errors from onNone callback', () => { + // Arrange + const noneOption = none() as Option; + const error = new Error('onNone error'); + + // Act & Assert + expect(() => { + noneOption.match( + (value) => value.toString(), + () => { throw error; } + ); + }).toThrow('onNone error'); + }); + }); + + describe('match with async callbacks', () => { + it('should handle async onSome callback', async () => { + // Arrange + const someOption = some(42); + + // Act + const result = await someOption.match( + async (value) => { + await new Promise(resolve => setTimeout(resolve, 10)); + return `Async: ${value}`; + }, + async () => 'Async: No value' + ); + + // Assert + expect(result).toBe('Async: 42'); + }); + + it('should handle async onNone callback', async () => { + // Arrange + const noneOption: Option = none(); + + // Act + const result = await noneOption.match( + async (value) => `Async: ${value}`, + async () => { + await new Promise(resolve => setTimeout(resolve, 10)); + return 'Async: No value'; + } + ); + + // Assert + expect(result).toBe('Async: No value'); + }); + }); +}); diff --git a/src/option/spec/match.type-spec.ts b/src/option/spec/match.type-spec.ts new file mode 100644 index 0000000..6b4e7a7 --- /dev/null +++ b/src/option/spec/match.type-spec.ts @@ -0,0 +1,213 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none } from "../"; + +describe("Option.match - Type Tests", () => { + describe("match with Some", () => { + it("should infer return type from both callback return types", () => { + // Arrange + const someOption = some(42); + + // Act + const stringResult = someOption.match( + (value) => `Value: ${value}`, + () => "No value" + ); + + const numberResult = someOption.match( + (value) => value * 2, + () => 0 + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(numberResult).toEqualTypeOf(); + }); + + it("should infer union type when callbacks return different types", () => { + // Arrange + const someOption = some(42); + + // Act + const unionResult = someOption.match( + (value) => value.toString(), // returns string + () => 0 // returns number + ); + + // Assert + expectTypeOf(unionResult).toEqualTypeOf(); + }); + + it("should preserve complex return types", () => { + // Arrange + type ComplexType = { data: string[]; count: number }; + const someOption = some({ id: 1, name: "test" }); + + // Act + const complexResult = someOption.match( + (value): ComplexType => ({ data: [value.name], count: 1 }), + (): ComplexType => ({ data: [], count: 0 }) + ); + + // Assert + expectTypeOf(complexResult).toEqualTypeOf(); + }); + + it("should handle generic types correctly", () => { + // Arrange + const someOption = some([1, 2, 3]); + + // Act + const mappedResult = someOption.match( + (arr) => arr.map(x => x.toString()), + () => [] + ); + + // Assert + expectTypeOf(mappedResult).toEqualTypeOf(); + }); + }); + + describe("match with None", () => { + it("should infer return type from callback return types", () => { + // Arrange + const noneOption: Option = none(); + + // Act + const stringResult = noneOption.match( + (value) => `Value: ${value}`, + () => "No value" + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + }); + + it("should handle union return types with None", () => { + // Arrange + const noneOption = none() as Option; + + // Act + const unionResult = noneOption.match( + (value) => value.length, // returns number + () => "empty" // returns string + ); + + // Assert + expectTypeOf(unionResult).toEqualTypeOf(); + }); + }); + + describe("match callback parameter types", () => { + it("should provide correct parameter type to onSome callback", () => { + // Arrange + const numberOption = some(42); + const stringOption = some("hello"); + const objectOption = some({ id: 1, name: "test" }); + + // Act & Assert - testing parameter types + numberOption.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + () => 0 + ); + + stringOption.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + () => "" + ); + + objectOption.match( + (value) => { + expectTypeOf(value).toEqualTypeOf<{ id: number; name: string }>(); + return value; + }, + () => ({ id: 0, name: "" }) + ); + }); + + it("should require compatible return types from both callbacks", () => { + // Arrange + const someOption = some(42); + + // Act & Assert - these should compile (no @ts-expect-error needed since they should NOT raise errors) + someOption.match( + (value) => value.toString(), + () => "default" + ); + + someOption.match( + (value) => value, + () => 0 + ); + }); + }); + + describe("match function signature validation", () => { + it("should be callable with correct callback signatures", () => { + // Arrange + const someOption = some(42); + const onSome = (value: number) => value * 2; + const onNone = () => 0; + + // Assert + expectTypeOf(someOption.match).toBeCallableWith(onSome, onNone); + }); + + it("should not be callable with incorrect callback signatures", () => { + // Arrange + const someOption = some(42); + + // Assert + // @ts-expect-error - wrong parameter type for onSome + someOption.match((value: string) => value, () => ""); + // @ts-expect-error - onNone should not take parameters + someOption.match((x: number) => x, (value: number) => value); + }); + + it("should not be callable with missing callbacks", () => { + // Arrange + const someOption = some(42); + + // Assert + // @ts-expect-error - missing onNone callback + someOption.match((x: number) => x); + // @ts-expect-error - missing both callbacks + someOption.match(); + }); + }); + + describe("match with complex types", () => { + it("should handle async callbacks correctly", () => { + // Arrange + const someOption = some(42); + + // Act + const asyncResult = someOption.match( + async (value) => `Async: ${value}`, + async () => "Async: No value" + ); + + // Assert + expectTypeOf(asyncResult).toEqualTypeOf>(); + }); + + it("should handle callback functions as return values", () => { + // Arrange + const someOption = some(42); + + // Act + const functionResult = someOption.match( + (value) => (x: number) => x + value, + () => (x: number) => x + ); + + // Assert + expectTypeOf(functionResult).toEqualTypeOf<(x: number) => number>(); + }); + }); +}); diff --git a/src/option/spec/namespace.spec.ts b/src/option/spec/namespace.spec.ts new file mode 100644 index 0000000..eb149f1 --- /dev/null +++ b/src/option/spec/namespace.spec.ts @@ -0,0 +1,421 @@ +import { describe, it, expect } from 'vitest'; +import { Option, Some, None, some, none } from '../'; + +describe('Option namespace - Runtime Tests', () => { + describe('Option.from', () => { + it('should return Some for non-null/undefined values', () => { + // Arrange + const numberValue = 42; + const stringValue = 'hello'; + const objectValue = { id: 1 }; + const arrayValue = [1, 2, 3]; + + // Act + const numberResult = Option.from(numberValue); + const stringResult = Option.from(stringValue); + const objectResult = Option.from(objectValue); + const arrayResult = Option.from(arrayValue); + + // Assert + expect(numberResult.isSome()).toBe(true); + expect(stringResult.isSome()).toBe(true); + expect(objectResult.isSome()).toBe(true); + expect(arrayResult.isSome()).toBe(true); + }); + + it('should return None for null', () => { + // Arrange + const value = null; + + // Act + const result = Option.from(value); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return None for undefined', () => { + // Arrange + const value = undefined; + + // Act + const result = Option.from(value); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return Some for falsy values that are not null/undefined', () => { + // Arrange + const zero = 0; + const falseBool = false; + const emptyString = ''; + + // Act + const zeroResult = Option.from(zero); + const falseResult = Option.from(falseBool); + const emptyResult = Option.from(emptyString); + + // Assert + expect(zeroResult.isSome()).toBe(true); + expect(falseResult.isSome()).toBe(true); + expect(emptyResult.isSome()).toBe(true); + + if (zeroResult.isSome()) expect(zeroResult.value).toBe(0); + if (falseResult.isSome()) expect(falseResult.value).toBe(false); + if (emptyResult.isSome()) expect(emptyResult.value).toBe(''); + }); + + it('should handle nullable union types', () => { + // Arrange + const withValue: string | null = 'hello'; + const withoutValue: string | null = null; + + // Act + const someResult = Option.from(withValue); + const noneResult = Option.from(withoutValue); + + // Assert + expect(someResult.isSome()).toBe(true); + expect(noneResult.isNone()).toBe(true); + if (someResult.isSome()) expect(someResult.value).toBe('hello'); + }); + }); + + describe('Option.isOption', () => { + it('should return true for Some instances', () => { + // Arrange + const someValue = some(42); + + // Act + const result = Option.isOption(someValue); + + // Assert + expect(result).toBe(true); + }); + + it('should return true for None instances', () => { + // Arrange + const noneValue = none(); + + // Act + const result = Option.isOption(noneValue); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for non-Option values', () => { + // Arrange + const values = [42, 'hello', true, null, undefined, { isSome: true }, [1, 2, 3]]; + + values.forEach((value) => { + // Act + const result = Option.isOption(value); + + // Assert + expect(result).toBe(false); + }); + }); + + it('should return true for Some with new Some constructor', () => { + // Arrange + const someInstance = new Some(42); + + // Act + const result = Option.isOption(someInstance); + + // Assert + expect(result).toBe(true); + }); + + it('should return true for None with new None constructor', () => { + // Arrange + const noneInstance = new None(); + + // Act + const result = Option.isOption(noneInstance); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('Option.all', () => { + it('should return Some with all values when all options are Some', () => { + // Arrange + const options: Option[] = [some(1), some(2), some(3)]; + + // Act + const result = Option.all(options); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 2, 3]); + } + }); + + it('should return None when any option is None', () => { + // Arrange + const options: Option[] = [some(1), none(), some(3)]; + + // Act + const result = Option.all(options); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return Some with empty array for empty input', () => { + // Arrange + const options: Option[] = []; + + // Act + const result = Option.all(options); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([]); + } + }); + + it('should return None when first option is None', () => { + // Arrange + const options: Option[] = [none(), some(2), some(3)]; + + // Act + const result = Option.all(options); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return None when last option is None', () => { + // Arrange + const options: Option[] = [some(1), some(2), none()]; + + // Act + const result = Option.all(options); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return None when all options are None', () => { + // Arrange + const options: Option[] = [none(), none(), none()]; + + // Act + const result = Option.all(options); + + // Assert + expect(result.isNone()).toBe(true); + }); + }); + + describe('Option.partition', () => { + it('should partition mixed options into values and noneCount', () => { + // Arrange + const options: Option[] = [some(1), none(), some(3), none()]; + + // Act + const result = Option.partition(options); + + // Assert + expect(result.values).toEqual([1, 3]); + expect(result.noneCount).toBe(2); + }); + + it('should handle all Some options', () => { + // Arrange + const options: Option[] = [some('a'), some('b'), some('c')]; + + // Act + const result = Option.partition(options); + + // Assert + expect(result.values).toEqual(['a', 'b', 'c']); + expect(result.noneCount).toBe(0); + }); + + it('should handle all None options', () => { + // Arrange + const options: Option[] = [none(), none(), none()]; + + // Act + const result = Option.partition(options); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(3); + }); + + it('should handle empty array', () => { + // Arrange + const options: Option[] = []; + + // Act + const result = Option.partition(options); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(0); + }); + }); + + describe('Option.compact', () => { + it('should extract values from Some instances', () => { + // Arrange + const options: Option[] = [some(1), none(), some(3)]; + + // Act + const result = Option.compact(options); + + // Assert + expect(result).toEqual([1, 3]); + }); + + it('should return empty array when all None', () => { + // Arrange + const options: Option[] = [none(), none()]; + + // Act + const result = Option.compact(options); + + // Assert + expect(result).toEqual([]); + }); + + it('should return all values when all Some', () => { + // Arrange + const options: Option[] = [some('a'), some('b'), some('c')]; + + // Act + const result = Option.compact(options); + + // Assert + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should return empty array for empty input', () => { + // Arrange + const options: Option[] = []; + + // Act + const result = Option.compact(options); + + // Assert + expect(result).toEqual([]); + }); + + it('should handle falsy values in Some correctly', () => { + // Arrange + const options: Option[] = [ + some(0), + some(false), + some(''), + none(), + ]; + + // Act + const result = Option.compact(options); + + // Assert + expect(result).toEqual([0, false, '']); + }); + }); + + describe('Option.some', () => { + it('should return true when at least one option is Some', () => { + // Arrange + const options: Option[] = [none(), some(2), none()]; + + // Act + const result = Option.some(options); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all options are None', () => { + // Arrange + const options: Option[] = [none(), none(), none()]; + + // Act + const result = Option.some(options); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when all options are Some', () => { + // Arrange + const options: Option[] = [some(1), some(2), some(3)]; + + // Act + const result = Option.some(options); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for empty array', () => { + // Arrange + const options: Option[] = []; + + // Act + const result = Option.some(options); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('Option.every', () => { + it('should return true when all options are Some', () => { + // Arrange + const options: Option[] = [some(1), some(2), some(3)]; + + // Act + const result = Option.every(options); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when any option is None', () => { + // Arrange + const options: Option[] = [some(1), none(), some(3)]; + + // Act + const result = Option.every(options); + + // Assert + expect(result).toBe(false); + }); + + it('should return false when all options are None', () => { + // Arrange + const options: Option[] = [none(), none(), none()]; + + // Act + const result = Option.every(options); + + // Assert + expect(result).toBe(false); + }); + + it('should return true for empty array', () => { + // Arrange + const options: Option[] = []; + + // Act + const result = Option.every(options); + + // Assert + expect(result).toBe(true); + }); + }); +}); diff --git a/src/option/spec/namespace.type-spec.ts b/src/option/spec/namespace.type-spec.ts new file mode 100644 index 0000000..218447c --- /dev/null +++ b/src/option/spec/namespace.type-spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none, OptionPartitionResult } from "../"; + +describe("Option namespace - Type Tests", () => { + describe("Option.from", () => { + it("should return Option for non-nullable types", () => { + // Arrange & Act + const numberResult = Option.from(42); + const stringResult = Option.from("hello"); + const booleanResult = Option.from(true); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf>(); + expectTypeOf(stringResult).toEqualTypeOf>(); + expectTypeOf(booleanResult).toEqualTypeOf>(); + }); + + it("should return None for null type", () => { + // Arrange & Act + const result = Option.from(null); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return None for undefined type", () => { + // Arrange & Act + const result = Option.from(undefined); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return Option for nullable union types", () => { + // Arrange + const value = "hello" as string | null; + + // Act + const result = Option.from(value); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Option for undefined union types", () => { + // Arrange + const value = "hello" as string | undefined; + + // Act + const result = Option.from(value); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should preserve falsy non-nullable types as Option", () => { + // Arrange & Act + const zeroResult = Option.from(0); + const falseResult = Option.from(false); + const emptyResult = Option.from(""); + + // Assert + expectTypeOf(zeroResult).toEqualTypeOf>(); + expectTypeOf(falseResult).toEqualTypeOf>(); + expectTypeOf(emptyResult).toEqualTypeOf>(); + }); + + it("should reject async functions with a type-level error", () => { + // @ts-expect-error — async functions should not be passed to Option.from + const result = Option.from(async () => 42); + + expectTypeOf(result).toBeString(); + }); + }); + + describe("Option.isOption", () => { + it("should act as type guard narrowing to Option", () => { + // Arrange + const value: unknown = some(42); + + // Act & Assert + if (Option.isOption(value)) { + expectTypeOf(value).toEqualTypeOf>(); + } + }); + + it("should accept any value", () => { + // Assert + expectTypeOf(Option.isOption).toBeCallableWith(42); + expectTypeOf(Option.isOption).toBeCallableWith("hello"); + expectTypeOf(Option.isOption).toBeCallableWith(null); + expectTypeOf(Option.isOption).toBeCallableWith(undefined); + expectTypeOf(Option.isOption).toBeCallableWith(some(42)); + expectTypeOf(Option.isOption).toBeCallableWith(none()); + }); + + it("should return boolean", () => { + // Act + const result = Option.isOption(42); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("Option.all", () => { + it("should return Option from Option[]", () => { + // Arrange + const options: Option[] = [some(1), some(2)]; + + // Act + const result = Option.all(options); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should handle string options", () => { + // Arrange + const options: Option[] = [some("a"), some("b")]; + + // Act + const result = Option.all(options); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should accept empty arrays", () => { + // Arrange + const options: Option[] = []; + + // Act + const result = Option.all(options); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Some when all inputs are known Some", () => { + const result = Option.all([some(1), some(2), some(3)]); + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("Option.partition", () => { + it("should return OptionPartitionResult", () => { + // Arrange + const options: Option[] = [some(1), none()]; + + // Act + const result = Option.partition(options); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.values).toEqualTypeOf(); + expectTypeOf(result.noneCount).toEqualTypeOf(); + }); + + it("should handle string options", () => { + // Arrange + const options: Option[] = [some("a"), none()]; + + // Act + const result = Option.partition(options); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("Option.compact", () => { + it("should return T[] from Option[]", () => { + // Arrange + const options: Option[] = [some(1), none()]; + + // Act + const result = Option.compact(options); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should handle complex types", () => { + // Arrange + type Item = { id: number; name: string }; + const options: Option[] = [some({ id: 1, name: "a" }), none()]; + + // Act + const result = Option.compact(options); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("Option.some", () => { + it("should return boolean", () => { + // Arrange + const options: Option[] = [some(1), none()]; + + // Act + const result = Option.some(options); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should accept Option[] for any T", () => { + // Assert + expectTypeOf(Option.some).toBeCallableWith([some(1), none()] as Option[]); + expectTypeOf(Option.some).toBeCallableWith([some("a")] as Option[]); + expectTypeOf(Option.some).toBeCallableWith([] as Option[]); + }); + }); + + describe("Option.every", () => { + it("should return boolean", () => { + // Arrange + const options: Option[] = [some(1), some(2)]; + + // Act + const result = Option.every(options); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should accept Option[] for any T", () => { + // Assert + expectTypeOf(Option.every).toBeCallableWith([some(1), none()] as Option[]); + expectTypeOf(Option.every).toBeCallableWith([some("a")] as Option[]); + expectTypeOf(Option.every).toBeCallableWith([] as Option[]); + }); + }); +}); diff --git a/src/option/spec/nullable.spec.ts b/src/option/spec/nullable.spec.ts new file mode 100644 index 0000000..04df706 --- /dev/null +++ b/src/option/spec/nullable.spec.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +import { Option, Some, None, some, none } from '../'; + +describe('Option toNullable/toUndefined/toString - Runtime Tests', () => { + describe('toNullable() method', () => { + it('should return the value for Some', () => { + // Arrange + const option = some(42); + + // Act + const result = option.toNullable(); + + // Assert + expect(result).toBe(42); + }); + + it('should return null for None', () => { + // Arrange + const option = none(); + + // Act + const result = option.toNullable(); + + // Assert + expect(result).toBeNull(); + }); + + it('should handle falsy values in Some correctly', () => { + // Arrange + const zeroOption = some(0); + const falseOption = some(false); + const emptyStringOption = some(''); + + // Act + const zeroResult = zeroOption.toNullable(); + const falseResult = falseOption.toNullable(); + const emptyStringResult = emptyStringOption.toNullable(); + + // Assert + expect(zeroResult).toBe(0); + expect(falseResult).toBe(false); + expect(emptyStringResult).toBe(''); + }); + + it('should return complex objects for Some', () => { + // Arrange + const obj = { id: 1, name: 'test' }; + const option = some(obj); + + // Act + const result = option.toNullable(); + + // Assert + expect(result).toBe(obj); + }); + + it('should return null for None typed as Option', () => { + // Arrange + const option: Option = none() as Option; + + // Act + const result = option.toNullable(); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('toUndefined() method', () => { + it('should return the value for Some', () => { + // Arrange + const option = some(42); + + // Act + const result = option.toUndefined(); + + // Assert + expect(result).toBe(42); + }); + + it('should return undefined for None', () => { + // Arrange + const option = none(); + + // Act + const result = option.toUndefined(); + + // Assert + expect(result).toBeUndefined(); + }); + + it('should handle falsy values in Some correctly', () => { + // Arrange + const zeroOption = some(0); + const falseOption = some(false); + const emptyStringOption = some(''); + + // Act + const zeroResult = zeroOption.toUndefined(); + const falseResult = falseOption.toUndefined(); + const emptyStringResult = emptyStringOption.toUndefined(); + + // Assert + expect(zeroResult).toBe(0); + expect(falseResult).toBe(false); + expect(emptyStringResult).toBe(''); + }); + + it('should return complex objects for Some', () => { + // Arrange + const arr = [1, 2, 3]; + const option = some(arr); + + // Act + const result = option.toUndefined(); + + // Assert + expect(result).toBe(arr); + }); + + it('should return undefined for None typed as Option', () => { + // Arrange + const option: Option = none() as Option; + + // Act + const result = option.toUndefined(); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe('toString() method', () => { + it('should return "Some(value)" for Some with a number', () => { + // Arrange + const option = some(42); + + // Act + const result = option.toString(); + + // Assert + expect(result).toBe('Some(42)'); + }); + + it('should return "Some(value)" for Some with a string', () => { + // Arrange + const option = some('hello'); + + // Act + const result = option.toString(); + + // Assert + expect(result).toBe('Some(hello)'); + }); + + it('should return "Some(value)" for Some with a boolean', () => { + // Arrange + const trueOption = some(true); + const falseOption = some(false); + + // Act & Assert + expect(trueOption.toString()).toBe('Some(true)'); + expect(falseOption.toString()).toBe('Some(false)'); + }); + + it('should return "Some(value)" for Some with falsy values', () => { + // Arrange + const zeroOption = some(0); + const emptyOption = some(''); + + // Act & Assert + expect(zeroOption.toString()).toBe('Some(0)'); + expect(emptyOption.toString()).toBe('Some()'); + }); + + it('should return "None()" for None', () => { + // Arrange + const option = none(); + + // Act + const result = option.toString(); + + // Assert + expect(result).toBe('None()'); + }); + + it('should return "None()" for None typed as Option', () => { + // Arrange + const option: Option = none() as Option; + + // Act + const result = option.toString(); + + // Assert + expect(result).toBe('None()'); + }); + + it('should handle object values using default coercion', () => { + // Arrange + const option = some({ id: 1 }); + + // Act + const result = option.toString(); + + // Assert + expect(result).toBe('Some([object Object])'); + }); + + it('should handle null and undefined values in Some', () => { + // Arrange + const nullOption = some(null); + const undefinedOption = some(undefined); + + // Act & Assert + expect(nullOption.toString()).toBe('Some(null)'); + expect(undefinedOption.toString()).toBe('Some(undefined)'); + }); + }); +}); diff --git a/src/option/spec/nullable.type-spec.ts b/src/option/spec/nullable.type-spec.ts new file mode 100644 index 0000000..565230f --- /dev/null +++ b/src/option/spec/nullable.type-spec.ts @@ -0,0 +1,130 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none } from "../"; + +describe("Option toNullable/toUndefined/toString - Type Tests", () => { + describe("toNullable() return types", () => { + it("should return T for Some", () => { + // Arrange + const numberSome = some(42); + const stringSome = some("hello"); + const objectSome = some({ id: 1 }); + + // Act + const numberResult = numberSome.toNullable(); + const stringResult = stringSome.toNullable(); + const objectResult = objectSome.toNullable(); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(objectResult).toEqualTypeOf<{ id: number }>(); + }); + + it("should return null for None", () => { + // Arrange + const noneInstance = none(); + + // Act + const result = noneInstance.toNullable(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return T | null for Option", () => { + // Arrange + const option: Option = some(42) as Option; + + // Act + const result = option.toNullable(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should handle complex types", () => { + // Arrange + type ComplexType = { data: string[]; count: number }; + const option: Option = some({ data: ["a"], count: 1 }) as Option; + + // Act + const result = option.toNullable(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("toUndefined() return types", () => { + it("should return T for Some", () => { + // Arrange + const numberSome = some(42); + const stringSome = some("hello"); + + // Act + const numberResult = numberSome.toUndefined(); + const stringResult = stringSome.toUndefined(); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + }); + + it("should return undefined for None", () => { + // Arrange + const noneInstance = none(); + + // Act + const result = noneInstance.toUndefined(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return T | undefined for Option", () => { + // Arrange + const option: Option = some("hello") as Option; + + // Act + const result = option.toUndefined(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("toString() return type", () => { + it("should return string for Some", () => { + // Arrange + const someOption = some(42); + + // Act + const result = someOption.toString(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return string for None", () => { + // Arrange + const noneOption = none(); + + // Act + const result = noneOption.toString(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return string for Option", () => { + // Arrange + const option: Option = some(42) as Option; + + // Act + const result = option.toString(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); +}); diff --git a/src/option/spec/option.spec.ts b/src/option/spec/option.spec.ts new file mode 100644 index 0000000..d61c632 --- /dev/null +++ b/src/option/spec/option.spec.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { Option, Some, None, some, none } from '../'; + +describe('Option constructors and factories - Runtime Tests', () => { + describe('Some constructor', () => { + it('should create Some instance with provided value', () => { + // Arrange + const value = 42; + + // Act + const result = new Some(value); + + // Assert + expect(result).toBeInstanceOf(Some); + expect(result.value).toBe(value); + }); + + it('should store different types of values correctly', () => { + // Arrange + const numberValue = 42; + const stringValue = 'hello'; + const objectValue = { id: 1, name: 'test' }; + const arrayValue = [1, 2, 3]; + const functionValue = (x: number) => x * 2; + + // Act + const numberSome = new Some(numberValue); + const stringSome = new Some(stringValue); + const objectSome = new Some(objectValue); + const arraySome = new Some(arrayValue); + const functionSome = new Some(functionValue); + + // Assert + expect(numberSome.value).toBe(numberValue); + expect(stringSome.value).toBe(stringValue); + expect(objectSome.value).toBe(objectValue); + expect(arraySome.value).toBe(arrayValue); + expect(functionSome.value).toBe(functionValue); + }); + + it('should handle null and undefined values', () => { + // Arrange & Act + const nullSome = new Some(null); + const undefinedSome = new Some(undefined); + + // Assert + expect(nullSome.value).toBeNull(); + expect(undefinedSome.value).toBeUndefined(); + }); + + it('should implement OptionInterface methods', () => { + // Arrange + const someInstance = new Some(42); + + // Assert + expect(typeof someInstance.isSome).toBe('function'); + expect(typeof someInstance.isNone).toBe('function'); + expect(typeof someInstance.map).toBe('function'); + expect(typeof someInstance.flatMap).toBe('function'); + expect(typeof someInstance.match).toBe('function'); + expect(typeof someInstance.filter).toBe('function'); + expect(typeof someInstance.toNullable).toBe('function'); + expect(typeof someInstance.toUndefined).toBe('function'); + expect(typeof someInstance.toString).toBe('function'); + }); + + it('should correctly identify as Some', () => { + // Arrange + const someInstance = new Some(42); + + // Act & Assert + expect(someInstance.isSome()).toBe(true); + expect(someInstance.isNone()).toBe(false); + }); + }); + + describe('None constructor', () => { + it('should create None instance', () => { + // Act + const result = new None(); + + // Assert + expect(result).toBeInstanceOf(None); + }); + + it('should implement OptionInterface methods', () => { + // Arrange + const noneInstance = new None(); + + // Assert + expect(typeof noneInstance.isSome).toBe('function'); + expect(typeof noneInstance.isNone).toBe('function'); + expect(typeof noneInstance.map).toBe('function'); + expect(typeof noneInstance.flatMap).toBe('function'); + expect(typeof noneInstance.match).toBe('function'); + expect(typeof noneInstance.filter).toBe('function'); + expect(typeof noneInstance.toNullable).toBe('function'); + expect(typeof noneInstance.toUndefined).toBe('function'); + expect(typeof noneInstance.toString).toBe('function'); + }); + + it('should correctly identify as None', () => { + // Arrange + const noneInstance = new None(); + + // Act & Assert + expect(noneInstance.isSome()).toBe(false); + expect(noneInstance.isNone()).toBe(true); + }); + }); + + describe('some() factory function', () => { + it('should create Some instance with provided value', () => { + // Arrange + const value = 42; + + // Act + const result = some(value); + + // Assert + expect(result).toBeInstanceOf(Some); + expect(result.value).toBe(value); + }); + + it('should work with various data types', () => { + // Arrange + const testCases = [ + 42, + 'hello', + true, + { id: 1, name: 'test' }, + [1, 2, 3], + null, + undefined, + (x: number) => x * 2 + ]; + + testCases.forEach(testValue => { + // Act + const result = some(testValue); + + // Assert + expect(result).toBeInstanceOf(Some); + expect(result.value).toBe(testValue); + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + }); + }); + + it('should create new instances for each call', () => { + // Arrange + const value = 42; + + // Act + const first = some(value); + const second = some(value); + + // Assert + expect(first).toBeInstanceOf(Some); + expect(second).toBeInstanceOf(Some); + expect(first).not.toBe(second); // Different instances + expect(first.value).toBe(second.value); // Same value + }); + }); + + describe('none() factory function', () => { + it('should return None instance', () => { + // Act + const result = none(); + + // Assert + expect(result).toBeInstanceOf(None); + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(true); + }); + + it('should return the same singleton instance', () => { + // Act + const first = none(); + const second = none(); + const third = none(); + + // Assert + expect(first).toBe(second); + expect(second).toBe(third); + expect(first).toBe(third); + }); + + it('should not accept any arguments', () => { + // Act & Assert + expect(() => none()).not.toThrow(); + + // TypeScript should prevent this, but testing runtime behavior + // @ts-expect-error Testing runtime behavior when called incorrectly + expect(() => none(42)).not.toThrow(); + }); + }); + + describe('Integration tests', () => { + it('should allow creating mixed arrays of Options', () => { + // Arrange & Act + const options: Option[] = [ + some(1) as Option, + some(2) as Option, + none() as Option, + some(3) as Option + ]; + + // Assert + expect(options).toHaveLength(4); + expect(options[0].isSome()).toBe(true); + expect(options[1].isSome()).toBe(true); + expect(options[2].isNone()).toBe(true); + expect(options[3].isSome()).toBe(true); + }); + + it('should enable type narrowing in runtime', () => { + // Arrange + const option: Option = some('hello') as Option; + + // Act & Assert + if (option.isSome()) { + expect(option.value).toBe('hello'); + expect(typeof option.value).toBe('string'); + } else { + fail('Expected option to be Some'); + } + }); + + it('should handle edge cases correctly', () => { + // Arrange & Act + const zeroSome = some(0); + const falseSome = some(false); + const emptySome = some(''); + const emptyArraySome = some([]); + + // Assert - All should be Some instances even with "falsy" values + expect(zeroSome.isSome()).toBe(true); + expect(falseSome.isSome()).toBe(true); + expect(emptySome.isSome()).toBe(true); + expect(emptyArraySome.isSome()).toBe(true); + + expect(zeroSome.value).toBe(0); + expect(falseSome.value).toBe(false); + expect(emptySome.value).toBe(''); + expect(emptyArraySome.value).toEqual([]); + }); + }); +}); diff --git a/src/option/spec/option.type-spec.ts b/src/option/spec/option.type-spec.ts new file mode 100644 index 0000000..e7768d2 --- /dev/null +++ b/src/option/spec/option.type-spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none, OptionInterface } from "../"; + +describe("Option constructors and factories - Type Tests", () => { + describe("Some constructor", () => { + it("should create Some with correct type inference", () => { + // Arrange & Act + const numberSome = new Some(42); + const stringSome = new Some("hello"); + const objectSome = new Some({ id: 1, name: "test" }); + const arraySome = new Some([1, 2, 3]); + + // Assert + expectTypeOf(numberSome).toEqualTypeOf>(); + expectTypeOf(stringSome).toEqualTypeOf>(); + expectTypeOf(objectSome).toEqualTypeOf>(); + expectTypeOf(arraySome).toEqualTypeOf>(); + }); + + it("should implement OptionInterface with correct type parameter", () => { + // Arrange + const somePrimitive = new Some(42); + const someObject = new Some({ value: "test" }); + + // Assert + expectTypeOf(somePrimitive).toMatchTypeOf>(); + expectTypeOf(someObject).toMatchTypeOf>(); + }); + + it("should be assignable to Option", () => { + // Arrange + const numberSome = new Some(42); + const stringSome = new Some("hello"); + + // Assert + expectTypeOf(numberSome).toMatchTypeOf>(); + expectTypeOf(stringSome).toMatchTypeOf>(); + }); + + it("should preserve complex generic types", () => { + // Arrange + type ComplexType = { data: Array<{ id: number; tags: string[] }> }; + const complexValue: ComplexType = { data: [{ id: 1, tags: ["a", "b"] }] }; + const complexSome = new Some(complexValue); + + // Assert + expectTypeOf(complexSome).toEqualTypeOf>(); + expectTypeOf(complexSome).toMatchTypeOf>(); + }); + + it("should handle nullable and undefined types correctly", () => { + // Arrange + const nullableSome = new Some(null); + const undefinedSome = new Some(undefined); + const unionSome = new Some("hello" as string | null); + + // Assert + expectTypeOf(nullableSome).toEqualTypeOf>(); + expectTypeOf(undefinedSome).toEqualTypeOf>(); + expectTypeOf(unionSome).toEqualTypeOf>(); + }); + }); + + describe("None constructor", () => { + it("should create None type", () => { + // Arrange & Act + const noneInstance = new None(); + + // Assert + expectTypeOf(noneInstance).toEqualTypeOf(); + }); + + it("should implement OptionInterface", () => { + // Arrange + const noneInstance = new None(); + + // Assert + expectTypeOf(noneInstance).toMatchTypeOf>(); + }); + + it("should be assignable to Option for any T", () => { + // Arrange + const noneInstance = new None(); + + // Assert + expectTypeOf(noneInstance).toMatchTypeOf>(); + expectTypeOf(noneInstance).toMatchTypeOf>(); + expectTypeOf(noneInstance).toMatchTypeOf>(); + }); + }); + + describe("some() factory function", () => { + it("should infer correct return type from value", () => { + // Arrange & Act + const numberSome = some(42); + const stringSome = some("hello"); + const booleanSome = some(true); + const objectSome = some({ id: 1 }); + const arraySome = some([1, 2, 3]); + + // Assert + expectTypeOf(numberSome).toEqualTypeOf>(); + expectTypeOf(stringSome).toEqualTypeOf>(); + expectTypeOf(booleanSome).toEqualTypeOf>(); + expectTypeOf(objectSome).toEqualTypeOf>(); + expectTypeOf(arraySome).toEqualTypeOf>(); + }); + + it("should preserve literal types", () => { + // Arrange & Act + const literalNumber = some(42 as const); + const literalString = some("hello" as const); + const literalObject = some({ type: "user" } as const); + + // Assert + expectTypeOf(literalNumber).toEqualTypeOf>(); + expectTypeOf(literalString).toEqualTypeOf>(); + expectTypeOf(literalObject).toEqualTypeOf>(); + }); + + it("should handle function types correctly", () => { + // Arrange + const simpleFunc = (x: number) => x * 2; + const asyncFunc = async (x: string) => x.toUpperCase(); + + // Act + const funcSome = some(simpleFunc); + const asyncFuncSome = some(asyncFunc); + + // Assert + expectTypeOf(funcSome).toEqualTypeOf number>>(); + expectTypeOf(asyncFuncSome).toEqualTypeOf Promise>>(); + }); + + it("should be callable with any type", () => { + // Assert - testing that some can be called with various types + expectTypeOf(some).toBeCallableWith(42); + expectTypeOf(some).toBeCallableWith("string"); + expectTypeOf(some).toBeCallableWith(null); + expectTypeOf(some).toBeCallableWith(undefined); + expectTypeOf(some).toBeCallableWith({ complex: { nested: "object" } }); + }); + }); + + describe("none() factory function", () => { + it("should return None type", () => { + // Arrange & Act + const noneResult = none(); + + // Assert + expectTypeOf(noneResult).toEqualTypeOf(); + }); + + it("should not be callable with arguments", () => { + // Assert + // @ts-expect-error - none() should not accept arguments + none(42); + // @ts-expect-error - none() should not accept arguments + none("string"); + // @ts-expect-error - none() should not accept arguments + none(null); + }); + + it("should be assignable to any Option", () => { + // Arrange + const noneResult = none(); + + // Assert + expectTypeOf(noneResult).toMatchTypeOf>(); + expectTypeOf(noneResult).toMatchTypeOf>(); + expectTypeOf(noneResult).toMatchTypeOf>(); + }); + }); + + describe("Type narrowing with constructors", () => { + it("should enable proper type narrowing with isSome/isNone", () => { + // Arrange + const option: Option = some(42) as Option; + + // Act & Assert - TypeScript should understand type narrowing + if (option.isSome()) { + expectTypeOf(option).toEqualTypeOf>(); + expectTypeOf(option.value).toEqualTypeOf(); + } + + if (option.isNone()) { + expectTypeOf(option).toEqualTypeOf(); + } + }); + }); +}); diff --git a/src/option/spec/promise.spec.ts b/src/option/spec/promise.spec.ts new file mode 100644 index 0000000..82ea8ff --- /dev/null +++ b/src/option/spec/promise.spec.ts @@ -0,0 +1,381 @@ +import { describe, it, expect } from 'vitest'; +import { Option, Some, None, some, none, OptionPromise } from '../'; + +describe('OptionPromise - Runtime Tests', () => { + describe('OptionPromise.from', () => { + it('should return Some when promise resolves to non-null value', async () => { + // Arrange + const fn = () => Promise.resolve(42); + + // Act + const result = await OptionPromise.from(fn); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should return None when promise resolves to null', async () => { + // Arrange + const fn = () => Promise.resolve(null); + + // Act + const result = await OptionPromise.from(fn); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return None when promise resolves to undefined', async () => { + // Arrange + const fn = () => Promise.resolve(undefined); + + // Act + const result = await OptionPromise.from(fn); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return Some for falsy non-null/undefined values', async () => { + // Arrange + const zeroFn = () => Promise.resolve(0); + const falseFn = () => Promise.resolve(false); + const emptyFn = () => Promise.resolve(''); + + // Act + const zeroResult = await OptionPromise.from(zeroFn); + const falseResult = await OptionPromise.from(falseFn); + const emptyResult = await OptionPromise.from(emptyFn); + + // Assert + expect(zeroResult.isSome()).toBe(true); + expect(falseResult.isSome()).toBe(true); + expect(emptyResult.isSome()).toBe(true); + + if (zeroResult.isSome()) expect(zeroResult.value).toBe(0); + if (falseResult.isSome()) expect(falseResult.value).toBe(false); + if (emptyResult.isSome()) expect(emptyResult.value).toBe(''); + }); + + it('should return Some for complex objects', async () => { + // Arrange + const obj = { id: 1, name: 'test' }; + const fn = () => Promise.resolve(obj); + + // Act + const result = await OptionPromise.from(fn); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(obj); + } + }); + + it('should propagate promise rejections', async () => { + // Arrange + const error = new Error('test error'); + const fn = () => Promise.reject(error); + + // Act & Assert + await expect(OptionPromise.from(fn)).rejects.toThrow('test error'); + }); + }); + + describe('OptionPromise.all', () => { + it('should return Some with all values when all resolve to Some', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(some(2) as Option), + Promise.resolve(some(3) as Option), + ]; + + // Act + const result = await OptionPromise.all(promises); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 2, 3]); + } + }); + + it('should return None when any resolves to None', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(none() as Option), + Promise.resolve(some(3) as Option), + ]; + + // Act + const result = await OptionPromise.all(promises); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return Some with empty array for empty input', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await OptionPromise.all(promises); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([]); + } + }); + + it('should return None when all resolve to None', async () => { + // Arrange + const promises = [ + Promise.resolve(none() as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.all(promises); + + // Assert + expect(result.isNone()).toBe(true); + }); + }); + + describe('OptionPromise.partition', () => { + it('should partition resolved options into values and noneCount', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(none() as Option), + Promise.resolve(some(3) as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.partition(promises); + + // Assert + expect(result.values).toEqual([1, 3]); + expect(result.noneCount).toBe(2); + }); + + it('should handle all Some promises', async () => { + // Arrange + const promises = [ + Promise.resolve(some('a') as Option), + Promise.resolve(some('b') as Option), + ]; + + // Act + const result = await OptionPromise.partition(promises); + + // Assert + expect(result.values).toEqual(['a', 'b']); + expect(result.noneCount).toBe(0); + }); + + it('should handle all None promises', async () => { + // Arrange + const promises = [ + Promise.resolve(none() as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.partition(promises); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(2); + }); + + it('should handle empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await OptionPromise.partition(promises); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(0); + }); + }); + + describe('OptionPromise.compact', () => { + it('should extract values from Some promises', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(none() as Option), + Promise.resolve(some(3) as Option), + ]; + + // Act + const result = await OptionPromise.compact(promises); + + // Assert + expect(result).toEqual([1, 3]); + }); + + it('should return empty array when all None', async () => { + // Arrange + const promises = [ + Promise.resolve(none() as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.compact(promises); + + // Assert + expect(result).toEqual([]); + }); + + it('should return all values when all Some', async () => { + // Arrange + const promises = [ + Promise.resolve(some('a') as Option), + Promise.resolve(some('b') as Option), + Promise.resolve(some('c') as Option), + ]; + + // Act + const result = await OptionPromise.compact(promises); + + // Assert + expect(result).toEqual(['a', 'b', 'c']); + }); + + it('should return empty array for empty input', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await OptionPromise.compact(promises); + + // Assert + expect(result).toEqual([]); + }); + }); + + describe('OptionPromise.some', () => { + it('should return true when at least one resolves to Some', async () => { + // Arrange + const promises = [ + Promise.resolve(none() as Option), + Promise.resolve(some(2) as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.some(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all resolve to None', async () => { + // Arrange + const promises = [ + Promise.resolve(none() as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.some(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when all resolve to Some', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(some(2) as Option), + ]; + + // Act + const result = await OptionPromise.some(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await OptionPromise.some(promises); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('OptionPromise.every', () => { + it('should return true when all resolve to Some', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(some(2) as Option), + Promise.resolve(some(3) as Option), + ]; + + // Act + const result = await OptionPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when any resolves to None', async () => { + // Arrange + const promises = [ + Promise.resolve(some(1) as Option), + Promise.resolve(none() as Option), + Promise.resolve(some(3) as Option), + ]; + + // Act + const result = await OptionPromise.every(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return false when all resolve to None', async () => { + // Arrange + const promises = [ + Promise.resolve(none() as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = await OptionPromise.every(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return true for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await OptionPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + }); +}); diff --git a/src/option/spec/promise.type-spec.ts b/src/option/spec/promise.type-spec.ts new file mode 100644 index 0000000..eca5e4b --- /dev/null +++ b/src/option/spec/promise.type-spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Option, Some, None, some, none, OptionPromise, OptionPartitionResult, OptionFromType } from "../"; + +describe("OptionPromise - Type Tests", () => { + describe("OptionPromise.from", () => { + it("should return Promise> for non-nullable resolved types", () => { + // Act + const numberResult = OptionPromise.from(() => Promise.resolve(42)); + const stringResult = OptionPromise.from(() => Promise.resolve("hello")); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf>>(); + expectTypeOf(stringResult).toEqualTypeOf>>(); + }); + + it("should return Promise for null resolved type", () => { + // Act + const result = OptionPromise.from(() => Promise.resolve(null)); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should return Promise for undefined resolved type", () => { + // Act + const result = OptionPromise.from(() => Promise.resolve(undefined)); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should handle nullable union resolved types", () => { + // Act + const result = OptionPromise.from(() => Promise.resolve("hello" as string | null)); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should require a function that returns a promise", () => { + // Assert + expectTypeOf(OptionPromise.from).toBeCallableWith(() => Promise.resolve(42)); + expectTypeOf(OptionPromise.from).toBeCallableWith(() => Promise.resolve("test")); + // @ts-expect-error - should not accept a non-function + OptionPromise.from(42); + // @ts-expect-error - should not accept a function that doesn't return a promise + OptionPromise.from(() => 42); + }); + }); + + describe("OptionPromise.all", () => { + it("should return Promise> from Promise>[]", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(some(1) as Option), + Promise.resolve(some(2) as Option), + ]; + + // Act + const result = OptionPromise.all(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should handle string option promises", () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = OptionPromise.all(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + }); + + describe("OptionPromise.partition", () => { + it("should return Promise>", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(some(1) as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = OptionPromise.partition(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + }); + + describe("OptionPromise.compact", () => { + it("should return Promise from Promise>[]", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(some(1) as Option), + Promise.resolve(none() as Option), + ]; + + // Act + const result = OptionPromise.compact(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should handle complex types", () => { + // Arrange + type Item = { id: number; name: string }; + const promises: Promise>[] = []; + + // Act + const result = OptionPromise.compact(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("OptionPromise.some", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(some(1) as Option), + ]; + + // Act + const result = OptionPromise.some(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("OptionPromise.every", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(some(1) as Option), + ]; + + // Act + const result = OptionPromise.every(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/query/index.ts b/src/query/index.ts new file mode 100644 index 0000000..8411f40 --- /dev/null +++ b/src/query/index.ts @@ -0,0 +1,758 @@ +import { isNullish } from '../util'; + +/** + * @template T The type of the success value. + * @template E The type of the error value. + */ +export interface QueryInterface { + /** + * Determines whether this query contains a value. + * @returns `true` if this query contains a value, `false` otherwise. + * ```ts + * okSome(42).isSome() // true + * okNone().isSome() // false + * errNone('error').isSome() // false + * ``` + */ + isSome(): this is OkSome; + + /** + * Determines whether this query succeeded with no value. + * @returns `true` if this query succeeded but found no value, `false` otherwise. + * ```ts + * okSome(42).isNone() // false + * okNone().isNone() // true + * errNone('error').isNone() // false + * ``` + */ + isNone(): this is OkNone; + + /** + * Determines whether this query is an error. + * @returns `true` if this query is an error, `false` otherwise. + * ```ts + * okSome(42).isErr() // false + * okNone().isErr() // false + * errNone('error').isErr() // true + * ``` + */ + isErr(): this is ErrNone; + + /** + * Transforms the contained value by applying a function to it. + * @param fn Function to apply to the contained value. + * @returns A new query containing the transformed value, or the original query unchanged. + * @remarks When called on a known `OkSome`, returns `OkSome`. On `OkNone` or `ErrNone`, returns the same instance — the function is never invoked. + * ```ts + * okSome(5).map(x => x * 2) // OkSome(10) + * okNone().map(x => x * 2) // OkNone + * errNone('error').map(x => x * 2) // ErrNone('error') + * ``` + */ + map(fn: (value: T) => U): Query; + + /** + * Transforms the contained error by applying a function to it. + * @param fn Function to apply to the contained error. + * @returns A new query containing the transformed error, or the original query unchanged. + * @remarks When called on a known `ErrNone`, returns `ErrNone`. On `OkSome` or `OkNone`, returns the same instance — the function is never invoked. + * ```ts + * okSome(42).mapErr(e => e.toUpperCase()) // OkSome(42) + * okNone().mapErr(e => e.toUpperCase()) // OkNone + * errNone('error').mapErr(e => e.toUpperCase()) // ErrNone('ERROR') + * ``` + */ + mapErr(fn: (error: E) => F): Query; + + /** + * Applies a function that returns a query to the contained value and flattens the result. + * @param fn Function that takes the contained value and returns a query. + * @returns The query returned by the function, or unchanged if this query does not contain a value. + * ```ts + * okSome(5).flatMap(x => okSome(x * 2)) // OkSome(10) + * okSome(5).flatMap(x => okNone()) // OkNone + * okNone().flatMap(x => okSome(x * 2)) // OkNone + * errNone('error').flatMap(x => okSome(x * 2)) // ErrNone('error') + * ``` + */ + flatMap>(fn: (value: T) => R): R; + + /** + * Applies a function that returns a query to the contained error and flattens the result. + * @param fn Function that takes the contained error and returns a query. + * @returns The query returned by the function, or unchanged if this query is not an error. + * ```ts + * okSome(42).flatMapErr(e => errNone(e.toUpperCase())) // OkSome(42) + * okNone().flatMapErr(e => errNone(e.toUpperCase())) // OkNone + * errNone('error').flatMapErr(e => errNone(e.toUpperCase())) // ErrNone('ERROR') + * errNone('error').flatMapErr(e => okSome('recovered')) // OkSome('recovered') + * ``` + */ + flatMapErr>(fn: (error: E) => R): R; + + /** + * Executes one of three functions based on whether this query contains a value, is empty, or is an error. + * @param onSome Function to execute if this query contains a value. + * @param onNone Function to execute if this query succeeded with no value. + * @param onErr Function to execute if this query is an error. + * @returns The return value of whichever function is executed. `U` is inferred as the union of all three callback return types. + * ```ts + * okSome(42).match(x => `Value: ${x}`, () => 'No value', e => `Error: ${e}`) // 'Value: 42' + * okNone().match(x => `Value: ${x}`, () => 'No value', e => `Error: ${e}`) // 'No value' + * errNone('error').match(x => `Value: ${x}`, () => 'No value', e => `Error: ${e}`) // 'Error: error' + * ``` + */ + match(onSome: (value: T) => U, onNone: () => U, onErr: (error: E) => U): U; + + /** + * Filters this query based on a type predicate, narrowing the value type on success. + * @param predicate Type predicate function to test the contained value. + * @returns OkSome with the narrowed type if the predicate passes, OkNone otherwise. A no-op on OkNone and ErrNone. + * @remarks This overload resolves when the predicate is a type guard (`value is U`), narrowing `T` to `U`. + * Filter failure demotes OkSome to OkNone (absence), not ErrNone (error). Use `flatMap` to produce errors from conditions. + * ```ts + * okSome(42 as number | string).filter((x): x is number => typeof x === 'number') // OkSome(42): Query + * okSome('hello' as number | string).filter((x): x is number => typeof x === 'number') // OkNone + * ``` + */ + filter(predicate: (value: T) => value is U): Query; + + /** + * Filters this query based on a boolean predicate. + * @param predicate Function to test the contained value. + * @returns OkSome if the value satisfies the predicate, OkNone otherwise. A no-op on OkNone and ErrNone. + * @remarks This overload resolves when the predicate returns `boolean` (not a type guard). The value type `T` is preserved. + * Filter failure demotes OkSome to OkNone (absence), not ErrNone (error). Use `flatMap` to produce errors from conditions. + * ```ts + * okSome(5).filter(x => x > 3) // OkSome(5) + * okSome(2).filter(x => x > 3) // OkNone + * okNone().filter(x => x > 3) // OkNone + * errNone('error').filter(x => x > 3) // ErrNone('error') + * ``` + */ + filter(predicate: (value: T) => boolean): Query; + + /** + * Returns the contained value or null. + * @returns The contained value if this query is OkSome, null otherwise. + * ```ts + * okSome(42).toNullable() // 42 + * okNone().toNullable() // null + * errNone('error').toNullable() // null + * ``` + */ + toNullable(): T | null; + + /** + * Returns the contained value or undefined. + * @returns The contained value if this query is OkSome, undefined otherwise. + * ```ts + * okSome(42).toUndefined() // 42 + * okNone().toUndefined() // undefined + * errNone('error').toUndefined() // undefined + * ``` + */ + toUndefined(): T | undefined; + + /** + * Returns a string representation of this query. + * @returns A string describing the query type and its contents. + * ```ts + * okSome(42).toString() // 'OkSome(42)' + * okNone().toString() // 'OkNone()' + * errNone('error').toString() // 'ErrNone(error)' + * ``` + */ + toString(): string; +} + +/** + * Represents a successful query containing a value of type `T`. + * + * The error type is always `never` — error-side methods (`mapErr`, `flatMapErr`) are no-ops + * that return the same `OkSome` instance unchanged. + * @remarks The contained value is accessible via the `.value` property, either directly or after narrowing with `isSome()`. + */ +export class OkSome implements QueryInterface { + constructor(readonly value: T) {} + + isSome(): this is OkSome { + return true; + } + + isNone(): this is never { + return false; + } + + isErr(): this is never { + return false; + } + + map(fn: (value: T) => U): OkSome { + return okSome(fn(this.value)); + } + + mapErr(): OkSome { + return this; + } + + flatMap>(fn: (value: T) => R): R { + return fn(this.value); + } + + // @ts-expect-error — no-op returns self; union dispatch resolves correctly + flatMapErr(): this { + return this; + } + + match(onSome: (value: T) => U, onNone: () => U, onErr: (error: never) => U): U { + return onSome(this.value); + } + + filter(predicate: (value: T) => value is U): OkSome | OkNone; + filter(predicate: (value: T) => boolean): OkSome | OkNone; + filter(predicate: (value: T) => boolean): OkSome | OkNone { + return predicate(this.value) ? this : okNone(); + } + + toNullable(): T { + return this.value; + } + + toUndefined(): T { + return this.value; + } + + toString(): string { + return `OkSome(${this.value})`; + } +} + +/** + * Represents a successful query that found no value. + * + * Both value and error types are `never` — all transformation methods (`map`, `mapErr`, `flatMap`, + * `flatMapErr`, `filter`) are no-ops that return the same `OkNone` instance without invoking callbacks. + * Instantiated via `okNone()`, which always returns the same frozen instance. + */ +export class OkNone implements QueryInterface { + isSome(): this is never { + return false; + } + + isNone(): this is OkNone { + return true; + } + + isErr(): this is never { + return false; + } + + map(): OkNone { + return this; + } + + mapErr(): OkNone { + return this; + } + + // @ts-expect-error — no-op returns self; union dispatch resolves correctly + flatMap(): this { + return this; + } + + // @ts-expect-error — no-op returns self; union dispatch resolves correctly + flatMapErr(): this { + return this; + } + + match(onSome: (value: never) => U, onNone: () => U, onErr: (error: never) => U): U { + return onNone(); + } + + filter(): OkNone { + return this; + } + + toNullable(): null { + return null; + } + + toUndefined(): undefined { + return undefined; + } + + toString(): string { + return 'OkNone()'; + } +} + +/** + * Represents an error query containing an error of type `E`. + * + * The value type is always `never` — value-side methods (`map`, `flatMap`, `filter`) are no-ops + * that return the same `ErrNone` instance unchanged. + * @remarks The contained error is accessible via the `.err` property, either directly or after narrowing with `isErr()`. + */ +export class ErrNone implements QueryInterface { + constructor(readonly err: E) {} + + isSome(): this is never { + return false; + } + + isNone(): this is never { + return false; + } + + isErr(): this is ErrNone { + return true; + } + + map(): ErrNone { + return this; + } + + mapErr(fn: (error: E) => F): ErrNone { + return errNone(fn(this.err)); + } + + // @ts-expect-error — no-op returns self; union dispatch resolves correctly + flatMap(): this { + return this; + } + + flatMapErr>(fn: (error: E) => R): R { + return fn(this.err); + } + + match(onSome: (value: never) => U, onNone: () => U, onErr: (error: E) => U): U { + return onErr(this.err); + } + + filter(): ErrNone { + return this; + } + + toNullable(): null { + return null; + } + + toUndefined(): undefined { + return undefined; + } + + toString(): string { + return `ErrNone(${this.err})`; + } +} + +/** + * Represents a query that is either OkSome containing a value, OkNone indicating no value found, or ErrNone containing an error. + * @remarks Use `isSome()`, `isNone()`, or `isErr()` to check the query type and access the contained value via `.value` or error via `.err`. + * ```ts + * const query = okSome(42); + * if (query.isSome()) { + * console.log(query.value); // 42 + * } + * + * const empty = okNone(); + * if (empty.isNone()) { + * console.log('No value found'); + * } + * + * const error = errNone('failed'); + * if (error.isErr()) { + * console.log(error.err); // 'failed' + * } + * ``` + */ +export type Query = OkSome | OkNone | ErrNone; + +/** + * Represents a promise that resolves to a Query. + */ +export type QueryPromise = Promise>; + +/** + * Represents a promise that resolves to OkSome. + */ +export type OkSomePromise = Promise>; + +/** + * Represents a promise that resolves to OkNone. + */ +export type OkNonePromise = Promise; + +/** + * Represents a promise that resolves to ErrNone. + */ +export type ErrNonePromise = Promise>; + +/** + * Represents the result of partitioning an array of queries into values, none count, and errors. + */ +export type QueryPartitionResult = { + values: T[]; + noneCount: number; + errors: E[]; +}; + +/** + * Conditional type that maps a value type to its `Query.from()` return type. + * + * When `T` is only `null | undefined`, evaluates to `OkNone`. Otherwise evaluates to `OkSome> | OkNone`, + * where `NonNullable` strips `null` and `undefined` from the union. + */ +export type QueryFromType = [NonNullable] extends [never] ? OkNone : OkSome> | OkNone; + +/** + * Creates a query from a value, treating null/undefined as OkNone. + */ +const queryFromValue = (value: T): QueryFromType => { + return isNullish(value) + ? (okNone() as QueryFromType) + : (okSome(value as NonNullable) as QueryFromType); +}; + +/** + * Creates a successful query containing the specified value. + * @param value The value to wrap in a query. + * @returns An OkSome containing the value. + * ```ts + * const query = okSome(42); + * if (query.isSome()) { + * console.log(query.value); // 42 + * } + * ``` + */ +export const okSome = (value: T): OkSome => new OkSome(value); + +const OK_NONE_INSTANCE = Object.freeze(new OkNone()); +/** + * Returns the singleton OkNone instance representing a successful query with no value. + * @returns The OkNone instance. + * ```ts + * okNone() // OkNone() + * okNone() === okNone() // true (same instance) + * ``` + */ +export const okNone = (): OkNone => OK_NONE_INSTANCE; + +/** + * Creates an error query containing the specified error. + * @param error The error to wrap in a query. + * @returns An ErrNone containing the error. + * ```ts + * const query = errNone('failed'); + * if (query.isErr()) { + * console.log(query.err); // 'failed' + * } + * ``` + */ +export const errNone = (error: E): ErrNone => new ErrNone(error); + +/** + * Combines an array of queries into a single query containing an array of values. + * @param queries The array of queries to combine. + * @returns OkSome containing an array of all values if at least one query contains a value and no errors occur, OkNone if all queries are OkNone, the first ErrNone otherwise. + * @remarks Short-circuits on the first ErrNone encountered — remaining queries are not evaluated. OkNone entries are silently skipped. When all inputs are known `OkSome`, the return type narrows to `OkSome`. + * Empty arrays return OkNone (no values to collect), unlike `Result.all`/`Option.all` which return success with an empty array. + * ```ts + * Query.all([okSome(1), okSome(2), okSome(3)]) // OkSome([1, 2, 3]) + * Query.all([okSome(1), okNone(), okSome(3)]) // OkSome([1, 3]) + * Query.all([okNone(), okNone()]) // OkNone + * Query.all([okSome(1), errNone('error'), okSome(3)]) // ErrNone('error') + * Query.all([]) // OkNone + * ``` + */ +function queryAll(queries: OkSome[]): OkSome; +function queryAll(queries: Query[]): Query; +function queryAll(queries: Query[]): Query { + const values = new Array(queries.length); + let len = 0; + + for (let i = 0; i < queries.length; i++) { + const query = queries[i]; + if (query.isSome()) { + values[len++] = query.value; + } else if (query.isErr()) { + return query; + } + } + + if (len === 0) return okNone(); + values.length = len; + return okSome(values); +} + +/** + * Combines an array of query promises into a single promise of a query containing an array of values. + * @param promises The array of query promises to combine. + * @returns A promise that resolves to OkSome containing an array of all values if at least one query contains a value and no errors occur, OkNone if all queries are OkNone, the first ErrNone otherwise. + * @remarks Rejects if any input promise rejects. When all inputs are known `Promise`, the return type narrows to `Promise>`. + * ```ts + * await QueryPromise.all([Promise.resolve(okSome(1)), Promise.resolve(okSome(2))]) // OkSome([1, 2]) + * await QueryPromise.all([Promise.resolve(okSome(1)), Promise.resolve(okNone())]) // OkSome([1]) + * await QueryPromise.all([Promise.resolve(okNone()), Promise.resolve(okNone())]) // OkNone + * await QueryPromise.all([Promise.resolve(okSome(1)), Promise.resolve(errNone('error'))]) // ErrNone('error') + * ``` + */ +async function queryPromiseAll(promises: Promise>[]): Promise>; +async function queryPromiseAll(promises: Promise>[]): Promise>; +async function queryPromiseAll(promises: Promise>[]): Promise> { + const results = await Promise.all(promises); + return queryAll(results); +} + +/** + * @internal Type-level guard: use `QueryPromise.from()` for async functions. + */ +function queryFrom( + fn: () => PromiseLike, + errorMap?: (error: unknown) => any, +): 'ERROR: Query.from() is synchronous — use QueryPromise.from() for async functions'; +/** + * Creates a query by executing a function and catching any thrown errors. + * @param fn Function to execute. + * @param errorMap Optional function to transform caught errors. When omitted, `E` defaults to `unknown` and the caught error is cast unsafely — prefer always providing `errorMap` for type safety. + * @returns OkSome containing the function's return value if successful and not null/undefined, + * OkNone if successful but returns null/undefined, + * ErrNone containing the caught (or mapped) error if the function throws. + * @remarks Only `null` and `undefined` produce OkNone. Values like `0`, `false`, and `""` produce OkSome. + * ```ts + * Query.from(() => 42) // OkSome(42) + * Query.from(() => 0) // OkSome(0) + * Query.from(() => null) // OkNone + * Query.from(() => undefined) // OkNone + * Query.from(() => { throw new Error('failed') }) // ErrNone(Error('failed')) — typed as ErrNone + * Query.from(() => { throw new Error('failed') }, e => 'Parse failed') // ErrNone('Parse failed') — typed as ErrNone + * ``` + */ +function queryFrom( + fn: () => T, + errorMap?: (error: unknown) => E, +): QueryFromType | ErrNone; +/** + * Creates a query from a nullable value. + * @param value The value to wrap. + * @returns OkSome if the value is not null/undefined, OkNone otherwise. + * @remarks Only `null` and `undefined` produce OkNone. Values like `0`, `false`, and `""` produce OkSome. + * ```ts + * Query.from(42) // OkSome(42) + * Query.from(0) // OkSome(0) + * Query.from(false) // OkSome(false) + * Query.from("") // OkSome("") — empty string is not considered falsy + * Query.from("hello" as string | null) // OkSome("hello") or OkNone depending on runtime value — typed as OkSome | OkNone + * Query.from(null) // OkNone + * Query.from(undefined) // OkNone + * ``` + */ +function queryFrom(value: T): QueryFromType; +function queryFrom( + valueOrFn: T | (() => T), + errorMap?: (error: unknown) => E, +): any { + if (typeof valueOrFn === 'function') { + try { + const result = (valueOrFn as () => T)(); + return queryFromValue(result); + } catch (error) { + return errNone(errorMap ? errorMap(error) : (error as E)); + } + } + return queryFromValue(valueOrFn); +} + +/** + * Namespace providing static utility methods for working with collections of queries. + */ +export const Query = { + from: queryFrom, + + /** + * Determines whether a value is a Query. + * @param value The value to test. + * @returns `true` if the value is a Query, `false` otherwise. + * @remarks This is a runtime `instanceof` check — it narrows to `Query` by default. The generic parameters `T` and `E` can be specified explicitly (e.g., `Query.isQuery(x)`) but are caller-asserted, not runtime-validated. + * ```ts + * Query.isQuery(okSome(42)) // true + * Query.isQuery(okNone()) // true + * Query.isQuery(errNone('error')) // true + * Query.isQuery(42) // false + * ``` + */ + isQuery(value: unknown): value is Query { + return value instanceof OkSome || value instanceof OkNone || value instanceof ErrNone; + }, + + all: queryAll, + + /** + * Partitions an array of queries into values, none count, and errors. + * @param queries The array of queries to partition. + * @returns An object containing arrays of extracted values and errors, and the count of OkNone instances. + * ```ts + * Query.partition([okSome(1), okNone(), errNone('error')]) // { values: [1], noneCount: 1, errors: ['error'] } + * Query.partition([okSome(1), okSome(2)]) // { values: [1, 2], noneCount: 0, errors: [] } + * ``` + */ + partition(queries: Query[]): QueryPartitionResult { + const values: T[] = []; + const errors: E[] = []; + let noneCount = 0; + + for (let i = 0; i < queries.length; i++) { + const query = queries[i]; + if (query.isSome()) { + values.push(query.value); + } else if (query.isNone()) { + noneCount++; + } else { + errors.push(query.err); + } + } + + return { values, noneCount, errors }; + }, + + /** + * Extracts all values from OkSome instances in an array of queries. + * @param queries The array of queries to compact. + * @returns An array containing only the values from OkSome instances. + * ```ts + * Query.compact([okSome(1), okNone(), errNone('error'), okSome(3)]) // [1, 3] + * Query.compact([okNone(), errNone('error')]) // [] + * ``` + */ + compact(queries: Query[]): T[] { + const values: T[] = []; + for (let i = 0; i < queries.length; i++) { + const q = queries[i]; + if (q.isSome()) values.push(q.value); + } + return values; + }, + + /** + * Determines whether any query in an array succeeded. + * @param queries The array of queries to test. + * @returns `true` if at least one query is OkSome or OkNone, `false` otherwise. + * ```ts + * Query.some([okSome(1), okNone(), errNone('error')]) // true + * Query.some([okNone(), errNone('error')]) // true + * Query.some([errNone('error1'), errNone('error2')]) // false + * Query.some([]) // false + * ``` + */ + some(queries: Query[]): boolean { + return queries.some((q) => !q.isErr()); + }, + + /** + * Determines whether every query in an array succeeded. + * @param queries The array of queries to test. + * @returns `true` if all queries are OkSome or OkNone, `false` otherwise. + * ```ts + * Query.every([okSome(1), okNone()]) // true + * Query.every([okSome(1), errNone('error')]) // false + * Query.every([]) // true (empty array is considered valid) + * ``` + */ + every(queries: Query[]): boolean { + return queries.every((q) => !q.isErr()); + }, +} as const; + +/** + * Namespace providing async utility methods for working with collections of query promises. + */ +export const QueryPromise = { + /** + * Creates a query promise by executing a function that returns a promise and catching any thrown errors. + * @param fn Function to execute that returns a promise. + * @param errorMap Optional function to transform caught errors. + * @returns A promise that resolves to OkSome containing the resolved value if successful and not null/undefined, + * OkNone if successful but resolves to null/undefined, + * ErrNone containing the caught error if the promise rejects or function throws. + * ```ts + * await QueryPromise.from(() => Promise.resolve(42)) // OkSome(42) + * await QueryPromise.from(() => Promise.resolve(null)) // OkNone + * await QueryPromise.from(() => Promise.resolve(undefined)) // OkNone + * await QueryPromise.from(() => Promise.reject(new Error('failed'))) // ErrNone(Error('failed')) + * await QueryPromise.from(() => Promise.reject(new Error('failed')), e => 'Fetch failed') // ErrNone('Fetch failed') + * ``` + */ + async from( + fn: () => Promise, + errorMap?: (error: unknown) => E, + ): Promise | ErrNone> { + try { + const result = await fn(); + return queryFromValue(result); + } catch (error) { + return errNone(errorMap ? errorMap(error) : (error as E)); + } + }, + all: queryPromiseAll, + + /** + * Partitions an array of query promises into values, none count, and errors. + * @param promises The array of query promises to partition. + * @returns A promise that resolves to an object containing arrays of extracted values and errors, and the count of OkNone instances. + * @remarks Rejects if any input promise rejects. + * ```ts + * await QueryPromise.partition([Promise.resolve(okSome(1)), Promise.resolve(okNone())]) // { values: [1], noneCount: 1, errors: [] } + * await QueryPromise.partition([Promise.resolve(okSome(1)), Promise.resolve(errNone('error'))]) // { values: [1], noneCount: 0, errors: ['error'] } + * ``` + */ + async partition(promises: Promise>[]): Promise> { + const resolved = await Promise.all(promises); + return Query.partition(resolved); + }, + + /** + * Extracts all values from OkSome instances in an array of query promises. + * @param promises The array of query promises to compact. + * @returns A promise that resolves to an array containing only the values from OkSome instances. + * @remarks Rejects if any input promise rejects. + * ```ts + * await QueryPromise.compact([Promise.resolve(okSome(1)), Promise.resolve(okNone())]) // [1] + * ``` + */ + async compact(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Query.compact(resolved); + }, + + /** + * Determines whether any query promise in an array resolves to a successful query. + * @param promises The array of query promises to test. + * @returns A promise that resolves to `true` if at least one query is OkSome or OkNone, `false` otherwise. + * @remarks Rejects if any input promise rejects. + * ```ts + * await QueryPromise.some([Promise.resolve(okSome(1)), Promise.resolve(okNone())]) // true + * await QueryPromise.some([Promise.resolve(okNone()), Promise.resolve(errNone('error'))]) // true + * await QueryPromise.some([Promise.resolve(errNone('e1')), Promise.resolve(errNone('e2'))]) // false + * ``` + */ + async some(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Query.some(resolved); + }, + + /** + * Determines whether every query promise in an array resolves to a successful query. + * @param promises The array of query promises to test. + * @returns A promise that resolves to `true` if all queries are OkSome or OkNone, `false` otherwise. + * @remarks Rejects if any input promise rejects. + * ```ts + * await QueryPromise.every([Promise.resolve(okSome(1)), Promise.resolve(okNone())]) // true + * await QueryPromise.every([Promise.resolve(okSome(1)), Promise.resolve(errNone('error'))]) // false + * ``` + */ + async every(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Query.every(resolved); + }, +} as const; diff --git a/src/query/spec/filter.spec.ts b/src/query/spec/filter.spec.ts new file mode 100644 index 0000000..42249e2 --- /dev/null +++ b/src/query/spec/filter.spec.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Query, okSome, okNone, errNone } from '../'; + +describe('Query.filter - Runtime Tests', () => { + describe('filter with OkSome', () => { + it('should return OkSome when predicate returns true', () => { + // Arrange + const query = okSome(42); + const predicate = vi.fn((x: number) => x > 0); + + // Act + const result = query.filter(predicate); + + // Assert + expect(predicate).toHaveBeenCalledWith(42); + expect(predicate).toHaveBeenCalledTimes(1); + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should return OkNone when predicate returns false', () => { + // Arrange + const query = okSome(42); + const predicate = vi.fn((x: number) => x < 0); + + // Act + const result = query.filter(predicate); + + // Assert + expect(predicate).toHaveBeenCalledWith(42); + expect(predicate).toHaveBeenCalledTimes(1); + expect(result.isNone()).toBe(true); + }); + + it('should work with various predicate conditions', () => { + // Arrange + const testCases = [ + { value: 5, predicate: (x: number) => x > 3, expected: true }, + { value: 2, predicate: (x: number) => x > 3, expected: false }, + { value: 'hello', predicate: (x: string) => x.length > 3, expected: true }, + { value: 'hi', predicate: (x: string) => x.length > 3, expected: false }, + { value: [1, 2, 3], predicate: (x: number[]) => x.length > 0, expected: true }, + { value: [] as number[], predicate: (x: number[]) => x.length > 0, expected: false }, + ]; + + testCases.forEach(({ value, predicate, expected }) => { + // Arrange + const query = okSome(value); + + // Act + // @ts-expect-error Testing with various types + const result = query.filter(predicate); + + // Assert + if (expected) { + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(value); + } + } else { + expect(result.isNone()).toBe(true); + } + }); + }); + + it('should handle complex object filtering', () => { + // Arrange + const users = [ + { name: 'Alice', age: 25, active: true }, + { name: 'Bob', age: 17, active: true }, + { name: 'Charlie', age: 30, active: false }, + ]; + + users.forEach((user) => { + // Act + const activeAdult = okSome(user).filter((u) => u.active && u.age >= 18); + + // Assert + if (user.name === 'Alice') { + expect(activeAdult.isSome()).toBe(true); + if (activeAdult.isSome()) { + expect(activeAdult.value).toBe(user); + } + } else { + expect(activeAdult.isNone()).toBe(true); + } + }); + }); + + it('should handle type predicate filtering', () => { + // Arrange + const mixedValues: (string | number)[] = [42, 'hello', 100, 'world']; + + mixedValues.forEach((value) => { + // Arrange + const query = okSome(value); + + // Act + const numberResult = query.filter((x): x is number => typeof x === 'number'); + const stringResult = query.filter((x): x is string => typeof x === 'string'); + + // Assert + if (typeof value === 'number') { + expect(numberResult.isSome()).toBe(true); + expect(stringResult.isNone()).toBe(true); + if (numberResult.isSome()) { + expect(numberResult.value).toBe(value); + } + } else { + expect(numberResult.isNone()).toBe(true); + expect(stringResult.isSome()).toBe(true); + if (stringResult.isSome()) { + expect(stringResult.value).toBe(value); + } + } + }); + }); + + it('should convert to OkNone on filter failure (not ErrNone)', () => { + // Arrange + const query = okSome(42); + + // Act + const result = query.filter((x) => x > 100); + + // Assert + expect(result.isNone()).toBe(true); + expect(result.isErr()).toBe(false); + }); + }); + + describe('filter with OkNone', () => { + it('should return OkNone without calling predicate', () => { + // Arrange + const query: Query = okNone() as Query; + const predicate = vi.fn((x: number) => x > 0); + + // Act + const result = query.filter(predicate); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(result.isNone()).toBe(true); + }); + + it('should return OkNone for any predicate', () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const result1 = query.filter((x) => x.length > 0); + const result2 = query.filter((x) => x.length === 0); + const result3 = query.filter((x): x is string => typeof x === 'string'); + + // Assert + expect(result1.isNone()).toBe(true); + expect(result2.isNone()).toBe(true); + expect(result3.isNone()).toBe(true); + }); + }); + + describe('filter with ErrNone', () => { + it('should return ErrNone without calling predicate', () => { + // Arrange + const query: Query = errNone('error') as Query; + const predicate = vi.fn((x: number) => x > 0); + + // Act + const result = query.filter(predicate); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + + it('should preserve the error through filter', () => { + // Arrange + const query: Query = errNone('original error') as Query; + + // Act + const result = query.filter((x) => x > 0); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('original error'); + } + }); + }); + + describe('filter chaining', () => { + it('should allow chaining multiple filters', () => { + // Arrange + const query = okSome(42); + + // Act + const result = query + .filter((x) => x > 0) + .filter((x) => x < 100) + .filter((x) => x % 2 === 0); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should short-circuit on first failed filter', () => { + // Arrange + const query = okSome(42); + const predicate1 = vi.fn((x: number) => x > 0); + const predicate2 = vi.fn((x: number) => x < 10); + const predicate3 = vi.fn((x: number) => x % 2 === 0); + + // Act + const result = query.filter(predicate1).filter(predicate2).filter(predicate3); + + // Assert + expect(predicate1).toHaveBeenCalledWith(42); + expect(predicate2).toHaveBeenCalledWith(42); + expect(predicate3).not.toHaveBeenCalled(); + expect(result.isNone()).toBe(true); + }); + }); + + describe('filter error handling', () => { + it('should propagate errors from predicate function', () => { + // Arrange + const query = okSome(42); + const error = new Error('Predicate error'); + const predicate = vi.fn(() => { + throw error; + }); + + // Act & Assert + expect(() => { + query.filter(predicate); + }).toThrow('Predicate error'); + + expect(predicate).toHaveBeenCalledWith(42); + }); + + it('should not call predicate on OkNone even if it would throw', () => { + // Arrange + const query: Query = okNone() as Query; + const predicate = vi.fn(() => { + throw new Error('Should not be called'); + }); + + // Act + const result = query.filter(predicate); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(result.isNone()).toBe(true); + }); + + it('should not call predicate on ErrNone even if it would throw', () => { + // Arrange + const query: Query = errNone('error') as Query; + const predicate = vi.fn(() => { + throw new Error('Should not be called'); + }); + + // Act + const result = query.filter(predicate); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(result.isErr()).toBe(true); + }); + }); + + describe('filter with edge cases', () => { + it('should handle falsy values correctly', () => { + // Arrange + const falsyValues = [0, false, '', NaN]; + + falsyValues.forEach((value) => { + // Arrange + const query = okSome(value); + + // Act + const truthyResult = query.filter((x) => !!x); + const falsyResult = query.filter((x) => !x); + + // Assert + expect(truthyResult.isNone()).toBe(true); + expect(falsyResult.isSome()).toBe(true); + if (falsyResult.isSome()) { + expect(falsyResult.value).toBe(value); + } + }); + }); + + it('should handle complex nested structures', () => { + // Arrange + const complexObject = { + data: { + items: [ + { id: 1, name: 'Item 1', tags: ['tag1', 'tag2'] }, + { id: 2, name: 'Item 2', tags: ['tag3'] }, + ], + }, + }; + const query = okSome(complexObject); + + // Act + const filtered = query.filter( + (obj) => obj.data.items.length > 0 && obj.data.items.every((item) => item.tags.length > 0), + ); + + // Assert + expect(filtered.isSome()).toBe(true); + if (filtered.isSome()) { + expect(filtered.value).toBe(complexObject); + } + }); + }); +}); diff --git a/src/query/spec/filter.type-spec.ts b/src/query/spec/filter.type-spec.ts new file mode 100644 index 0000000..6c6ec52 --- /dev/null +++ b/src/query/spec/filter.type-spec.ts @@ -0,0 +1,198 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, QueryInterface, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const qi = (q: Query) => q as unknown as QueryInterface; + +describe("Query.filter - Type Tests", () => { + describe("filter with type predicate", () => { + it("should narrow type with type predicate", () => { + // Arrange + const unionQuery = qi(okSome(42) as Query); + + // Act + const numberResult = unionQuery.filter((x): x is number => typeof x === "number"); + const stringResult = unionQuery.filter((x): x is string => typeof x === "string"); + + // Assert + expectTypeOf(qi(numberResult)).toEqualTypeOf>(); + expectTypeOf(qi(stringResult)).toEqualTypeOf>(); + }); + + it("should handle complex type predicates", () => { + // Arrange + interface User { + type: "user"; + name: string; + } + interface Admin { + type: "admin"; + name: string; + permissions: string[]; + } + type Person = User | Admin; + + const personQuery = qi(okSome({ type: "user", name: "John" }) as Query); + + // Act + const adminResult = personQuery.filter((p): p is Admin => p.type === "admin"); + const userResult = personQuery.filter((p): p is User => p.type === "user"); + + // Assert + expectTypeOf(qi(adminResult)).toEqualTypeOf>(); + expectTypeOf(qi(userResult)).toEqualTypeOf>(); + }); + + it("should handle nullable type predicates", () => { + // Arrange + const nullableQuery = qi(okSome("hello") as Query); + + // Act + const nonNullResult = nullableQuery.filter((x): x is string => x !== null); + + // Assert + expectTypeOf(qi(nonNullResult)).toEqualTypeOf>(); + }); + + it("should handle array type predicates", () => { + // Arrange + const arrayQuery = qi(okSome([1, 2, 3]) as Query); + + // Act + const numberArrayResult = arrayQuery.filter( + (arr): arr is number[] => arr.every((item) => typeof item === "number"), + ); + + // Assert + expectTypeOf(qi(numberArrayResult)).toEqualTypeOf>(); + }); + }); + + describe("filter with boolean predicate", () => { + it("should preserve original type with boolean predicate", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + + // Act + const filteredNumber = numberQuery.filter((x) => x > 0); + const filteredString = stringQuery.filter((x) => x.length > 0); + + // Assert + expectTypeOf(filteredNumber).toEqualTypeOf>(); + expectTypeOf(filteredString).toEqualTypeOf>(); + }); + + it("should work with complex boolean predicates", () => { + // Arrange + const objectQuery = okSome({ id: 1, name: "test", active: true }); + + // Act + const filteredObject = objectQuery.filter((obj) => obj.active && obj.name.length > 0); + + // Assert + expectTypeOf(filteredObject).toEqualTypeOf>(); + }); + }); + + describe("filter with OkNone", () => { + it("should preserve Query type regardless of predicate", () => { + // Arrange + const noneQuery: Query = okNone() as Query; + + // Act + const filteredWithBoolean = noneQuery.filter((x) => x > 0); + const filteredWithTypePredicate = noneQuery.filter((x): x is number => typeof x === "number"); + + // Assert + expectTypeOf(filteredWithBoolean).toEqualTypeOf>(); + expectTypeOf(filteredWithTypePredicate).toEqualTypeOf>(); + }); + }); + + describe("filter with ErrNone", () => { + it("should preserve Query type regardless of predicate", () => { + // Arrange + const errQuery: Query = errNone("error") as Query; + + // Act + const filteredWithBoolean = errQuery.filter((x) => x > 0); + + // Assert + expectTypeOf(filteredWithBoolean).toEqualTypeOf>(); + }); + }); + + describe("filter function signature validation", () => { + it("should be callable with correct predicate signatures", () => { + // Arrange + const query = okSome(42); + const booleanPredicate = (x: number) => x > 0; + const typePredicate = (x: number): x is number => typeof x === "number"; + + // Assert + expectTypeOf(query.filter).toBeCallableWith(booleanPredicate); + expectTypeOf(query.filter).toBeCallableWith(typePredicate); + }); + + it("should not be callable with incorrect predicate signatures", () => { + // Arrange + const query = okSome(42); + + // Assert + // @ts-expect-error - wrong parameter type for predicate + query.filter((x: string) => x.length > 0); + // @ts-expect-error - wrong return type for predicate + query.filter((x: number) => x.toString()); + }); + + it("should not be callable without predicate", () => { + // Arrange + const query = okSome(42); + + // Assert + // @ts-expect-error - missing predicate argument + query.filter(); + }); + }); + + describe("filter overload resolution", () => { + it("should resolve to type predicate overload when type predicate is provided", () => { + // Arrange + const unionQuery = qi(okSome(42) as Query); + + // Act + const result = unionQuery.filter((x): x is number => typeof x === "number"); + + // Assert - should be Query, not Query + expectTypeOf(qi(result)).toEqualTypeOf>(); + }); + + it("should resolve to boolean predicate overload when boolean predicate is provided", () => { + // Arrange + const numberQuery = okSome(42); + + // Act + const result = numberQuery.filter((x) => x > 0); + + // Assert - should preserve original type + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("filter with generic constraints", () => { + it("should work with generic type constraints", () => { + // Arrange + interface Lengthable { + length: number; + } + const lengthableQuery = qi(okSome("hello") as Query); + + // Act + const result = lengthableQuery.filter((x): x is string => typeof x === "string"); + + // Assert + expectTypeOf(qi(result)).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/query/spec/flatMap.spec.ts b/src/query/spec/flatMap.spec.ts new file mode 100644 index 0000000..56f5b54 --- /dev/null +++ b/src/query/spec/flatMap.spec.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Query, QueryInterface, okSome, okNone, errNone } from '../'; + +// Helper to use the interface signatures which correctly unify T and E +const qi = (q: Query): QueryInterface => q; + +describe('Query flatMap and flatMapErr - Runtime Tests', () => { + describe('flatMap method', () => { + describe('when Query is OkSome', () => { + it('should apply function and flatten the result when function returns OkSome', () => { + // Arrange + const query: QueryInterface = okSome(5); + const flatMapper = (x: number): Query => okSome(x * 2); + + // Act + const result = query.flatMap(flatMapper); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(10); + } + }); + + it('should return OkNone when function returns OkNone', () => { + // Arrange + const query: QueryInterface = okSome(5); + const flatMapper = (_x: number): Query => okNone(); + + // Act + const result = query.flatMap(flatMapper); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return ErrNone when function returns ErrNone', () => { + // Arrange + const query: QueryInterface = okSome(5); + const flatMapper = (_x: number): Query => errNone('failed'); + + // Act + const result = query.flatMap(flatMapper); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('failed'); + } + }); + + it('should handle conditional logic in flatMapper', () => { + // Arrange + const positiveQuery: QueryInterface = okSome(5); + const negativeQuery: QueryInterface = okSome(-3); + const flatMapper = (x: number): Query => + x > 0 ? okSome(x * 2) : okNone(); + + // Act + const positiveResult = positiveQuery.flatMap(flatMapper); + const negativeResult = negativeQuery.flatMap(flatMapper); + + // Assert + expect(positiveResult.isSome()).toBe(true); + expect(negativeResult.isNone()).toBe(true); + if (positiveResult.isSome()) { + expect(positiveResult.value).toBe(10); + } + }); + + it('should handle type transformations', () => { + // Arrange + const stringQuery: QueryInterface = okSome('42'); + const parseNumber = (str: string): Query => { + const num = parseInt(str); + return isNaN(num) ? okNone() : okSome(num); + }; + + // Act + const validResult = stringQuery.flatMap(parseNumber); + const invalidQuery: QueryInterface = okSome('invalid'); + const invalidResult = invalidQuery.flatMap(parseNumber); + + // Assert + expect(validResult.isSome()).toBe(true); + expect(invalidResult.isNone()).toBe(true); + if (validResult.isSome()) { + expect(validResult.value).toBe(42); + } + }); + + it('should not mutate the original query', () => { + // Arrange + const original = okSome({ value: 10 }); + const originalValue = original.value; + + // Act + const flatMapped = original.flatMap((obj) => okSome({ ...obj, value: obj.value * 2 })); + + // Assert + expect(original.value).toBe(originalValue); + expect(original.value.value).toBe(10); + if (flatMapped.isSome()) { + expect(flatMapped.value.value).toBe(20); + } + }); + }); + + describe('when Query is OkNone', () => { + it('should return OkNone without calling the flatMapper function', () => { + // Arrange + const query: QueryInterface = okNone(); + const flatMapperSpy = vi.fn((x: number): Query => okSome(x * 2)); + + // Act + const result = query.flatMap(flatMapperSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(flatMapperSpy).not.toHaveBeenCalled(); + }); + + it('should preserve OkNone through multiple flatMap operations', () => { + // Arrange + const query: QueryInterface = okNone(); + + // Act + const step1 = qi(query.flatMap((x): Query => okSome(x * 2))); + const step2 = qi(step1.flatMap((x): Query => okSome(x.toString()))); + const result = step2.flatMap((s): Query => okSome(s.length)); + + // Assert + expect(result.isNone()).toBe(true); + }); + }); + + describe('when Query is ErrNone', () => { + it('should return ErrNone without calling the flatMapper function', () => { + // Arrange + const query: QueryInterface = errNone('error'); + const flatMapperSpy = vi.fn((x: number): Query => okSome(x * 2)); + + // Act + const result = query.flatMap(flatMapperSpy); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + expect(flatMapperSpy).not.toHaveBeenCalled(); + }); + + it('should preserve ErrNone through multiple flatMap operations', () => { + // Arrange + const query: QueryInterface = errNone('error'); + + // Act + const step1 = qi(query.flatMap((x): Query => okSome(x * 2))); + const result = step1.flatMap((x): Query => okSome(x.toString())); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + }); + }); + + describe('flatMapErr method', () => { + describe('when Query is OkSome', () => { + it('should return OkSome without calling the flatMapper function', () => { + // Arrange + const query = okSome(42); + + // Act - OkSome.flatMapErr takes 0 arguments since E=never + const result = query.flatMapErr(); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + }); + + describe('when Query is OkNone', () => { + it('should return OkNone without calling the flatMapper function', () => { + // Arrange + const query: QueryInterface = okNone(); + const flatMapperSpy = vi.fn((_e: string): Query => errNone('new error')); + + // Act + const result = query.flatMapErr(flatMapperSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(flatMapperSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when Query is ErrNone', () => { + it('should apply function and flatten the result when function returns ErrNone', () => { + // Arrange + const query = errNone('error'); + const flatMapper = (e: string) => errNone(e.toUpperCase()); + + // Act + const result = query.flatMapErr(flatMapper); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('ERROR'); + } + }); + + it('should return OkSome when function returns OkSome (recovery)', () => { + // Arrange + const query: QueryInterface = errNone('error'); + const flatMapper = (_e: string): Query => okSome('recovered'); + + // Act + const result = query.flatMapErr(flatMapper); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe('recovered'); + } + }); + + it('should return OkNone when function returns OkNone', () => { + // Arrange + const query = errNone('error'); + const flatMapper = (_e: string): Query => okNone(); + + // Act + const result = query.flatMapErr(flatMapper); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should handle conditional error recovery', () => { + // Arrange + const recoverableErr: QueryInterface = errNone('recoverable'); + const fatalErr: QueryInterface = errNone('fatal'); + const flatMapper = (e: string): Query => + e === 'recoverable' + ? okSome('recovered') + : errNone(`unrecoverable: ${e}`); + + // Act + const recovered = recoverableErr.flatMapErr(flatMapper); + const notRecovered = fatalErr.flatMapErr(flatMapper); + + // Assert + expect(recovered.isSome()).toBe(true); + if (recovered.isSome()) expect(recovered.value).toBe('recovered'); + + expect(notRecovered.isErr()).toBe(true); + if (notRecovered.isErr()) expect(notRecovered.err).toBe('unrecoverable: fatal'); + }); + }); + }); + + describe('flatMap and flatMapErr integration', () => { + it('should work together in complex chains', () => { + // Arrange + const input: QueryInterface = okSome('123'); + + // Act + const step1 = qi(input.flatMap((str): Query => { + const num = parseInt(str); + return isNaN(num) + ? errNone('parse error') + : okSome(num); + })); + const result = step1.flatMap((num): Query => + num > 100 + ? okSome(`Large: ${num}`) + : okSome(`Small: ${num}`), + ); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe('Large: 123'); + } + }); + + it('should short-circuit on OkNone in chain', () => { + // Arrange + const mapSpy = vi.fn((x: string): Query => okSome(x.toUpperCase())); + const flatMapSpy = vi.fn((x: string): Query => okSome(x.length)); + + // Act + const input: QueryInterface = okSome('test'); + const step1 = qi(input.flatMap((_x): Query => okNone())); + const step2 = qi(step1.flatMap(mapSpy)); + const result = step2.flatMap(flatMapSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(mapSpy).not.toHaveBeenCalled(); + expect(flatMapSpy).not.toHaveBeenCalled(); + }); + + it('should short-circuit on ErrNone in chain', () => { + // Arrange + const mapSpy = vi.fn((x: string): Query => okSome(x.toUpperCase())); + + // Act + const input: QueryInterface = okSome('test'); + const step1 = qi(input.flatMap((_x): Query => errNone('error'))); + const result = step1.flatMap(mapSpy); + + // Assert + expect(result.isErr()).toBe(true); + expect(mapSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/query/spec/flatMap.type-spec.ts b/src/query/spec/flatMap.type-spec.ts new file mode 100644 index 0000000..580e22a --- /dev/null +++ b/src/query/spec/flatMap.type-spec.ts @@ -0,0 +1,178 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, QueryInterface, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const qi = (q: Query) => q as unknown as QueryInterface; + +describe("Query flatMap and flatMapErr - Type Tests", () => { + describe("flatMap directly on Query union", () => { + it("should accept callbacks returning Query without qi() helper", () => { + const query: Query = okSome(42) as Query; + const flatMapped = query.flatMap(x => x > 0 ? okSome(x * 2) : errNone("negative")); + expectTypeOf(flatMapped).toEqualTypeOf | ErrNone | OkNone | ErrNone>(); + }); + + it("should return exact callback type on known OkSome via R pattern", () => { + const flatMapped = okSome(42).flatMap(x => okSome(x.toString())); + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + }); + + describe("flatMapErr directly on Query union", () => { + it("should return exact callback type on known ErrNone via R pattern", () => { + const flatMapped = errNone("fail").flatMapErr(e => errNone(e.length)); + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + }); + + describe("flatMap method", () => { + it("should flatten OkSome with function returning Query", () => { + // Arrange + const numberQuery = okSome(42); + + // Act + const flatMappedSome = numberQuery.flatMap((x) => okSome(x * 2)); + const flatMappedNone = numberQuery.flatMap((_x) => okNone() as Query); + + // Assert + expectTypeOf(flatMappedSome).toEqualTypeOf>(); + expectTypeOf(flatMappedNone).toEqualTypeOf>(); + }); + + it("should handle OkNone input correctly", () => { + // Arrange + const noneQuery: Query = okNone() as Query; + + // Act + const flatMapped = noneQuery.flatMap((x) => okSome(x * 2)); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should handle ErrNone input correctly", () => { + // Arrange + const errQuery: Query = errNone("error") as Query; + + // Act + const flatMapped = errQuery.flatMap((x) => okSome(x * 2)); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should transform types correctly with flatMap", () => { + // Arrange + const stringQuery = okSome("42"); + + // Act + const parsed = stringQuery.flatMap((str) => { + const num = parseInt(str); + return isNaN(num) ? (okNone() as Query) : okSome(num); + }); + + // Assert + expectTypeOf(parsed).toEqualTypeOf>(); + }); + + it("should work with chained flatMap operations", () => { + // Arrange + const initialQuery = okSome(5); + + // Act + const chained = initialQuery + .flatMap((x) => (x > 0 ? okSome(x * 2) : (okNone() as Query))) + .flatMap((x) => (x < 20 ? okSome(x.toString()) : (okNone() as Query))); + + // Assert + expectTypeOf(chained).toEqualTypeOf>(); + }); + + it("should be callable with correct flatMapper function signatures", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + + // Assert + expectTypeOf(numberQuery.flatMap).toBeCallableWith((x: number) => okSome(x * 2)); + expectTypeOf(numberQuery.flatMap).toBeCallableWith((_x: number) => okNone() as Query); + expectTypeOf(stringQuery.flatMap).toBeCallableWith((s: string) => okSome(s.length)); + // @ts-expect-error - flatMap callback must return a Query, not a plain value + numberQuery.flatMap((x: number) => x * 2); + // @ts-expect-error - wrong parameter type for flatMap callback + numberQuery.flatMap((s: string) => okSome(s.length)); + }); + }); + + describe("flatMapErr method", () => { + it("should flatten ErrNone with function returning Query", () => { + // Arrange + const errQuery = errNone("error"); + + // Act + const flatMapped = errQuery.flatMapErr((e) => errNone(e.toUpperCase())); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should handle OkSome input correctly (no-op)", () => { + // Arrange + const someQuery = qi(okSome(42) as Query); + + // Act + const flatMapped = someQuery.flatMapErr((_e) => errNone("new error")); + + // Assert + expectTypeOf(qi(flatMapped)).toEqualTypeOf>(); + }); + + it("should handle OkNone input correctly (no-op)", () => { + // Arrange + const noneQuery: Query = okNone() as Query; + + // Act + const flatMapped = noneQuery.flatMapErr((_e) => errNone(42)); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should handle error recovery returning OkSome", () => { + // Arrange + const errQuery = qi(errNone("error") as Query); + + // Act + const recovered = errQuery.flatMapErr((_e) => okSome("recovered") as Query); + + // Assert + expectTypeOf(qi(recovered)).toEqualTypeOf>(); + }); + + it("should be callable with correct flatMapper function signatures", () => { + // Arrange + const errQuery = errNone("error"); + + // Assert + expectTypeOf(errQuery.flatMapErr).toBeCallableWith((e: string) => errNone(e.toUpperCase())); + expectTypeOf(errQuery.flatMapErr).toBeCallableWith((e: string) => errNone(e.length)); + // @ts-expect-error - wrong parameter type for flatMapErr callback + errQuery.flatMapErr((e: number) => errNone(e.toString())); + }); + }); + + describe("flatMap and flatMapErr interaction", () => { + it("should work together in chains with correct type inference", () => { + // Arrange + const query = qi(okSome(5) as Query); + + // Act + const result = query + .flatMap((x) => (x > 0 ? okSome(x.toString()) : (errNone("negative") as Query))) + .flatMapErr((e) => errNone(e.length)); + + // Assert + expectTypeOf(qi(result)).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/query/spec/is.spec.ts b/src/query/spec/is.spec.ts new file mode 100644 index 0000000..5987db4 --- /dev/null +++ b/src/query/spec/is.spec.ts @@ -0,0 +1,357 @@ +import { describe, it, expect } from 'vitest'; +import { Query, okSome, okNone, errNone } from '../'; + +describe('Query isSome/isNone/isErr - Runtime Tests', () => { + describe('isSome() method', () => { + it('should return true for OkSome instances', () => { + // Arrange + const testCases = [ + okSome(42), + okSome('hello'), + okSome(true), + okSome({ id: 1 }), + okSome([1, 2, 3]), + okSome(0), + okSome(false), + okSome(''), + ]; + + testCases.forEach((query) => { + // Act & Assert + expect(query.isSome()).toBe(true); + }); + }); + + it('should return false for OkNone instances', () => { + // Arrange + const query = okNone(); + + // Act & Assert + expect(query.isSome()).toBe(false); + }); + + it('should return false for ErrNone instances', () => { + // Arrange + const query = errNone('error'); + + // Act & Assert + expect(query.isSome()).toBe(false); + }); + + it('should enable access to value property after type narrowing', () => { + // Arrange + const query: Query = okSome('hello') as Query; + + // Act & Assert + if (query.isSome()) { + expect(query.value).toBe('hello'); + expect(typeof query.value).toBe('string'); + } else { + fail('Expected query to be OkSome'); + } + }); + + it('should work with complex objects', () => { + // Arrange + const complexObject = { + id: 1, + name: 'test', + nested: { data: [1, 2, 3] }, + fn: (x: number) => x * 2, + }; + const query: Query = okSome(complexObject) as Query; + + // Act & Assert + if (query.isSome()) { + expect(query.value).toBe(complexObject); + expect(query.value.id).toBe(1); + expect(query.value.name).toBe('test'); + expect(query.value.nested.data).toEqual([1, 2, 3]); + expect(query.value.fn(5)).toBe(10); + } else { + fail('Expected query to be OkSome'); + } + }); + + it('should work with falsy values that are still OkSome', () => { + // Arrange + const falsy = [okSome(0), okSome(false), okSome('')]; + + falsy.forEach((query) => { + // Act & Assert + expect(query.isSome()).toBe(true); + }); + }); + }); + + describe('isNone() method', () => { + it('should return true for OkNone instances', () => { + // Arrange + const query = okNone(); + + // Act & Assert + expect(query.isNone()).toBe(true); + }); + + it('should return false for OkSome instances', () => { + // Arrange + const testCases = [ + okSome(42), + okSome('hello'), + okSome(true), + okSome(0), + okSome(false), + okSome(''), + ]; + + testCases.forEach((query) => { + // Act & Assert + expect(query.isNone()).toBe(false); + }); + }); + + it('should return false for ErrNone instances', () => { + // Arrange + const query = errNone('error'); + + // Act & Assert + expect(query.isNone()).toBe(false); + }); + + it('should work with different generic types', () => { + // Arrange + const numberNone: Query = okNone() as Query; + const stringNone: Query = okNone() as Query; + const objectNone: Query<{ id: number }, Error> = okNone() as Query<{ id: number }, Error>; + + // Act & Assert + expect(numberNone.isNone()).toBe(true); + expect(stringNone.isNone()).toBe(true); + expect(objectNone.isNone()).toBe(true); + }); + }); + + describe('isErr() method', () => { + it('should return true for ErrNone instances', () => { + // Arrange + const testCases = [ + errNone('error'), + errNone(404), + errNone(new Error('fail')), + errNone({ code: 'ERR' }), + ]; + + testCases.forEach((query) => { + // Act & Assert + expect(query.isErr()).toBe(true); + }); + }); + + it('should return false for OkSome instances', () => { + // Arrange + const query = okSome(42); + + // Act & Assert + expect(query.isErr()).toBe(false); + }); + + it('should return false for OkNone instances', () => { + // Arrange + const query = okNone(); + + // Act & Assert + expect(query.isErr()).toBe(false); + }); + + it('should enable access to err property after type narrowing', () => { + // Arrange + const query: Query = errNone('failure') as Query; + + // Act & Assert + if (query.isErr()) { + expect(query.err).toBe('failure'); + expect(typeof query.err).toBe('string'); + } else { + fail('Expected query to be ErrNone'); + } + }); + }); + + describe('Mutual exclusivity', () => { + it('should be mutually exclusive for OkSome instances', () => { + // Arrange + const testCases = [okSome(42), okSome('hello'), okSome(false), okSome(0)]; + + testCases.forEach((query) => { + // Act & Assert + expect(query.isSome()).toBe(true); + expect(query.isNone()).toBe(false); + expect(query.isErr()).toBe(false); + }); + }); + + it('should be mutually exclusive for OkNone instances', () => { + // Arrange + const query = okNone(); + + // Act & Assert + expect(query.isSome()).toBe(false); + expect(query.isNone()).toBe(true); + expect(query.isErr()).toBe(false); + }); + + it('should be mutually exclusive for ErrNone instances', () => { + // Arrange + const query = errNone('error'); + + // Act & Assert + expect(query.isSome()).toBe(false); + expect(query.isNone()).toBe(false); + expect(query.isErr()).toBe(true); + }); + }); + + describe('Type narrowing in control flow', () => { + it('should enable type-safe value access in if statements', () => { + // Arrange + const query: Query<{ name: string; age: number }, string> = okSome({ + name: 'John', + age: 30, + }) as Query<{ name: string; age: number }, string>; + + // Act & Assert + if (query.isSome()) { + expect(query.value.name).toBe('John'); + expect(query.value.age).toBe(30); + } else { + fail('Expected query to be OkSome'); + } + }); + + it('should work with early returns covering all three states', () => { + // Arrange + function processQuery(query: Query): string { + if (query.isErr()) { + return `error: ${query.err}`; + } + if (query.isNone()) { + return 'none'; + } + return `value: ${query.value}`; + } + + // Act + const someResult = processQuery(okSome(42) as Query); + const noneResult = processQuery(okNone() as Query); + const errResult = processQuery(errNone('fail') as Query); + + // Assert + expect(someResult).toBe('value: 42'); + expect(noneResult).toBe('none'); + expect(errResult).toBe('error: fail'); + }); + + it('should work with negated conditions', () => { + // Arrange + const someQuery: Query = okSome('hello') as Query; + const noneQuery: Query = okNone() as Query; + const errQuery: Query = errNone(42) as Query; + + // Act & Assert + if (!someQuery.isNone() && !someQuery.isErr()) { + expect(someQuery.value).toBe('hello'); + } else { + fail('Expected query to be OkSome'); + } + + if (!noneQuery.isSome() && !noneQuery.isErr()) { + expect(noneQuery.isNone()).toBe(true); + } else { + fail('Expected query to be OkNone'); + } + + if (!errQuery.isSome() && !errQuery.isNone()) { + expect(errQuery.err).toBe(42); + } else { + fail('Expected query to be ErrNone'); + } + }); + }); + + describe('Performance and consistency', () => { + it('should return consistent results across multiple calls', () => { + // Arrange + const someQuery = okSome(42); + const noneQuery = okNone(); + const errQuery = errNone('error'); + + // Act & Assert + expect(someQuery.isSome()).toBe(someQuery.isSome()); + expect(someQuery.isNone()).toBe(someQuery.isNone()); + expect(someQuery.isErr()).toBe(someQuery.isErr()); + expect(noneQuery.isSome()).toBe(noneQuery.isSome()); + expect(noneQuery.isNone()).toBe(noneQuery.isNone()); + expect(noneQuery.isErr()).toBe(noneQuery.isErr()); + expect(errQuery.isSome()).toBe(errQuery.isSome()); + expect(errQuery.isNone()).toBe(errQuery.isNone()); + expect(errQuery.isErr()).toBe(errQuery.isErr()); + }); + + it('should be callable without side effects', () => { + // Arrange + const query = okSome({ counter: 0 }); + + // Act + const before = query.value.counter; + query.isSome(); + query.isNone(); + query.isErr(); + query.isSome(); + const after = query.value.counter; + + // Assert + expect(before).toBe(after); + }); + }); + + describe('Edge cases', () => { + it('should handle deeply nested objects', () => { + // Arrange + const deepObject = { + level1: { + level2: { + level3: { + value: 'deep', + }, + }, + }, + }; + const query: Query = okSome(deepObject) as Query; + + // Act & Assert + if (query.isSome()) { + expect(query.value.level1.level2.level3.value).toBe('deep'); + } else { + fail('Expected query to be OkSome'); + } + }); + + it('should handle arrays with mixed types', () => { + // Arrange + const mixedArray = [1, 'string', true, null, { id: 1 }]; + const query: Query = okSome(mixedArray) as Query; + + // Act & Assert + if (query.isSome()) { + expect(query.value).toHaveLength(5); + expect(query.value[0]).toBe(1); + expect(query.value[1]).toBe('string'); + expect(query.value[2]).toBe(true); + expect(query.value[3]).toBeNull(); + expect(query.value[4]).toEqual({ id: 1 }); + } else { + fail('Expected query to be OkSome'); + } + }); + }); +}); diff --git a/src/query/spec/is.type-spec.ts b/src/query/spec/is.type-spec.ts new file mode 100644 index 0000000..377439a --- /dev/null +++ b/src/query/spec/is.type-spec.ts @@ -0,0 +1,197 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from "../"; + +describe("Query isSome/isNone/isErr - Type Tests", () => { + describe("isSome() type guard", () => { + it("should act as type guard narrowing Query to OkSome", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act & Assert + if (query.isSome()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.value).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is OkSome'", () => { + // Arrange + const numberQuery: Query = okSome(42) as Query; + const stringQuery: Query = okSome("hello") as Query; + + // Act + const numberResult = numberQuery.isSome(); + const stringResult = stringQuery.isSome(); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + }); + + it("should enable access to value property after type narrowing", () => { + // Arrange + const query: Query<{ name: string; age: number }, string> = okSome({ name: "John", age: 30 }) as Query<{ name: string; age: number }, string>; + + // Act & Assert + if (query.isSome()) { + expectTypeOf(query.value).toEqualTypeOf<{ name: string; age: number }>(); + expectTypeOf(query.value.name).toEqualTypeOf(); + expectTypeOf(query.value.age).toEqualTypeOf(); + } + }); + + it("should work with union types", () => { + // Arrange + const query: Query = okSome("hello") as Query; + + // Act & Assert + if (query.isSome()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.value).toEqualTypeOf(); + } + }); + }); + + describe("isNone() type guard", () => { + it("should act as type guard narrowing Query to OkNone", () => { + // Arrange + const query: Query = okNone() as Query; + + // Act & Assert + if (query.isNone()) { + expectTypeOf(query).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is OkNone'", () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const result = query.isNone(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should narrow to OkNone regardless of original generic types", () => { + // Arrange + const numberQuery: Query = okNone() as Query; + const complexQuery: Query<{ data: Array }, Error> = okNone() as Query<{ data: Array }, Error>; + + // Act & Assert + if (numberQuery.isNone()) { + expectTypeOf(numberQuery).toEqualTypeOf(); + } + + if (complexQuery.isNone()) { + expectTypeOf(complexQuery).toEqualTypeOf(); + } + }); + }); + + describe("isErr() type guard", () => { + it("should act as type guard narrowing Query to ErrNone", () => { + // Arrange + const query: Query = errNone("error") as Query; + + // Act & Assert + if (query.isErr()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.err).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is ErrNone'", () => { + // Arrange + const query: Query = errNone("error") as Query; + + // Act + const result = query.isErr(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should enable access to err property after type narrowing", () => { + // Arrange + const query: Query = errNone({ code: "ERR", message: "fail" }) as Query; + + // Act & Assert + if (query.isErr()) { + expectTypeOf(query.err).toEqualTypeOf<{ code: string; message: string }>(); + expectTypeOf(query.err.code).toEqualTypeOf(); + expectTypeOf(query.err.message).toEqualTypeOf(); + } + }); + }); + + describe("Type narrowing patterns", () => { + it("should enable exhaustive type checking with if-else", () => { + // Arrange + const query: Query = okSome("test") as Query; + + // Act & Assert + if (query.isSome()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.value).toEqualTypeOf(); + } else if (query.isNone()) { + expectTypeOf(query).toEqualTypeOf(); + } else { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.err).toEqualTypeOf(); + } + }); + + it("should work with early returns", () => { + // Arrange + function processQuery(query: Query): number { + if (query.isErr()) { + return -1; + } + if (query.isNone()) { + return 0; + } + return query.value * 2; + } + + const testQuery: Query = okSome(5) as Query; + const result = processQuery(testQuery); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should work with negated conditions", () => { + // Arrange + const query: Query = okSome(true) as Query; + + // Act & Assert + if (!query.isNone() && !query.isErr()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.value).toEqualTypeOf(); + } + + if (!query.isSome() && !query.isErr()) { + expectTypeOf(query).toEqualTypeOf(); + } + + if (!query.isSome() && !query.isNone()) { + expectTypeOf(query).toEqualTypeOf>(); + } + }); + }); + + describe("Method chaining with type narrowing", () => { + it("should preserve type information in method chains", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act & Assert + if (query.isSome()) { + const mapped = query.map((x) => x.toString()); + expectTypeOf(mapped).toEqualTypeOf>(); + } + }); + }); +}); diff --git a/src/query/spec/map.spec.ts b/src/query/spec/map.spec.ts new file mode 100644 index 0000000..59dcc83 --- /dev/null +++ b/src/query/spec/map.spec.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Query, okSome, okNone, errNone } from '../'; + +describe('Query map and mapErr - Runtime Tests', () => { + describe('map method', () => { + describe('when Query is OkSome', () => { + it('should transform the value using the provided function', () => { + // Arrange + const query = okSome(5); + const mapper = (x: number) => x * 2; + + // Act + const result = query.map(mapper); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(10); + } + }); + + it('should handle different type transformations', () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome('hello'); + const booleanQuery = okSome(true); + + // Act + const numberToString = numberQuery.map((x) => x.toString()); + const stringToNumber = stringQuery.map((s) => s.length); + const booleanToString = booleanQuery.map((b) => (b ? 'yes' : 'no')); + + // Assert + expect(numberToString.isSome()).toBe(true); + expect(stringToNumber.isSome()).toBe(true); + expect(booleanToString.isSome()).toBe(true); + + if (numberToString.isSome()) expect(numberToString.value).toBe('42'); + if (stringToNumber.isSome()) expect(stringToNumber.value).toBe(5); + if (booleanToString.isSome()) expect(booleanToString.value).toBe('yes'); + }); + + it('should handle complex object transformations', () => { + // Arrange + const user = { id: 1, name: 'John', age: 30 }; + const userQuery = okSome(user); + + // Act + const userSummary = userQuery.map((u) => ({ + displayName: `${u.name} (${u.age})`, + isAdult: u.age >= 18, + })); + + // Assert + expect(userSummary.isSome()).toBe(true); + if (userSummary.isSome()) { + expect(userSummary.value).toEqual({ + displayName: 'John (30)', + isAdult: true, + }); + } + }); + + it('should not mutate the original query', () => { + // Arrange + const original = okSome({ count: 5 }); + const originalValue = original.value; + + // Act + const mapped = original.map((obj) => ({ ...obj, count: obj.count * 2 })); + + // Assert + expect(original.value).toBe(originalValue); + expect(original.value.count).toBe(5); + if (mapped.isSome()) { + expect(mapped.value.count).toBe(10); + } + }); + }); + + describe('when Query is OkNone', () => { + it('should return OkNone without calling the mapper function', () => { + // Arrange + const query: Query = okNone() as Query; + const mapperSpy = vi.fn((x: number) => x * 2); + + // Act + const result = query.map(mapperSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(mapperSpy).not.toHaveBeenCalled(); + }); + + it('should preserve OkNone through multiple map operations', () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const result = query + .map((x) => x * 2) + .map((x) => x.toString()) + .map((s) => s.length); + + // Assert + expect(result.isNone()).toBe(true); + }); + }); + + describe('when Query is ErrNone', () => { + it('should return ErrNone without calling the mapper function', () => { + // Arrange + const query: Query = errNone('error') as Query; + const mapperSpy = vi.fn((x: number) => x * 2); + + // Act + const result = query.map(mapperSpy); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + expect(mapperSpy).not.toHaveBeenCalled(); + }); + + it('should preserve ErrNone through multiple map operations', () => { + // Arrange + const query: Query = errNone('error') as Query; + + // Act + const result = query + .map((x) => x * 2) + .map((x) => x.toString()) + .map((s) => s.length); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + }); + }); + + describe('mapErr method', () => { + describe('when Query is OkSome', () => { + it('should return OkSome unchanged', () => { + // Arrange + const query = okSome(42); + + // Act - OkSome.mapErr takes 0 arguments since E=never + const result = query.mapErr(); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + }); + + describe('when Query is OkNone', () => { + it('should return OkNone without calling the mapper function', () => { + // Arrange + const query: Query = okNone() as Query; + const mapperSpy = vi.fn((e: string) => e.toUpperCase()); + + // Act + const result = query.mapErr(mapperSpy); + + // Assert + expect(result.isNone()).toBe(true); + expect(mapperSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when Query is ErrNone', () => { + it('should transform the error using the provided function', () => { + // Arrange + const query = errNone('error'); + const mapper = (e: string) => e.toUpperCase(); + + // Act + const result = query.mapErr(mapper); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('ERROR'); + } + }); + + it('should handle different error type transformations', () => { + // Arrange + const stringErr = errNone('error'); + const numberErr = errNone(404); + + // Act + const stringToNumber = stringErr.mapErr((e) => e.length); + const numberToString = numberErr.mapErr((e) => `Error ${e}`); + + // Assert + expect(stringToNumber.isErr()).toBe(true); + expect(numberToString.isErr()).toBe(true); + + if (stringToNumber.isErr()) expect(stringToNumber.err).toBe(5); + if (numberToString.isErr()) expect(numberToString.err).toBe('Error 404'); + }); + + it('should not mutate the original error', () => { + // Arrange + const original = errNone({ code: 'ERR', message: 'fail' }); + + // Act + const mapped = original.mapErr((e) => ({ ...e, message: e.message.toUpperCase() })); + + // Assert + expect(original.err.message).toBe('fail'); + if (mapped.isErr()) { + expect(mapped.err.message).toBe('FAIL'); + } + }); + }); + }); + + describe('map and mapErr integration', () => { + it('should allow chaining map and mapErr', () => { + // Arrange + const someQuery: Query = okSome(5) as Query; + const errQuery: Query = errNone('error') as Query; + + // Act + const someResult = someQuery.map((x) => x * 2).mapErr((e) => e.toUpperCase()); + const errResult = errQuery.map((x) => x * 2).mapErr((e) => e.toUpperCase()); + + // Assert + expect(someResult.isSome()).toBe(true); + if (someResult.isSome()) expect(someResult.value).toBe(10); + + expect(errResult.isErr()).toBe(true); + if (errResult.isErr()) expect(errResult.err).toBe('ERROR'); + }); + + it('should handle edge cases with falsy values', () => { + // Arrange + const zeroQuery = okSome(0); + const emptyStringQuery = okSome(''); + const falseQuery = okSome(false); + + // Act + const zeroResult = zeroQuery.map((x) => x + 1); + const emptyResult = emptyStringQuery.map((s) => s.length); + const falseResult = falseQuery.map((b) => !b); + + // Assert + expect(zeroResult.isSome()).toBe(true); + expect(emptyResult.isSome()).toBe(true); + expect(falseResult.isSome()).toBe(true); + + if (zeroResult.isSome()) expect(zeroResult.value).toBe(1); + if (emptyResult.isSome()) expect(emptyResult.value).toBe(0); + if (falseResult.isSome()) expect(falseResult.value).toBe(true); + }); + }); +}); diff --git a/src/query/spec/map.type-spec.ts b/src/query/spec/map.type-spec.ts new file mode 100644 index 0000000..fc13580 --- /dev/null +++ b/src/query/spec/map.type-spec.ts @@ -0,0 +1,210 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, QueryInterface, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const qi = (q: Query) => q as unknown as QueryInterface; + +describe("Query map and mapErr - Type Tests", () => { + describe("map method", () => { + it("should transform OkSome to Query with correct type inference", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + + // Act + const doubledNumber = numberQuery.map((x) => x * 2); + const uppercaseString = stringQuery.map((s) => s.toUpperCase()); + const numberToString = numberQuery.map((x) => x.toString()); + + // Assert + expectTypeOf(doubledNumber).toEqualTypeOf>(); + expectTypeOf(uppercaseString).toEqualTypeOf>(); + expectTypeOf(numberToString).toEqualTypeOf>(); + }); + + it("should return Query when called on Query", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act + const mapped = query.map((x) => x.toString()); + + // Assert + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should handle complex type transformations", () => { + // Arrange + type User = { id: number; name: string }; + type UserDto = { userId: number; displayName: string }; + const userQuery = okSome({ id: 1, name: "John" } as User); + + // Act + const userDtoQuery = userQuery.map( + (user): UserDto => ({ + userId: user.id, + displayName: user.name.toUpperCase(), + }), + ); + + // Assert + expectTypeOf(userDtoQuery).toEqualTypeOf>(); + }); + + it("should be callable with correct mapper function signatures", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + + // Assert + expectTypeOf(numberQuery.map).toBeCallableWith((x: number) => x * 2); + expectTypeOf(numberQuery.map).toBeCallableWith((x: number) => x.toString()); + expectTypeOf(stringQuery.map).toBeCallableWith((s: string) => s.length); + // @ts-expect-error - wrong parameter type for map callback + numberQuery.map((s: string) => s.length); + }); + }); + + describe("narrow return types on concrete variants", () => { + it("should return OkSome when map is called on a known OkSome", () => { + const mapped = okSome(42).map(x => x.toString()); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return OkSome when mapErr is called on a known OkSome", () => { + const mapped = okSome(42).mapErr(e => String(e)); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return OkSome when flatMapErr is called on a known OkSome", () => { + const mapped = okSome(42).flatMapErr(e => errNone(String(e))); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return OkNone when map is called on a known OkNone", () => { + const mapped = okNone().map(x => String(x)); + expectTypeOf(mapped).toEqualTypeOf(); + }); + + it("should return OkNone when mapErr is called on a known OkNone", () => { + const mapped = okNone().mapErr(e => String(e)); + expectTypeOf(mapped).toEqualTypeOf(); + }); + + it("should return OkNone when flatMap is called on a known OkNone", () => { + const mapped = okNone().flatMap(x => okSome(String(x))); + expectTypeOf(mapped).toEqualTypeOf(); + }); + + it("should return OkNone when flatMapErr is called on a known OkNone", () => { + const mapped = okNone().flatMapErr(e => errNone(String(e))); + expectTypeOf(mapped).toEqualTypeOf(); + }); + + it("should return OkNone when filter is called on a known OkNone", () => { + const filtered = okNone().filter(); + expectTypeOf(filtered).toEqualTypeOf(); + }); + + it("should return ErrNone when map is called on a known ErrNone", () => { + const mapped = errNone("fail").map(x => String(x)); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return ErrNone when mapErr is called on a known ErrNone", () => { + const mapped = errNone("fail").mapErr(e => e.length); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return ErrNone when flatMap is called on a known ErrNone", () => { + const mapped = errNone("fail").flatMap(x => okSome(String(x))); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return ErrNone when filter is called on a known ErrNone", () => { + const filtered = errNone("fail").filter(); + expectTypeOf(filtered).toEqualTypeOf>(); + }); + + it("should return OkSome when flatMap callback returns OkSome", () => { + const result = okSome(5).flatMap(x => okSome(x.toString())); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Query when flatMap callback returns Query", () => { + const result = okSome(5).flatMap(x => (x > 0 ? okSome(x) : okNone()) as Query); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return ErrNone when flatMapErr callback returns ErrNone", () => { + const result = errNone("fail").flatMapErr(e => errNone(e.length)); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Query when flatMapErr callback returns Query", () => { + const result = errNone("fail").flatMapErr(e => (e === "retry" ? okSome(0) : errNone(e.length)) as Query); + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("mapErr method", () => { + it("should transform ErrNone to Query with correct type inference", () => { + // Arrange + const errQuery = errNone("error"); + + // Act + const mapped = errQuery.mapErr((e) => e.toUpperCase()); + + // Assert + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Query when called on Query", () => { + // Arrange + const query: Query = errNone("error") as Query; + + // Act + const mapped = query.mapErr((e) => e.length); + + // Assert + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should preserve T when called on OkSome", () => { + // Arrange + const query = qi(okSome(42) as Query); + + // Act + const mapped = query.mapErr((e: never) => e); + + // Assert + expectTypeOf(qi(mapped)).toEqualTypeOf>(); + }); + + it("should be callable with correct mapper function signatures", () => { + // Arrange + const errQuery = errNone("error"); + + // Assert + expectTypeOf(errQuery.mapErr).toBeCallableWith((e: string) => e.toUpperCase()); + expectTypeOf(errQuery.mapErr).toBeCallableWith((e: string) => e.length); + // @ts-expect-error - wrong parameter type for mapErr callback + errQuery.mapErr((e: number) => e.toString()); + }); + }); + + describe("map and mapErr interaction", () => { + it("should work together in chains with correct type inference", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act + const result = query + .map((x) => x.toString()) + .mapErr((e) => e.length); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/query/spec/match.spec.ts b/src/query/spec/match.spec.ts new file mode 100644 index 0000000..75df032 --- /dev/null +++ b/src/query/spec/match.spec.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Query, okSome, okNone, errNone } from '../'; + +describe('Query.match - Runtime Tests', () => { + describe('match with OkSome', () => { + it('should execute onSome callback with the contained value', () => { + // Arrange + const query = okSome(42); + const onSome = vi.fn((value: number) => `Value: ${value}`); + const onNone = vi.fn(() => 'No value'); + const onErr = vi.fn((error: never) => `Error: ${error}`); + + // Act + const result = query.match(onSome, onNone, onErr); + + // Assert + expect(onSome).toHaveBeenCalledWith(42); + expect(onSome).toHaveBeenCalledTimes(1); + expect(onNone).not.toHaveBeenCalled(); + expect(onErr).not.toHaveBeenCalled(); + expect(result).toBe('Value: 42'); + }); + + it('should return the result of onSome callback', () => { + // Arrange + const query = okSome(10); + + // Act + const result = query.match( + (value) => value * 2, + () => 0, + () => -1, + ); + + // Assert + expect(result).toBe(20); + }); + + it('should work with various data types', () => { + // Arrange + const testCases = [ + { query: okSome(42), expected: 'number: 42' }, + { query: okSome('hello'), expected: 'string: "hello"' }, + { query: okSome(true), expected: 'boolean: true' }, + { query: okSome({ id: 1 }), expected: 'object: {"id":1}' }, + { query: okSome([1, 2, 3]), expected: 'object: [1,2,3]' }, + ]; + + testCases.forEach(({ query, expected }) => { + // Act + const result = query.match( + (value) => `${typeof value}: ${JSON.stringify(value)}`, + () => 'none', + () => 'error', + ); + + // Assert + expect(result).toBe(expected); + }); + }); + + it('should handle complex transformations', () => { + // Arrange + const query = okSome({ name: 'John', age: 30 }); + + // Act + const result = query.match( + (person) => ({ + greeting: `Hello, ${person.name}!`, + isAdult: person.age >= 18, + }), + () => ({ greeting: 'Hello, stranger!', isAdult: false }), + () => ({ greeting: 'Error occurred', isAdult: false }), + ); + + // Assert + expect(result).toEqual({ + greeting: 'Hello, John!', + isAdult: true, + }); + }); + }); + + describe('match with OkNone', () => { + it('should execute onNone callback', () => { + // Arrange + const query: Query = okNone() as Query; + const onSome = vi.fn((value: number) => `Value: ${value}`); + const onNone = vi.fn(() => 'No value'); + const onErr = vi.fn((error: string) => `Error: ${error}`); + + // Act + const result = query.match(onSome, onNone, onErr); + + // Assert + expect(onNone).toHaveBeenCalledWith(); + expect(onNone).toHaveBeenCalledTimes(1); + expect(onSome).not.toHaveBeenCalled(); + expect(onErr).not.toHaveBeenCalled(); + expect(result).toBe('No value'); + }); + + it('should return the result of onNone callback', () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const result = query.match( + (value) => value.toUpperCase(), + () => 'DEFAULT', + (err) => `Error: ${err}`, + ); + + // Assert + expect(result).toBe('DEFAULT'); + }); + + it('should handle different return types from onNone', () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const stringResult = query.match( + (value) => value.toString(), + () => 'empty', + (err) => err, + ); + + const numberResult = query.match( + (value) => value, + () => -1, + () => -2, + ); + + const objectResult = query.match( + (value): { value: number | null } => ({ value }), + (): { value: number | null } => ({ value: null }), + (): { value: number | null } => ({ value: null }), + ); + + // Assert + expect(stringResult).toBe('empty'); + expect(numberResult).toBe(-1); + expect(objectResult).toEqual({ value: null }); + }); + }); + + describe('match with ErrNone', () => { + it('should execute onErr callback with the contained error', () => { + // Arrange + const query: Query = errNone('something failed') as Query; + const onSome = vi.fn((value: number) => `Value: ${value}`); + const onNone = vi.fn(() => 'No value'); + const onErr = vi.fn((error: string) => `Error: ${error}`); + + // Act + const result = query.match(onSome, onNone, onErr); + + // Assert + expect(onErr).toHaveBeenCalledWith('something failed'); + expect(onErr).toHaveBeenCalledTimes(1); + expect(onSome).not.toHaveBeenCalled(); + expect(onNone).not.toHaveBeenCalled(); + expect(result).toBe('Error: something failed'); + }); + + it('should return the result of onErr callback', () => { + // Arrange + const query = errNone(404); + + // Act + const result = query.match( + () => 'has value', + () => 'no value', + (err) => `HTTP ${err}`, + ); + + // Assert + expect(result).toBe('HTTP 404'); + }); + + it('should handle complex error objects', () => { + // Arrange + const query = errNone({ code: 'NOT_FOUND', message: 'Resource not found' }); + + // Act + const result = query.match( + () => ({ status: 'ok', message: '' }), + () => ({ status: 'empty', message: '' }), + (err) => ({ status: 'error', message: `${err.code}: ${err.message}` }), + ); + + // Assert + expect(result).toEqual({ + status: 'error', + message: 'NOT_FOUND: Resource not found', + }); + }); + }); + + describe('match with mixed Query types', () => { + it('should handle dynamic query types correctly', () => { + // Arrange + const queries: Query[] = [ + okSome('hello') as Query, + okNone() as Query, + errNone('error') as Query, + okSome('world') as Query, + ]; + + // Act + const results = queries.map((query) => + query.match( + (value) => value.toUpperCase(), + () => 'EMPTY', + (err) => `ERR: ${err}`, + ), + ); + + // Assert + expect(results).toEqual(['HELLO', 'EMPTY', 'ERR: error', 'WORLD']); + }); + + it('should work in conditional contexts', () => { + // Arrange + const getQuery = (type: 'some' | 'none' | 'err'): Query => { + if (type === 'some') return okSome(42) as Query; + if (type === 'none') return okNone() as Query; + return errNone('error') as Query; + }; + + // Act + const someResult = getQuery('some').match( + (value) => `Found: ${value}`, + () => 'Not found', + (err) => `Error: ${err}`, + ); + + const noneResult = getQuery('none').match( + (value) => `Found: ${value}`, + () => 'Not found', + (err) => `Error: ${err}`, + ); + + const errResult = getQuery('err').match( + (value) => `Found: ${value}`, + () => 'Not found', + (err) => `Error: ${err}`, + ); + + // Assert + expect(someResult).toBe('Found: 42'); + expect(noneResult).toBe('Not found'); + expect(errResult).toBe('Error: error'); + }); + }); + + describe('match error handling', () => { + it('should propagate errors from onSome callback', () => { + // Arrange + const query = okSome(42); + const error = new Error('onSome error'); + + // Act & Assert + expect(() => { + query.match( + () => { + throw error; + }, + () => 'fallback', + () => 'err fallback', + ); + }).toThrow('onSome error'); + }); + + it('should propagate errors from onNone callback', () => { + // Arrange + const query: Query = okNone() as Query; + const error = new Error('onNone error'); + + // Act & Assert + expect(() => { + query.match( + (value) => value.toString(), + () => { + throw error; + }, + (err) => err, + ); + }).toThrow('onNone error'); + }); + + it('should propagate errors from onErr callback', () => { + // Arrange + const query: Query = errNone('error') as Query; + const error = new Error('onErr error'); + + // Act & Assert + expect(() => { + query.match( + (value) => value.toString(), + () => 'none', + () => { + throw error; + }, + ); + }).toThrow('onErr error'); + }); + }); + + describe('match with async callbacks', () => { + it('should handle async onSome callback', async () => { + // Arrange + const query = okSome(42); + + // Act + const result = await query.match( + async (value) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return `Async: ${value}`; + }, + async () => 'Async: No value', + async () => 'Async: Error', + ); + + // Assert + expect(result).toBe('Async: 42'); + }); + + it('should handle async onNone callback', async () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const result = await query.match( + async (value) => `Async: ${value}`, + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return 'Async: No value'; + }, + async (err) => `Async: ${err}`, + ); + + // Assert + expect(result).toBe('Async: No value'); + }); + + it('should handle async onErr callback', async () => { + // Arrange + const query: Query = errNone('fail') as Query; + + // Act + const result = await query.match( + async (value) => `Async: ${value}`, + async () => 'Async: No value', + async (err) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return `Async: ${err}`; + }, + ); + + // Assert + expect(result).toBe('Async: fail'); + }); + }); +}); diff --git a/src/query/spec/match.type-spec.ts b/src/query/spec/match.type-spec.ts new file mode 100644 index 0000000..f3309da --- /dev/null +++ b/src/query/spec/match.type-spec.ts @@ -0,0 +1,217 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from "../"; + +describe("Query.match - Type Tests", () => { + describe("match with OkSome", () => { + it("should infer return type from all three callback return types", () => { + // Arrange + const query = okSome(42); + + // Act + const stringResult = query.match( + (value) => `Value: ${value}`, + () => "No value", + () => "Error", + ); + + const numberResult = query.match( + (value) => value * 2, + () => 0, + () => -1, + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(numberResult).toEqualTypeOf(); + }); + + it("should infer union type when callbacks return different types", () => { + // Arrange + const query = okSome(42); + + // Act + const unionResult = query.match( + (value) => value.toString(), + () => 0, + () => true, + ); + + // Assert + expectTypeOf(unionResult).toEqualTypeOf(); + }); + + it("should preserve complex return types", () => { + // Arrange + type ComplexType = { data: string[]; count: number }; + const query = okSome({ id: 1, name: "test" }); + + // Act + const complexResult = query.match( + (value): ComplexType => ({ data: [value.name], count: 1 }), + (): ComplexType => ({ data: [], count: 0 }), + (): ComplexType => ({ data: [], count: -1 }), + ); + + // Assert + expectTypeOf(complexResult).toEqualTypeOf(); + }); + }); + + describe("match with OkNone", () => { + it("should infer return type from callback return types", () => { + // Arrange + const query: Query = okNone() as Query; + + // Act + const stringResult = query.match( + (value) => `Value: ${value}`, + () => "No value", + (err) => `Error: ${err}`, + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + }); + }); + + describe("match with ErrNone", () => { + it("should infer return type from callback return types", () => { + // Arrange + const query: Query = errNone("error") as Query; + + // Act + const stringResult = query.match( + (value) => `Value: ${value}`, + () => "No value", + (err) => `Error: ${err}`, + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + }); + }); + + describe("match callback parameter types", () => { + it("should provide correct parameter type to onSome callback", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + const objectQuery = okSome({ id: 1, name: "test" }); + + // Act & Assert + numberQuery.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + () => 0, + () => 0, + ); + + stringQuery.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + () => "", + () => "", + ); + + objectQuery.match( + (value) => { + expectTypeOf(value).toEqualTypeOf<{ id: number; name: string }>(); + return value; + }, + () => ({ id: 0, name: "" }), + () => ({ id: 0, name: "" }), + ); + }); + + it("should provide correct parameter type to onErr callback", () => { + // Arrange + const query: Query = errNone("error") as Query; + + // Act & Assert + query.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return ""; + }, + () => "", + (err) => { + expectTypeOf(err).toEqualTypeOf(); + return err; + }, + ); + }); + }); + + describe("match function signature validation", () => { + it("should be callable with correct callback signatures", () => { + // Arrange + const query: Query = okSome(42) as Query; + const onSome = (value: number) => value * 2; + const onNone = () => 0; + const onErr = (err: string) => err.length; + + // Assert + expectTypeOf(query.match).toBeCallableWith(onSome, onNone, onErr); + }); + + it("should not be callable with incorrect callback signatures", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Assert + // @ts-expect-error - wrong parameter type for onSome callback + query.match((value: string) => value, () => "", () => ""); + // @ts-expect-error - wrong parameter type for onErr callback + query.match((x: number) => x, () => 0, (err: number) => err); + }); + + it("should not be callable with missing callbacks", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Assert + // @ts-expect-error - missing onNone and onErr callbacks + query.match((x: number) => x); + // @ts-expect-error - missing onErr callback + query.match((x: number) => x, () => 0); + // @ts-expect-error - missing all callbacks + query.match(); + }); + }); + + describe("match with complex types", () => { + it("should handle async callbacks correctly", () => { + // Arrange + const query = okSome(42); + + // Act + const asyncResult = query.match( + async (value) => `Async: ${value}`, + async () => "Async: No value", + async () => "Async: Error", + ); + + // Assert + expectTypeOf(asyncResult).toEqualTypeOf>(); + }); + + it("should handle callback functions as return values", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act + const functionResult = query.match( + (value) => (x: number) => x + value, + () => (x: number) => x, + () => (x: number) => x, + ); + + // Assert + expectTypeOf(functionResult).toEqualTypeOf<(x: number) => number>(); + }); + }); +}); diff --git a/src/query/spec/namespace.spec.ts b/src/query/spec/namespace.spec.ts new file mode 100644 index 0000000..f188edd --- /dev/null +++ b/src/query/spec/namespace.spec.ts @@ -0,0 +1,641 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Query, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from '../'; + +describe('Query namespace - Runtime Tests', () => { + describe('Query.from', () => { + it('should return OkSome when function returns a non-null/undefined value', () => { + // Act + const result = Query.from(() => 42); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should return OkSome for various value types', () => { + // Arrange & Act & Assert + const stringResult = Query.from(() => 'hello'); + expect(stringResult.isSome()).toBe(true); + if (stringResult.isSome()) expect(stringResult.value).toBe('hello'); + + const boolResult = Query.from(() => true); + expect(boolResult.isSome()).toBe(true); + if (boolResult.isSome()) expect(boolResult.value).toBe(true); + + const objResult = Query.from(() => ({ id: 1 })); + expect(objResult.isSome()).toBe(true); + if (objResult.isSome()) expect(objResult.value).toEqual({ id: 1 }); + + const arrResult = Query.from(() => [1, 2, 3]); + expect(arrResult.isSome()).toBe(true); + if (arrResult.isSome()) expect(arrResult.value).toEqual([1, 2, 3]); + }); + + it('should return OkNone when function returns null', () => { + // Act + const result = Query.from(() => null); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return OkNone when function returns undefined', () => { + // Act + const result = Query.from(() => undefined); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return ErrNone when function throws', () => { + // Arrange + const error = new Error('something failed'); + + // Act + const result = Query.from(() => { + throw error; + }); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe(error); + } + }); + + it('should apply errorMap when function throws and errorMap is provided', () => { + // Act + const result = Query.from( + () => { + throw new Error('original'); + }, + () => 'mapped error', + ); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('mapped error'); + } + }); + + it('should pass the caught error to errorMap', () => { + // Arrange + const originalError = new Error('original'); + const errorMapSpy = vi.fn((e: unknown) => `Mapped: ${(e as Error).message}`); + + // Act + const result = Query.from(() => { + throw originalError; + }, errorMapSpy); + + // Assert + expect(errorMapSpy).toHaveBeenCalledWith(originalError); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('Mapped: original'); + } + }); + + it('should return OkSome for falsy-but-not-nullish values (0, false, "")', () => { + // Act + const zeroResult = Query.from(() => 0 as number | null); + const falseResult = Query.from(() => false as boolean | null); + const emptyResult = Query.from(() => '' as string | null); + + // Assert + expect(zeroResult.isSome()).toBe(true); + if (zeroResult.isSome()) expect(zeroResult.value).toBe(0); + + expect(falseResult.isSome()).toBe(true); + if (falseResult.isSome()) expect(falseResult.value).toBe(false); + + expect(emptyResult.isSome()).toBe(true); + if (emptyResult.isSome()) expect(emptyResult.value).toBe(''); + }); + + it('should return OkSome when passed a non-null/undefined value directly', () => { + // Act & Assert + const numResult = Query.from(42); + expect(numResult.isSome()).toBe(true); + if (numResult.isSome()) expect(numResult.value).toBe(42); + + const strResult = Query.from('hello'); + expect(strResult.isSome()).toBe(true); + if (strResult.isSome()) expect(strResult.value).toBe('hello'); + + const objResult = Query.from({ id: 1 }); + expect(objResult.isSome()).toBe(true); + if (objResult.isSome()) expect(objResult.value).toEqual({ id: 1 }); + }); + + it('should return OkNone when passed null or undefined directly', () => { + // Act & Assert + expect(Query.from(null).isNone()).toBe(true); + expect(Query.from(undefined).isNone()).toBe(true); + }); + + it('should return OkSome for falsy-but-not-nullish values passed directly', () => { + // Act & Assert + const zeroResult = Query.from(0); + expect(zeroResult.isSome()).toBe(true); + if (zeroResult.isSome()) expect(zeroResult.value).toBe(0); + + const falseResult = Query.from(false); + expect(falseResult.isSome()).toBe(true); + if (falseResult.isSome()) expect(falseResult.value).toBe(false); + + const emptyResult = Query.from(''); + expect(emptyResult.isSome()).toBe(true); + if (emptyResult.isSome()) expect(emptyResult.value).toBe(''); + }); + }); + + describe('Query.isQuery', () => { + it('should return true for OkSome instances', () => { + // Arrange & Act & Assert + expect(Query.isQuery(okSome(42))).toBe(true); + expect(Query.isQuery(okSome('hello'))).toBe(true); + expect(Query.isQuery(okSome({ id: 1 }))).toBe(true); + }); + + it('should return true for OkNone instances', () => { + // Arrange & Act & Assert + expect(Query.isQuery(okNone())).toBe(true); + }); + + it('should return true for ErrNone instances', () => { + // Arrange & Act & Assert + expect(Query.isQuery(errNone('error'))).toBe(true); + expect(Query.isQuery(errNone(404))).toBe(true); + }); + + it('should return false for non-Query values', () => { + // Arrange & Act & Assert + expect(Query.isQuery(42)).toBe(false); + expect(Query.isQuery('hello')).toBe(false); + expect(Query.isQuery(null)).toBe(false); + expect(Query.isQuery(undefined)).toBe(false); + expect(Query.isQuery(true)).toBe(false); + expect(Query.isQuery({ isSome: () => true })).toBe(false); + expect(Query.isQuery([1, 2, 3])).toBe(false); + expect(Query.isQuery({})).toBe(false); + }); + }); + + describe('Query.all', () => { + it('should return OkSome with all values when all queries are OkSome', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okSome(2) as Query, + okSome(3) as Query, + ]; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 2, 3]); + } + }); + + it('should skip OkNone values and return remaining values', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okNone() as Query, + okSome(3) as Query, + ]; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 3]); + } + }); + + it('should return OkNone when all queries are OkNone', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + okNone() as Query, + ]; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return the first ErrNone when any query is ErrNone', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + errNone('first error') as Query, + okSome(3) as Query, + errNone('second error') as Query, + ]; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('first error'); + } + }); + + it('should return OkNone for empty array', () => { + // Arrange + const queries: Query[] = []; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should handle mixed OkSome and OkNone correctly', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + okSome(1) as Query, + okNone() as Query, + okSome(2) as Query, + okNone() as Query, + ]; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 2]); + } + }); + + it('should return ErrNone even if preceded by OkNone', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + errNone('error') as Query, + okSome(1) as Query, + ]; + + // Act + const result = Query.all(queries); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + }); + + describe('Query.partition', () => { + it('should partition queries into values, noneCount, and errors', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okNone() as Query, + errNone('error1') as Query, + okSome(2) as Query, + okNone() as Query, + errNone('error2') as Query, + ]; + + // Act + const result = Query.partition(queries); + + // Assert + expect(result.values).toEqual([1, 2]); + expect(result.noneCount).toBe(2); + expect(result.errors).toEqual(['error1', 'error2']); + }); + + it('should return empty arrays and zero noneCount for empty input', () => { + // Arrange + const queries: Query[] = []; + + // Act + const result = Query.partition(queries); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('should handle all OkSome queries', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okSome(2) as Query, + ]; + + // Act + const result = Query.partition(queries); + + // Assert + expect(result.values).toEqual([1, 2]); + expect(result.noneCount).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('should handle all OkNone queries', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + okNone() as Query, + okNone() as Query, + ]; + + // Act + const result = Query.partition(queries); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(3); + expect(result.errors).toEqual([]); + }); + + it('should handle all ErrNone queries', () => { + // Arrange + const queries: Query[] = [ + errNone('err1') as Query, + errNone('err2') as Query, + ]; + + // Act + const result = Query.partition(queries); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(0); + expect(result.errors).toEqual(['err1', 'err2']); + }); + }); + + describe('Query.compact', () => { + it('should extract values from OkSome instances only', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okNone() as Query, + errNone('error') as Query, + okSome(3) as Query, + ]; + + // Act + const result = Query.compact(queries); + + // Assert + expect(result).toEqual([1, 3]); + }); + + it('should return empty array when no OkSome instances', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + errNone('error') as Query, + ]; + + // Act + const result = Query.compact(queries); + + // Assert + expect(result).toEqual([]); + }); + + it('should return empty array for empty input', () => { + // Arrange + const queries: Query[] = []; + + // Act + const result = Query.compact(queries); + + // Assert + expect(result).toEqual([]); + }); + + it('should return all values when all are OkSome', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okSome(2) as Query, + okSome(3) as Query, + ]; + + // Act + const result = Query.compact(queries); + + // Assert + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('Query.some', () => { + it('should return true when at least one query is OkSome', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okNone() as Query, + errNone('error') as Query, + ]; + + // Act + const result = Query.some(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when at least one query is OkNone', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + errNone('error') as Query, + ]; + + // Act + const result = Query.some(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return false for empty array', () => { + // Arrange + const queries: Query[] = []; + + // Act + const result = Query.some(queries); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when all are OkNone', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + okNone() as Query, + ]; + + // Act + const result = Query.some(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all are ErrNone', () => { + // Arrange + const queries: Query[] = [ + errNone('error1') as Query, + errNone('error2') as Query, + ]; + + // Act + const result = Query.some(queries); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when only one query is OkSome', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + okNone() as Query, + okSome(42) as Query, + errNone('error') as Query, + ]; + + // Act + const result = Query.some(queries); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('Query.every', () => { + it('should return true when all queries are OkSome or OkNone', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okNone() as Query, + okSome(2) as Query, + ]; + + // Act + const result = Query.every(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when any query is ErrNone', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okNone() as Query, + errNone('error') as Query, + ]; + + // Act + const result = Query.every(queries); + + // Assert + expect(result).toBe(false); + }); + + it('should return true for empty array', () => { + // Arrange + const queries: Query[] = []; + + // Act + const result = Query.every(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when all are OkSome', () => { + // Arrange + const queries: Query[] = [ + okSome(1) as Query, + okSome(2) as Query, + okSome(3) as Query, + ]; + + // Act + const result = Query.every(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when all are OkNone', () => { + // Arrange + const queries: Query[] = [ + okNone() as Query, + okNone() as Query, + ]; + + // Act + const result = Query.every(queries); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all are ErrNone', () => { + // Arrange + const queries: Query[] = [ + errNone('err1') as Query, + errNone('err2') as Query, + ]; + + // Act + const result = Query.every(queries); + + // Assert + expect(result).toBe(false); + }); + + it('should accept both OkSome and OkNone as success (not just OkSome)', () => { + // Arrange - all OkNone is still "every" + const allNone: Query[] = [ + okNone() as Query, + okNone() as Query, + ]; + + // Act & Assert + expect(Query.every(allNone)).toBe(true); + + // Arrange - mixed OkSome and OkNone + const mixed: Query[] = [ + okSome(1) as Query, + okNone() as Query, + ]; + + // Act & Assert + expect(Query.every(mixed)).toBe(true); + }); + }); +}); diff --git a/src/query/spec/namespace.type-spec.ts b/src/query/spec/namespace.type-spec.ts new file mode 100644 index 0000000..1ca68fe --- /dev/null +++ b/src/query/spec/namespace.type-spec.ts @@ -0,0 +1,227 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { + Query, + QueryInterface, + OkSome, + OkNone, + ErrNone, + okSome, + okNone, + errNone, + QueryPartitionResult, +} from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const qi = (q: Query) => q as unknown as QueryInterface; + +describe("Query namespace - Type Tests", () => { + describe("Query.from", () => { + it("should return QueryFromType | ErrNone without errorMap", () => { + // Act + const result = Query.from(() => 42); + + // Assert + expectTypeOf(result).toEqualTypeOf | ErrNone>(); + }); + + it("should return QueryFromType | ErrNone with errorMap", () => { + // Act + const result = Query.from( + () => 42, + () => "error", + ); + + // Assert + expectTypeOf(result).toEqualTypeOf | ErrNone>(); + }); + + it("should return OkNone for null return type", () => { + // Act + const result = Query.from(() => null); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return OkNone for undefined return type", () => { + // Act + const result = Query.from(() => undefined); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should handle nullable return types", () => { + // Act + const result = Query.from(() => "hello" as string | null); + + // Assert + expectTypeOf(qi(result as Query)).toEqualTypeOf>(); + }); + + it("should be callable with correct function signatures", () => { + // Assert + expectTypeOf(Query.from).toBeCallableWith(() => 42); + expectTypeOf(Query.from).toBeCallableWith( + () => 42, + () => "error", + ); + expectTypeOf(Query.from).toBeCallableWith(() => null); + }); + + it("should accept plain values and return QueryFromType", () => { + // Act + const numResult = Query.from(42); + const strResult = Query.from("hello"); + const nullResult = Query.from(null); + const undefinedResult = Query.from(undefined); + const zeroResult = Query.from(0); + const falseResult = Query.from(false); + + // Assert + expectTypeOf(numResult).toEqualTypeOf | OkNone>(); + expectTypeOf(strResult).toEqualTypeOf | OkNone>(); + expectTypeOf(nullResult).toEqualTypeOf(); + expectTypeOf(undefinedResult).toEqualTypeOf(); + expectTypeOf(zeroResult).toEqualTypeOf | OkNone>(); + expectTypeOf(falseResult).toEqualTypeOf | OkNone>(); + }); + + it("should strip null/undefined from value union types", () => { + // Arrange + const value = "hello" as string | null; + + // Act + const result = Query.from(value); + + // Assert + expectTypeOf(result).toEqualTypeOf | OkNone>(); + }); + + it("should reject async functions with a type-level error", () => { + // @ts-expect-error — async functions should not be passed to Query.from + const result = Query.from(async () => 42); + + expectTypeOf(result).toBeString(); + }); + + it("should reject async functions even with errorMap", () => { + // @ts-expect-error — async functions should not be passed to Query.from + const result = Query.from(async () => 42, () => "error"); + + expectTypeOf(result).toBeString(); + }); + }); + + describe("Query.isQuery", () => { + it("should act as type guard for Query", () => { + // Arrange + const value: unknown = okSome(42); + + // Act & Assert + if (Query.isQuery(value)) { + expectTypeOf(value).toEqualTypeOf>(); + } + }); + + it("should return boolean", () => { + // Act + const result = Query.isQuery(42); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should be callable with any value", () => { + // Assert + expectTypeOf(Query.isQuery).toBeCallableWith(42); + expectTypeOf(Query.isQuery).toBeCallableWith("hello"); + expectTypeOf(Query.isQuery).toBeCallableWith(null); + expectTypeOf(Query.isQuery).toBeCallableWith(undefined); + expectTypeOf(Query.isQuery).toBeCallableWith(okSome(42)); + expectTypeOf(Query.isQuery).toBeCallableWith(okNone()); + expectTypeOf(Query.isQuery).toBeCallableWith(errNone("error")); + }); + }); + + describe("Query.all", () => { + it("should return Query", () => { + // Arrange + const queries: Query[] = [okSome(1) as Query]; + + // Act + const result = Query.all(queries); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should be callable with Query array", () => { + // Assert + expectTypeOf(Query.all).toBeCallableWith([ + okSome(1) as Query, + okNone() as Query, + ]); + }); + + it("should return OkSome when all inputs are known OkSome", () => { + const result = Query.all([okSome(1), okSome(2), okSome(3)]); + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("Query.partition", () => { + it("should return QueryPartitionResult", () => { + // Arrange + const queries: Query[] = [okSome(1) as Query]; + + // Act + const result = Query.partition(queries); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.values).toEqualTypeOf(); + expectTypeOf(result.noneCount).toEqualTypeOf(); + expectTypeOf(result.errors).toEqualTypeOf(); + }); + }); + + describe("Query.compact", () => { + it("should return T[]", () => { + // Arrange + const queries: Query[] = [okSome(1) as Query]; + + // Act + const result = Query.compact(queries); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("Query.some", () => { + it("should return boolean", () => { + // Arrange + const queries: Query[] = [okSome(1) as Query]; + + // Act + const result = Query.some(queries); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("Query.every", () => { + it("should return boolean", () => { + // Arrange + const queries: Query[] = [okSome(1) as Query]; + + // Act + const result = Query.every(queries); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); +}); diff --git a/src/query/spec/nullable.spec.ts b/src/query/spec/nullable.spec.ts new file mode 100644 index 0000000..3631212 --- /dev/null +++ b/src/query/spec/nullable.spec.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from 'vitest'; +import { Query, okSome, okNone, errNone } from '../'; + +describe('Query toNullable and toUndefined - Runtime Tests', () => { + describe('toNullable method', () => { + describe('when Query is OkSome', () => { + it('should return the contained value', () => { + // Arrange + const query = okSome(42); + + // Act + const result = query.toNullable(); + + // Assert + expect(result).toBe(42); + }); + + it('should return the value for various data types', () => { + // Arrange & Act & Assert + expect(okSome('hello').toNullable()).toBe('hello'); + expect(okSome(true).toNullable()).toBe(true); + expect(okSome({ id: 1 }).toNullable()).toEqual({ id: 1 }); + expect(okSome([1, 2, 3]).toNullable()).toEqual([1, 2, 3]); + }); + + it('should return falsy values correctly', () => { + // Arrange & Act & Assert + expect(okSome(0).toNullable()).toBe(0); + expect(okSome(false).toNullable()).toBe(false); + expect(okSome('').toNullable()).toBe(''); + }); + + it('should return the exact same reference for objects', () => { + // Arrange + const obj = { id: 1, name: 'test' }; + const query = okSome(obj); + + // Act + const result = query.toNullable(); + + // Assert + expect(result).toBe(obj); + }); + }); + + describe('when Query is OkNone', () => { + it('should return null', () => { + // Arrange + const query = okNone(); + + // Act + const result = query.toNullable(); + + // Assert + expect(result).toBeNull(); + }); + }); + + describe('when Query is ErrNone', () => { + it('should return null', () => { + // Arrange + const query = errNone('error'); + + // Act + const result = query.toNullable(); + + // Assert + expect(result).toBeNull(); + }); + + it('should return null regardless of error type', () => { + // Arrange & Act & Assert + expect(errNone('string error').toNullable()).toBeNull(); + expect(errNone(404).toNullable()).toBeNull(); + expect(errNone(new Error('fail')).toNullable()).toBeNull(); + expect(errNone({ code: 'ERR' }).toNullable()).toBeNull(); + }); + }); + }); + + describe('toUndefined method', () => { + describe('when Query is OkSome', () => { + it('should return the contained value', () => { + // Arrange + const query = okSome(42); + + // Act + const result = query.toUndefined(); + + // Assert + expect(result).toBe(42); + }); + + it('should return the value for various data types', () => { + // Arrange & Act & Assert + expect(okSome('hello').toUndefined()).toBe('hello'); + expect(okSome(true).toUndefined()).toBe(true); + expect(okSome({ id: 1 }).toUndefined()).toEqual({ id: 1 }); + expect(okSome([1, 2, 3]).toUndefined()).toEqual([1, 2, 3]); + }); + + it('should return falsy values correctly', () => { + // Arrange & Act & Assert + expect(okSome(0).toUndefined()).toBe(0); + expect(okSome(false).toUndefined()).toBe(false); + expect(okSome('').toUndefined()).toBe(''); + }); + + it('should return the exact same reference for objects', () => { + // Arrange + const obj = { id: 1, name: 'test' }; + const query = okSome(obj); + + // Act + const result = query.toUndefined(); + + // Assert + expect(result).toBe(obj); + }); + }); + + describe('when Query is OkNone', () => { + it('should return undefined', () => { + // Arrange + const query = okNone(); + + // Act + const result = query.toUndefined(); + + // Assert + expect(result).toBeUndefined(); + }); + }); + + describe('when Query is ErrNone', () => { + it('should return undefined', () => { + // Arrange + const query = errNone('error'); + + // Act + const result = query.toUndefined(); + + // Assert + expect(result).toBeUndefined(); + }); + + it('should return undefined regardless of error type', () => { + // Arrange & Act & Assert + expect(errNone('string error').toUndefined()).toBeUndefined(); + expect(errNone(404).toUndefined()).toBeUndefined(); + expect(errNone(new Error('fail')).toUndefined()).toBeUndefined(); + expect(errNone({ code: 'ERR' }).toUndefined()).toBeUndefined(); + }); + }); + }); + + describe('toNullable and toUndefined consistency', () => { + it('should both return the value for OkSome', () => { + // Arrange + const query = okSome(42); + + // Act & Assert + expect(query.toNullable()).toBe(42); + expect(query.toUndefined()).toBe(42); + }); + + it('should both return non-value for OkNone', () => { + // Arrange + const query = okNone(); + + // Act & Assert + expect(query.toNullable()).toBeNull(); + expect(query.toUndefined()).toBeUndefined(); + }); + + it('should both return non-value for ErrNone', () => { + // Arrange + const query = errNone('error'); + + // Act & Assert + expect(query.toNullable()).toBeNull(); + expect(query.toUndefined()).toBeUndefined(); + }); + }); +}); diff --git a/src/query/spec/nullable.type-spec.ts b/src/query/spec/nullable.type-spec.ts new file mode 100644 index 0000000..57bdf29 --- /dev/null +++ b/src/query/spec/nullable.type-spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from "../"; + +describe("Query toNullable and toUndefined - Type Tests", () => { + describe("toNullable method", () => { + it("should return T for OkSome", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + const objectQuery = okSome({ id: 1 }); + + // Act + const numberResult = numberQuery.toNullable(); + const stringResult = stringQuery.toNullable(); + const objectResult = objectQuery.toNullable(); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(objectResult).toEqualTypeOf<{ id: number }>(); + }); + + it("should return null for OkNone", () => { + // Arrange + const query = okNone(); + + // Act + const result = query.toNullable(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return null for ErrNone", () => { + // Arrange + const query = errNone("error"); + + // Act + const result = query.toNullable(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return T | null for Query", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act + const result = query.toNullable(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("toUndefined method", () => { + it("should return T for OkSome", () => { + // Arrange + const numberQuery = okSome(42); + const stringQuery = okSome("hello"); + const objectQuery = okSome({ id: 1 }); + + // Act + const numberResult = numberQuery.toUndefined(); + const stringResult = stringQuery.toUndefined(); + const objectResult = objectQuery.toUndefined(); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf(); + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(objectResult).toEqualTypeOf<{ id: number }>(); + }); + + it("should return undefined for OkNone", () => { + // Arrange + const query = okNone(); + + // Act + const result = query.toUndefined(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return undefined for ErrNone", () => { + // Arrange + const query = errNone("error"); + + // Act + const result = query.toUndefined(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should return T | undefined for Query", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act + const result = query.toUndefined(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); +}); diff --git a/src/query/spec/promise.spec.ts b/src/query/spec/promise.spec.ts new file mode 100644 index 0000000..6e9abf3 --- /dev/null +++ b/src/query/spec/promise.spec.ts @@ -0,0 +1,472 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Query, QueryPromise, okSome, okNone, errNone } from '../'; + +describe('QueryPromise namespace - Runtime Tests', () => { + describe('QueryPromise.from', () => { + it('should return OkSome when promise resolves with non-null/undefined value', async () => { + // Act + const result = await QueryPromise.from(() => Promise.resolve(42)); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toBe(42); + } + }); + + it('should return OkSome for various value types', async () => { + // Arrange & Act & Assert + const stringResult = await QueryPromise.from(() => Promise.resolve('hello')); + expect(stringResult.isSome()).toBe(true); + if (stringResult.isSome()) expect(stringResult.value).toBe('hello'); + + const boolResult = await QueryPromise.from(() => Promise.resolve(true)); + expect(boolResult.isSome()).toBe(true); + if (boolResult.isSome()) expect(boolResult.value).toBe(true); + + const objResult = await QueryPromise.from(() => Promise.resolve({ id: 1 })); + expect(objResult.isSome()).toBe(true); + if (objResult.isSome()) expect(objResult.value).toEqual({ id: 1 }); + + const arrResult = await QueryPromise.from(() => Promise.resolve([1, 2, 3])); + expect(arrResult.isSome()).toBe(true); + if (arrResult.isSome()) expect(arrResult.value).toEqual([1, 2, 3]); + }); + + it('should return OkNone when promise resolves with null', async () => { + // Act + const result = await QueryPromise.from(() => Promise.resolve(null)); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return OkNone when promise resolves with undefined', async () => { + // Act + const result = await QueryPromise.from(() => Promise.resolve(undefined)); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return ErrNone when promise rejects', async () => { + // Arrange + const error = new Error('async failure'); + + // Act + const result = await QueryPromise.from(() => Promise.reject(error)); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe(error); + } + }); + + it('should return ErrNone when function throws synchronously', async () => { + // Arrange + const error = new Error('sync failure'); + + // Act + const result = await QueryPromise.from(() => { + throw error; + }); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe(error); + } + }); + + it('should apply errorMap when promise rejects and errorMap is provided', async () => { + // Act + const result = await QueryPromise.from( + () => Promise.reject(new Error('original')), + () => 'mapped error', + ); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('mapped error'); + } + }); + + it('should pass the caught error to errorMap', async () => { + // Arrange + const originalError = new Error('original'); + const errorMapSpy = vi.fn((e: unknown) => `Mapped: ${(e as Error).message}`); + + // Act + const result = await QueryPromise.from(() => Promise.reject(originalError), errorMapSpy); + + // Assert + expect(errorMapSpy).toHaveBeenCalledWith(originalError); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('Mapped: original'); + } + }); + + it('should return OkSome for falsy-but-not-nullish values (0, false, "")', async () => { + // Act + const zeroResult = await QueryPromise.from(() => Promise.resolve(0 as number | null)); + const falseResult = await QueryPromise.from(() => Promise.resolve(false as boolean | null)); + const emptyResult = await QueryPromise.from(() => Promise.resolve('' as string | null)); + + // Assert + expect(zeroResult.isSome()).toBe(true); + if (zeroResult.isSome()) expect(zeroResult.value).toBe(0); + + expect(falseResult.isSome()).toBe(true); + if (falseResult.isSome()) expect(falseResult.value).toBe(false); + + expect(emptyResult.isSome()).toBe(true); + if (emptyResult.isSome()) expect(emptyResult.value).toBe(''); + }); + }); + + describe('QueryPromise.all', () => { + it('should return OkSome with all values when all promises resolve to OkSome', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okSome(2) as Query), + Promise.resolve(okSome(3) as Query), + ]; + + // Act + const result = await QueryPromise.all(promises); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 2, 3]); + } + }); + + it('should skip OkNone values and return remaining values', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(okSome(3) as Query), + ]; + + // Act + const result = await QueryPromise.all(promises); + + // Assert + expect(result.isSome()).toBe(true); + if (result.isSome()) { + expect(result.value).toEqual([1, 3]); + } + }); + + it('should return OkNone when all promises resolve to OkNone', async () => { + // Arrange + const promises = [ + Promise.resolve(okNone() as Query), + Promise.resolve(okNone() as Query), + ]; + + // Act + const result = await QueryPromise.all(promises); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should return the first ErrNone when any promise resolves to ErrNone', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(errNone('first error') as Query), + Promise.resolve(okSome(3) as Query), + ]; + + // Act + const result = await QueryPromise.all(promises); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('first error'); + } + }); + + it('should return OkNone for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await QueryPromise.all(promises); + + // Assert + expect(result.isNone()).toBe(true); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.reject(new Error('promise rejection')), + ]; + + // Act & Assert + await expect(QueryPromise.all(promises)).rejects.toThrow('promise rejection'); + }); + }); + + describe('QueryPromise.partition', () => { + it('should partition promises into values, noneCount, and errors', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error1') as Query), + Promise.resolve(okSome(2) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error2') as Query), + ]; + + // Act + const result = await QueryPromise.partition(promises); + + // Assert + expect(result.values).toEqual([1, 2]); + expect(result.noneCount).toBe(2); + expect(result.errors).toEqual(['error1', 'error2']); + }); + + it('should return empty arrays and zero noneCount for empty input', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await QueryPromise.partition(promises); + + // Assert + expect(result.values).toEqual([]); + expect(result.noneCount).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('should handle all OkSome promises', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okSome(2) as Query), + ]; + + // Act + const result = await QueryPromise.partition(promises); + + // Assert + expect(result.values).toEqual([1, 2]); + expect(result.noneCount).toBe(0); + expect(result.errors).toEqual([]); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.reject(new Error('promise rejection')), + ]; + + // Act & Assert + await expect(QueryPromise.partition(promises)).rejects.toThrow('promise rejection'); + }); + }); + + describe('QueryPromise.compact', () => { + it('should extract values from OkSome promises only', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error') as Query), + Promise.resolve(okSome(3) as Query), + ]; + + // Act + const result = await QueryPromise.compact(promises); + + // Assert + expect(result).toEqual([1, 3]); + }); + + it('should return empty array when no OkSome instances', async () => { + // Arrange + const promises = [ + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error') as Query), + ]; + + // Act + const result = await QueryPromise.compact(promises); + + // Assert + expect(result).toEqual([]); + }); + + it('should return empty array for empty input', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await QueryPromise.compact(promises); + + // Assert + expect(result).toEqual([]); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.reject(new Error('promise rejection')), + ]; + + // Act & Assert + await expect(QueryPromise.compact(promises)).rejects.toThrow('promise rejection'); + }); + }); + + describe('QueryPromise.some', () => { + it('should return true when at least one promise resolves to OkSome', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error') as Query), + ]; + + // Act + const result = await QueryPromise.some(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when at least one promise resolves to OkNone', async () => { + // Arrange + const promises = [ + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error') as Query), + ]; + + // Act + const result = await QueryPromise.some(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all promises resolve to ErrNone', async () => { + // Arrange + const promises = [ + Promise.resolve(errNone('error1') as Query), + Promise.resolve(errNone('error2') as Query), + ]; + + // Act + const result = await QueryPromise.some(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return false for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await QueryPromise.some(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.reject(new Error('promise rejection')), + ]; + + // Act & Assert + await expect(QueryPromise.some(promises)).rejects.toThrow('promise rejection'); + }); + }); + + describe('QueryPromise.every', () => { + it('should return true when all promises resolve to OkSome or OkNone', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(okSome(2) as Query), + ]; + + // Act + const result = await QueryPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when any promise resolves to ErrNone', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + Promise.resolve(errNone('error') as Query), + ]; + + // Act + const result = await QueryPromise.every(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return true for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await QueryPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return true when all are OkNone', async () => { + // Arrange + const promises = [ + Promise.resolve(okNone() as Query), + Promise.resolve(okNone() as Query), + ]; + + // Act + const result = await QueryPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(okSome(1) as Query), + Promise.reject(new Error('promise rejection')), + ]; + + // Act & Assert + await expect(QueryPromise.every(promises)).rejects.toThrow('promise rejection'); + }); + }); +}); diff --git a/src/query/spec/promise.type-spec.ts b/src/query/spec/promise.type-spec.ts new file mode 100644 index 0000000..54b16b2 --- /dev/null +++ b/src/query/spec/promise.type-spec.ts @@ -0,0 +1,156 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { + Query, + QueryInterface, + QueryPromise, + OkSome, + OkNone, + ErrNone, + okSome, + okNone, + errNone, + QueryPartitionResult, +} from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const qi = (q: Query) => q as unknown as QueryInterface; + +describe("QueryPromise namespace - Type Tests", () => { + describe("QueryPromise.from", () => { + it("should return Promise | ErrNone> without errorMap", () => { + // Act + const result = QueryPromise.from(() => Promise.resolve(42)); + + // Assert + expectTypeOf(result).toEqualTypeOf | ErrNone>>(); + }); + + it("should return Promise | ErrNone> with errorMap", () => { + // Act + const result = QueryPromise.from( + () => Promise.resolve(42), + () => "error", + ); + + // Assert + expectTypeOf(result).toEqualTypeOf | ErrNone>>(); + }); + + it("should return OkNone for null promise type", () => { + // Act + const result = QueryPromise.from(() => Promise.resolve(null)); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should return OkNone for undefined promise type", () => { + // Act + const result = QueryPromise.from(() => Promise.resolve(undefined)); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should handle nullable promise return types", () => { + // Act + const result = QueryPromise.from(() => Promise.resolve("hello" as string | null)); + + // Assert + expectTypeOf(result).resolves.toMatchTypeOf>(); + }); + + it("should be callable with correct function signatures", () => { + // Assert + expectTypeOf(QueryPromise.from).toBeCallableWith(() => Promise.resolve(42)); + expectTypeOf(QueryPromise.from).toBeCallableWith( + () => Promise.resolve(42), + () => "error", + ); + expectTypeOf(QueryPromise.from).toBeCallableWith(() => Promise.resolve(null)); + }); + }); + + describe("QueryPromise.all", () => { + it("should return Promise>", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(okSome(1) as Query), + ]; + + // Act + const result = QueryPromise.all(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + + it("should be callable with Promise>[]", () => { + // Assert + expectTypeOf(QueryPromise.all).toBeCallableWith([ + Promise.resolve(okSome(1) as Query), + Promise.resolve(okNone() as Query), + ]); + }); + }); + + describe("QueryPromise.partition", () => { + it("should return Promise>", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(okSome(1) as Query), + ]; + + // Act + const result = QueryPromise.partition(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + }); + + describe("QueryPromise.compact", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(okSome(1) as Query), + ]; + + // Act + const result = QueryPromise.compact(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("QueryPromise.some", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(okSome(1) as Query), + ]; + + // Act + const result = QueryPromise.some(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("QueryPromise.every", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = [ + Promise.resolve(okSome(1) as Query), + ]; + + // Act + const result = QueryPromise.every(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/query/spec/query.spec.ts b/src/query/spec/query.spec.ts new file mode 100644 index 0000000..b8f34ee --- /dev/null +++ b/src/query/spec/query.spec.ts @@ -0,0 +1,389 @@ +import { describe, it, expect } from 'vitest'; +import { Query, OkSome, OkNone, ErrNone, okSome, okNone, errNone } from '../'; + +describe('Query constructors and factories - Runtime Tests', () => { + describe('OkSome constructor', () => { + it('should create OkSome instance with provided value', () => { + // Arrange + const value = 42; + + // Act + const result = new OkSome(value); + + // Assert + expect(result).toBeInstanceOf(OkSome); + expect(result.value).toBe(value); + }); + + it('should store different types of values correctly', () => { + // Arrange + const numberValue = 42; + const stringValue = 'hello'; + const objectValue = { id: 1, name: 'test' }; + const arrayValue = [1, 2, 3]; + const functionValue = (x: number) => x * 2; + + // Act + const numberOkSome = new OkSome(numberValue); + const stringOkSome = new OkSome(stringValue); + const objectOkSome = new OkSome(objectValue); + const arrayOkSome = new OkSome(arrayValue); + const functionOkSome = new OkSome(functionValue); + + // Assert + expect(numberOkSome.value).toBe(numberValue); + expect(stringOkSome.value).toBe(stringValue); + expect(objectOkSome.value).toBe(objectValue); + expect(arrayOkSome.value).toBe(arrayValue); + expect(functionOkSome.value).toBe(functionValue); + }); + + it('should implement QueryInterface methods', () => { + // Arrange + const instance = new OkSome(42); + + // Assert + expect(typeof instance.isSome).toBe('function'); + expect(typeof instance.isNone).toBe('function'); + expect(typeof instance.isErr).toBe('function'); + expect(typeof instance.map).toBe('function'); + expect(typeof instance.mapErr).toBe('function'); + expect(typeof instance.flatMap).toBe('function'); + expect(typeof instance.flatMapErr).toBe('function'); + expect(typeof instance.match).toBe('function'); + expect(typeof instance.filter).toBe('function'); + expect(typeof instance.toNullable).toBe('function'); + expect(typeof instance.toUndefined).toBe('function'); + expect(typeof instance.toString).toBe('function'); + }); + + it('should correctly identify as OkSome', () => { + // Arrange + const instance = new OkSome(42); + + // Act & Assert + expect(instance.isSome()).toBe(true); + expect(instance.isNone()).toBe(false); + expect(instance.isErr()).toBe(false); + }); + }); + + describe('OkNone constructor', () => { + it('should create OkNone instance', () => { + // Act + const result = new OkNone(); + + // Assert + expect(result).toBeInstanceOf(OkNone); + }); + + it('should implement QueryInterface methods', () => { + // Arrange + const instance = new OkNone(); + + // Assert + expect(typeof instance.isSome).toBe('function'); + expect(typeof instance.isNone).toBe('function'); + expect(typeof instance.isErr).toBe('function'); + expect(typeof instance.map).toBe('function'); + expect(typeof instance.mapErr).toBe('function'); + expect(typeof instance.flatMap).toBe('function'); + expect(typeof instance.flatMapErr).toBe('function'); + expect(typeof instance.match).toBe('function'); + expect(typeof instance.filter).toBe('function'); + expect(typeof instance.toNullable).toBe('function'); + expect(typeof instance.toUndefined).toBe('function'); + expect(typeof instance.toString).toBe('function'); + }); + + it('should correctly identify as OkNone', () => { + // Arrange + const instance = new OkNone(); + + // Act & Assert + expect(instance.isSome()).toBe(false); + expect(instance.isNone()).toBe(true); + expect(instance.isErr()).toBe(false); + }); + }); + + describe('ErrNone constructor', () => { + it('should create ErrNone instance with provided error', () => { + // Arrange + const error = 'something went wrong'; + + // Act + const result = new ErrNone(error); + + // Assert + expect(result).toBeInstanceOf(ErrNone); + expect(result.err).toBe(error); + }); + + it('should store different types of errors correctly', () => { + // Arrange + const stringError = 'error'; + const numberError = 404; + const objectError = { code: 'NOT_FOUND', message: 'not found' }; + const errorInstance = new Error('fail'); + + // Act + const stringErrNone = new ErrNone(stringError); + const numberErrNone = new ErrNone(numberError); + const objectErrNone = new ErrNone(objectError); + const errorErrNone = new ErrNone(errorInstance); + + // Assert + expect(stringErrNone.err).toBe(stringError); + expect(numberErrNone.err).toBe(numberError); + expect(objectErrNone.err).toBe(objectError); + expect(errorErrNone.err).toBe(errorInstance); + }); + + it('should implement QueryInterface methods', () => { + // Arrange + const instance = new ErrNone('error'); + + // Assert + expect(typeof instance.isSome).toBe('function'); + expect(typeof instance.isNone).toBe('function'); + expect(typeof instance.isErr).toBe('function'); + expect(typeof instance.map).toBe('function'); + expect(typeof instance.mapErr).toBe('function'); + expect(typeof instance.flatMap).toBe('function'); + expect(typeof instance.flatMapErr).toBe('function'); + expect(typeof instance.match).toBe('function'); + expect(typeof instance.filter).toBe('function'); + expect(typeof instance.toNullable).toBe('function'); + expect(typeof instance.toUndefined).toBe('function'); + expect(typeof instance.toString).toBe('function'); + }); + + it('should correctly identify as ErrNone', () => { + // Arrange + const instance = new ErrNone('error'); + + // Act & Assert + expect(instance.isSome()).toBe(false); + expect(instance.isNone()).toBe(false); + expect(instance.isErr()).toBe(true); + }); + }); + + describe('okSome() factory function', () => { + it('should create OkSome instance with provided value', () => { + // Arrange + const value = 42; + + // Act + const result = okSome(value); + + // Assert + expect(result).toBeInstanceOf(OkSome); + expect(result.value).toBe(value); + }); + + it('should work with various data types', () => { + // Arrange + const testCases = [ + 42, + 'hello', + true, + { id: 1, name: 'test' }, + [1, 2, 3], + (x: number) => x * 2, + ]; + + testCases.forEach((testValue) => { + // Act + const result = okSome(testValue); + + // Assert + expect(result).toBeInstanceOf(OkSome); + expect(result.value).toBe(testValue); + expect(result.isSome()).toBe(true); + expect(result.isNone()).toBe(false); + expect(result.isErr()).toBe(false); + }); + }); + + it('should create new instances for each call', () => { + // Arrange + const value = 42; + + // Act + const first = okSome(value); + const second = okSome(value); + + // Assert + expect(first).toBeInstanceOf(OkSome); + expect(second).toBeInstanceOf(OkSome); + expect(first).not.toBe(second); + expect(first.value).toBe(second.value); + }); + }); + + describe('okNone() factory function', () => { + it('should return OkNone instance', () => { + // Act + const result = okNone(); + + // Assert + expect(result).toBeInstanceOf(OkNone); + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(true); + expect(result.isErr()).toBe(false); + }); + + it('should return the same singleton instance', () => { + // Act + const first = okNone(); + const second = okNone(); + const third = okNone(); + + // Assert + expect(first).toBe(second); + expect(second).toBe(third); + expect(first).toBe(third); + }); + + it('should return a frozen instance', () => { + // Act + const instance = okNone(); + + // Assert + expect(Object.isFrozen(instance)).toBe(true); + }); + + it('should not accept any arguments', () => { + // Act & Assert + expect(() => okNone()).not.toThrow(); + + // @ts-expect-error Testing runtime behavior when called incorrectly + expect(() => okNone(42)).not.toThrow(); + }); + }); + + describe('errNone() factory function', () => { + it('should create ErrNone instance with provided error', () => { + // Arrange + const error = 'something failed'; + + // Act + const result = errNone(error); + + // Assert + expect(result).toBeInstanceOf(ErrNone); + expect(result.err).toBe(error); + }); + + it('should work with various error types', () => { + // Arrange + const testCases = [ + 'error string', + 42, + new Error('fail'), + { code: 'ERR', message: 'failed' }, + ['err1', 'err2'], + ]; + + testCases.forEach((testError) => { + // Act + const result = errNone(testError); + + // Assert + expect(result).toBeInstanceOf(ErrNone); + expect(result.err).toBe(testError); + expect(result.isSome()).toBe(false); + expect(result.isNone()).toBe(false); + expect(result.isErr()).toBe(true); + }); + }); + + it('should create new instances for each call', () => { + // Arrange + const error = 'error'; + + // Act + const first = errNone(error); + const second = errNone(error); + + // Assert + expect(first).not.toBe(second); + expect(first.err).toBe(second.err); + }); + }); + + describe('toString()', () => { + it('should return correct string for OkSome', () => { + // Arrange & Act & Assert + expect(okSome(42).toString()).toBe('OkSome(42)'); + expect(okSome('hello').toString()).toBe('OkSome(hello)'); + expect(okSome(true).toString()).toBe('OkSome(true)'); + }); + + it('should return correct string for OkNone', () => { + // Arrange & Act & Assert + expect(okNone().toString()).toBe('OkNone()'); + }); + + it('should return correct string for ErrNone', () => { + // Arrange & Act & Assert + expect(errNone('error').toString()).toBe('ErrNone(error)'); + expect(errNone(404).toString()).toBe('ErrNone(404)'); + }); + }); + + describe('Integration tests', () => { + it('should allow creating mixed arrays of Queries', () => { + // Arrange & Act + const queries: Query[] = [ + okSome(1) as Query, + okSome(2) as Query, + okNone() as Query, + errNone('error') as Query, + okSome(3) as Query, + ]; + + // Assert + expect(queries).toHaveLength(5); + expect(queries[0].isSome()).toBe(true); + expect(queries[1].isSome()).toBe(true); + expect(queries[2].isNone()).toBe(true); + expect(queries[3].isErr()).toBe(true); + expect(queries[4].isSome()).toBe(true); + }); + + it('should enable type narrowing in runtime', () => { + // Arrange + const query: Query = okSome('hello') as Query; + + // Act & Assert + if (query.isSome()) { + expect(query.value).toBe('hello'); + expect(typeof query.value).toBe('string'); + } else { + fail('Expected query to be OkSome'); + } + }); + + it('should handle edge cases correctly', () => { + // Arrange & Act + const zeroOkSome = okSome(0); + const falseOkSome = okSome(false); + const emptyOkSome = okSome(''); + const emptyArrayOkSome = okSome([]); + + // Assert - All should be OkSome instances even with "falsy" values + expect(zeroOkSome.isSome()).toBe(true); + expect(falseOkSome.isSome()).toBe(true); + expect(emptyOkSome.isSome()).toBe(true); + expect(emptyArrayOkSome.isSome()).toBe(true); + + expect(zeroOkSome.value).toBe(0); + expect(falseOkSome.value).toBe(false); + expect(emptyOkSome.value).toBe(''); + expect(emptyArrayOkSome.value).toEqual([]); + }); + }); +}); diff --git a/src/query/spec/query.type-spec.ts b/src/query/spec/query.type-spec.ts new file mode 100644 index 0000000..5187b8e --- /dev/null +++ b/src/query/spec/query.type-spec.ts @@ -0,0 +1,225 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Query, OkSome, OkNone, ErrNone, okSome, okNone, errNone, QueryInterface } from "../"; + +describe("Query constructors and factories - Type Tests", () => { + describe("OkSome constructor", () => { + it("should create OkSome with correct type inference", () => { + // Arrange & Act + const numberOkSome = new OkSome(42); + const stringOkSome = new OkSome("hello"); + const objectOkSome = new OkSome({ id: 1, name: "test" }); + const arrayOkSome = new OkSome([1, 2, 3]); + + // Assert + expectTypeOf(numberOkSome).toEqualTypeOf>(); + expectTypeOf(stringOkSome).toEqualTypeOf>(); + expectTypeOf(objectOkSome).toEqualTypeOf>(); + expectTypeOf(arrayOkSome).toEqualTypeOf>(); + }); + + it("should implement QueryInterface with correct type parameters", () => { + // Arrange + const instance = new OkSome(42); + + // Assert + expectTypeOf(instance).toMatchTypeOf>(); + }); + + it("should be assignable to Query", () => { + // Arrange + const numberOkSome = new OkSome(42); + const stringOkSome = new OkSome("hello"); + + // Assert + expectTypeOf(numberOkSome).toMatchTypeOf>(); + expectTypeOf(stringOkSome).toMatchTypeOf>(); + }); + + it("should preserve complex generic types", () => { + // Arrange + type ComplexType = { data: Array<{ id: number; tags: string[] }> }; + const complexValue: ComplexType = { data: [{ id: 1, tags: ["a", "b"] }] }; + const complexOkSome = new OkSome(complexValue); + + // Assert + expectTypeOf(complexOkSome).toEqualTypeOf>(); + expectTypeOf(complexOkSome).toMatchTypeOf>(); + }); + }); + + describe("OkNone constructor", () => { + it("should create OkNone type", () => { + // Arrange & Act + const instance = new OkNone(); + + // Assert + expectTypeOf(instance).toEqualTypeOf(); + }); + + it("should implement QueryInterface", () => { + // Arrange + const instance = new OkNone(); + + // Assert + expectTypeOf(instance).toMatchTypeOf>(); + }); + + it("should be assignable to Query for any T and E", () => { + // Arrange + const instance = new OkNone(); + + // Assert + expectTypeOf(instance).toMatchTypeOf>(); + expectTypeOf(instance).toMatchTypeOf>(); + expectTypeOf(instance).toMatchTypeOf>(); + }); + }); + + describe("ErrNone constructor", () => { + it("should create ErrNone with correct type inference", () => { + // Arrange & Act + const stringErr = new ErrNone("error"); + const numberErr = new ErrNone(404); + const objectErr = new ErrNone({ code: "ERR", message: "fail" }); + + // Assert + expectTypeOf(stringErr).toEqualTypeOf>(); + expectTypeOf(numberErr).toEqualTypeOf>(); + expectTypeOf(objectErr).toEqualTypeOf>(); + }); + + it("should implement QueryInterface", () => { + // Arrange + const instance = new ErrNone("error"); + + // Assert + expectTypeOf(instance).toMatchTypeOf>(); + }); + + it("should be assignable to Query for any T", () => { + // Arrange + const instance = new ErrNone("error"); + + // Assert + expectTypeOf(instance).toMatchTypeOf>(); + expectTypeOf(instance).toMatchTypeOf>(); + }); + }); + + describe("okSome() factory function", () => { + it("should infer correct return type from value", () => { + // Arrange & Act + const numberOkSome = okSome(42); + const stringOkSome = okSome("hello"); + const booleanOkSome = okSome(true); + const objectOkSome = okSome({ id: 1 }); + const arrayOkSome = okSome([1, 2, 3]); + + // Assert + expectTypeOf(numberOkSome).toEqualTypeOf>(); + expectTypeOf(stringOkSome).toEqualTypeOf>(); + expectTypeOf(booleanOkSome).toEqualTypeOf>(); + expectTypeOf(objectOkSome).toEqualTypeOf>(); + expectTypeOf(arrayOkSome).toEqualTypeOf>(); + }); + + it("should preserve literal types", () => { + // Arrange & Act + const literalNumber = okSome(42 as const); + const literalString = okSome("hello" as const); + + // Assert + expectTypeOf(literalNumber).toEqualTypeOf>(); + expectTypeOf(literalString).toEqualTypeOf>(); + }); + + it("should be callable with any type", () => { + // Assert + expectTypeOf(okSome).toBeCallableWith(42); + expectTypeOf(okSome).toBeCallableWith("string"); + expectTypeOf(okSome).toBeCallableWith({ complex: { nested: "object" } }); + }); + }); + + describe("okNone() factory function", () => { + it("should return OkNone type", () => { + // Arrange & Act + const result = okNone(); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should not be callable with arguments", () => { + // Assert + // @ts-expect-error - okNone takes no arguments + okNone(42); + // @ts-expect-error - okNone takes no arguments + okNone("string"); + // @ts-expect-error - okNone takes no arguments + okNone(null); + }); + + it("should be assignable to any Query", () => { + // Arrange + const result = okNone(); + + // Assert + expectTypeOf(result).toMatchTypeOf>(); + expectTypeOf(result).toMatchTypeOf>(); + expectTypeOf(result).toMatchTypeOf>(); + }); + }); + + describe("errNone() factory function", () => { + it("should infer correct return type from error", () => { + // Arrange & Act + const stringErr = errNone("error"); + const numberErr = errNone(404); + const objectErr = errNone({ code: "ERR" }); + + // Assert + expectTypeOf(stringErr).toEqualTypeOf>(); + expectTypeOf(numberErr).toEqualTypeOf>(); + expectTypeOf(objectErr).toEqualTypeOf>(); + }); + + it("should be callable with any type", () => { + // Assert + expectTypeOf(errNone).toBeCallableWith("error"); + expectTypeOf(errNone).toBeCallableWith(42); + expectTypeOf(errNone).toBeCallableWith(new Error("fail")); + }); + + it("should be assignable to any Query", () => { + // Arrange + const result = errNone("error"); + + // Assert + expectTypeOf(result).toMatchTypeOf>(); + expectTypeOf(result).toMatchTypeOf>(); + }); + }); + + describe("Type narrowing with constructors", () => { + it("should enable proper type narrowing with isSome/isNone/isErr", () => { + // Arrange + const query: Query = okSome(42) as Query; + + // Act & Assert + if (query.isSome()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.value).toEqualTypeOf(); + } + + if (query.isNone()) { + expectTypeOf(query).toEqualTypeOf(); + } + + if (query.isErr()) { + expectTypeOf(query).toEqualTypeOf>(); + expectTypeOf(query.err).toEqualTypeOf(); + } + }); + }); +}); diff --git a/src/result/index.ts b/src/result/index.ts new file mode 100644 index 0000000..99d23c5 --- /dev/null +++ b/src/result/index.ts @@ -0,0 +1,566 @@ +/** + * @template T The type of the success value. + * @template E The type of the error value. + */ +export interface ResultInterface { + /** + * Determines whether this result is successful. + * @returns `true` if this result is Ok, `false` otherwise. + * ```ts + * ok(42).isOk() // true + * err('error').isOk() // false + * ``` + */ + isOk(): this is Ok; + + /** + * Determines whether this result is an error. + * @returns `true` if this result is Err, `false` otherwise. + * ```ts + * ok(42).isErr() // false + * err('error').isErr() // true + * ``` + */ + isErr(): this is Err; + + /** + * Transforms the contained value by applying a function to it. + * @param fn Function to apply to the contained value. + * @returns A new result containing the transformed value, or the original Err unchanged. + * @remarks When called on a known `Ok`, returns `Ok`. When called on a known `Err`, returns `Err` — the function is never invoked. + * ```ts + * ok(5).map(x => x * 2) // Ok(10) + * err('error').map(x => x * 2) // Err('error') + * ``` + */ + map(fn: (value: T) => U): Result; + + /** + * Transforms the contained error by applying a function to it. + * @param fn Function to apply to the contained error. + * @returns A new result containing the transformed error, or the original Ok unchanged. + * @remarks When called on a known `Err`, returns `Err`. When called on a known `Ok`, returns `Ok` — the function is never invoked. + * ```ts + * ok(42).mapErr(e => e.toUpperCase()) // Ok(42) + * err('error').mapErr(e => e.toUpperCase()) // Err('ERROR') + * ``` + */ + mapErr(fn: (error: E) => F): Result; + + /** + * Applies a function that returns a result to the contained value and flattens the result. + * @param fn Function that takes the contained value and returns a result. + * @returns The result returned by the function, or Err if this result is an error. + * ```ts + * ok(5).flatMap(x => ok(x * 2)) // Ok(10) + * ok(5).flatMap(x => err('error')) // Err('error') + * err('error').flatMap(x => ok(x * 2)) // Err('error') + * ``` + */ + flatMap(fn: (value: T) => Result): Result; + + /** + * Applies a function that returns a result to the contained error and flattens the result. + * @param fn Function that takes the contained error and returns a result. + * @returns The result returned by the function, or Ok if this result is successful. + * ```ts + * ok(42).flatMapErr(e => err(e.toUpperCase())) // Ok(42) + * err('error').flatMapErr(e => err(e.toUpperCase())) // Err('ERROR') + * err('error').flatMapErr(e => ok('recovered')) // Ok('recovered') + * ``` + */ + flatMapErr(fn: (error: E) => Result): Result; + + /** + * Executes one of two functions based on whether this result is successful or an error. + * @param onOk Function to execute if this result is Ok. + * @param onErr Function to execute if this result is Err. + * @returns The return value of whichever function is executed. `U` is inferred as the union of both callback return types. + * ```ts + * ok(42).match(x => `Value: ${x}`, e => `Error: ${e}`) // 'Value: 42' + * err('error').match(x => `Value: ${x}`, e => `Error: ${e}`) // 'Error: error' + * ``` + */ + match(onOk: (value: T) => U, onErr: (error: E) => U): U; + + /** + * Filters this result based on a type predicate, narrowing the value type on success. + * @param predicate Type predicate function to test the contained value. + * @param errorFactory Function to produce an error if the predicate fails. + * @returns Ok with the narrowed type if Ok and the predicate passes, Err otherwise. A no-op on Err. + * @remarks This overload resolves when the predicate is a type guard (`value is U`), narrowing `T` to `U`. + * ```ts + * ok(42 as number | string).filter((x): x is number => typeof x === 'number', v => `Not a number: ${v}`) // Ok(42): Result + * ok('hello' as number | string).filter((x): x is number => typeof x === 'number', v => `Not a number: ${v}`) // Err('Not a number: hello') + * ``` + */ + filter(predicate: (value: T) => value is U, errorFactory: (value: T) => E): Result; + + /** + * Filters this result based on a boolean predicate. + * @param predicate Function to test the contained value. + * @param errorFactory Function to produce an error if the predicate fails. + * @returns Ok if Ok and the predicate returns true, Err otherwise. A no-op on Err. + * @remarks This overload resolves when the predicate returns `boolean` (not a type guard). The value type `T` is preserved. + * ```ts + * ok(5).filter(x => x > 3, x => `${x} is too small`) // Ok(5) + * ok(2).filter(x => x > 3, x => `${x} is too small`) // Err('2 is too small') + * err('error').filter(x => x > 3, x => `${x} is too small`) // Err('error') + * ``` + */ + filter(predicate: (value: T) => boolean, errorFactory: (value: T) => E): Result; + + /** + * Returns the contained value or null. + * @returns The contained value if this result is Ok, null otherwise. + * ```ts + * ok(42).toNullable() // 42 + * err('error').toNullable() // null + * ``` + */ + toNullable(): T | null; + + /** + * Returns the contained value or undefined. + * @returns The contained value if this result is Ok, undefined otherwise. + * ```ts + * ok(42).toUndefined() // 42 + * err('error').toUndefined() // undefined + * ``` + */ + toUndefined(): T | undefined; + + /** + * Returns a string representation of this result. + * @returns A string describing whether this result is Ok or Err, and the contained value or error. + * ```ts + * ok(42).toString() // 'Ok(42)' + * err('error').toString() // 'Err(error)' + * ``` + */ + toString(): string; +} + +/** + * Represents a successful result containing a value of type `T`. + * + * The error type is always `never` — error-side methods (`mapErr`, `flatMapErr`) are no-ops + * that return the same `Ok` instance unchanged. + * @remarks The contained value is accessible via the `.value` property, either directly or after narrowing with `isOk()`. + */ +export class Ok implements ResultInterface { + constructor(readonly value: T) {} + + isOk(): this is Ok { + return true; + } + + isErr(): this is never { + return false; + } + + map(fn: (value: T) => U): Ok { + return ok(fn(this.value)); + } + + mapErr(): Ok { + return this; + } + + flatMap>(fn: (value: T) => R): R { + return fn(this.value); + } + + flatMapErr(): this { + return this; + } + + match(onOk: (value: T) => U, onErr: (error: never) => U): U { + return onOk(this.value); + } + + filter(predicate: (value: T) => value is U, errorFactory: (value: T) => F): Ok | Err; + filter(predicate: (value: T) => boolean, errorFactory: (value: T) => F): Ok | Err; + filter(predicate: (value: T) => boolean, errorFactory: (value: T) => F): Ok | Err { + return predicate(this.value) ? this : err(errorFactory(this.value)); + } + + toNullable(): T { + return this.value; + } + + toUndefined(): T { + return this.value; + } + + toString(): string { + return `Ok(${this.value})`; + } +} + +/** + * Represents an error result containing an error of type `E`. + * + * The value type is always `never` — value-side methods (`map`, `flatMap`, `filter`) are no-ops + * that return the same `Err` instance unchanged. + * @remarks The contained error is accessible via the `.err` property, either directly or after narrowing with `isErr()`. + */ +export class Err implements ResultInterface { + constructor(readonly err: E) {} + + isOk(): this is never { + return false; + } + + isErr(): this is Err { + return true; + } + + map(): Err { + return this; + } + + mapErr(fn: (error: E) => F): Err { + return err(fn(this.err)); + } + + flatMap(): this { + return this; + } + + flatMapErr>(fn: (error: E) => R): R { + return fn(this.err); + } + + match(onOk: (value: never) => U, onErr: (error: E) => U): U { + return onErr(this.err); + } + + filter(): Err { + return this; + } + + toNullable(): null { + return null; + } + + toUndefined(): undefined { + return undefined; + } + + toString(): string { + return `Err(${this.err})`; + } +} + +/** + * Represents a result that is either Ok containing a value or Err containing an error. + * @remarks Use `isOk()` or `isErr()` to check the result type and access the contained value via `.value` or error via `.err`. + * ```ts + * const result = ok(42); + * if (result.isOk()) { + * console.log(result.value); // 42 + * } + * + * const result = err('failed'); + * if (result.isErr()) { + * console.log(result.err); // 'failed' + * } + * ``` + */ +export type Result = Ok | Err; + +/** + * Represents a promise that resolves to a Result. + */ +export type ResultPromise = Promise>; + +/** + * Represents a promise that resolves to Ok. + */ +export type OkPromise = Promise>; + +/** + * Represents a promise that resolves to Err. + */ +export type ErrPromise = Promise>; + +/** + * Represents the result of partitioning an array of results into values and errors. + */ +export type ResultPartitionResult = { values: T[]; errors: E[] }; + +/** + * Creates a successful result containing the specified value. + * @param value The value to wrap in a result. + * @returns An Ok containing the value. + * ```ts + * const result = ok(42); + * if (result.isOk()) { + * console.log(result.value); // 42 + * } + * ``` + */ +export const ok = (value: T): Ok => new Ok(value); + +/** + * Creates an error result containing the specified error. + * @param error The error to wrap in a result. + * @returns An Err containing the error. + * ```ts + * const result = err('failed'); + * if (result.isErr()) { + * console.log(result.err); // 'failed' + * } + * ``` + */ +export const err = (error: E): Err => new Err(error); + +/** + * Combines an array of results into a single result containing an array of values. + * @param results The array of results to combine. + * @returns Ok containing an array of all values if all results are Ok, the first Err otherwise. + * @remarks Short-circuits on the first Err encountered — remaining results are not evaluated. The returned Err is the original instance by reference. When all inputs are known `Ok`, the return type narrows to `Ok`. + * ```ts + * Result.all([ok(1), ok(2), ok(3)]) // Ok([1, 2, 3]) + * Result.all([ok(1), err('error'), ok(3)]) // Err('error') + * Result.all([]) // Ok([]) + * ``` + */ +function resultAll(results: Ok[]): Ok; +function resultAll(results: Result[]): Result; +function resultAll(results: Result[]): Result { + const values = new Array(results.length); + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.isErr()) return result; + values[i] = result.value; + } + return ok(values); +} + +/** + * Combines an array of result promises into a single promise of a result containing an array of values. + * @param promises The array of result promises to combine. + * @returns A promise that resolves to Ok containing an array of all values if all results are Ok, the first Err otherwise. + * @remarks Rejects if any input promise rejects. When all inputs are known `Promise`, the return type narrows to `Promise>`. + * ```ts + * await ResultPromise.all([Promise.resolve(ok(1)), Promise.resolve(ok(2))]) // Ok([1, 2]) + * await ResultPromise.all([Promise.resolve(ok(1)), Promise.resolve(err('error'))]) // Err('error') + * ``` + */ +async function resultPromiseAll(promises: Promise>[]): Promise>; +async function resultPromiseAll(promises: Promise>[]): Promise>; +async function resultPromiseAll(promises: Promise>[]): Promise> { + const results = await Promise.all(promises); + return resultAll(results); +} + +/** + * @internal Type-level guard: use `ResultPromise.from()` for async functions. + */ +function resultFrom( + fn: () => PromiseLike, + errorMap?: (error: unknown) => any, +): 'ERROR: Result.from() is synchronous — use ResultPromise.from() for async functions'; +/** + * Creates a result by executing a function and catching any thrown errors. + * @param fn Function to execute. + * @param errorMap Optional function to transform caught errors. When omitted, `E` defaults to `unknown` and the caught error is cast unsafely — prefer always providing `errorMap` for type safety. + * @returns Ok containing the function's return value, or Err containing the caught (or mapped) error. + * @remarks Unlike `Option.from`, there is no value overload — raw values don't have + * a natural Ok/Err mapping. Use `ok(value)` or `err(error)` directly to wrap known values. + * ```ts + * Result.from(() => JSON.parse('{"valid": true}')) // Ok({valid: true}) + * Result.from(() => JSON.parse('invalid')) // Err(SyntaxError) — typed as Result + * Result.from(() => JSON.parse('invalid'), e => 'Parse failed') // Err('Parse failed') — typed as Result + * ``` + */ +function resultFrom(fn: () => T, errorMap?: (error: unknown) => E): Result; +function resultFrom(fn: () => T, errorMap?: (error: unknown) => E): any { + try { + return ok(fn()); + } catch (error) { + return err(errorMap ? errorMap(error) : (error as E)); + } +} + +/** + * Namespace providing static utility methods for working with collections of results. + */ +export const Result = { + from: resultFrom, + + /** + * Determines whether a value is a Result. + * @param value The value to test. + * @returns `true` if the value is a Result, `false` otherwise. + * @remarks This is a runtime `instanceof` check — it narrows to `Result` by default. The generic parameters `T` and `E` can be specified explicitly (e.g., `Result.isResult(x)`) but are caller-asserted, not runtime-validated. + * ```ts + * Result.isResult(ok(42)) // true + * Result.isResult(err('error')) // true + * Result.isResult(42) // false + * ``` + */ + isResult(value: unknown): value is Result { + return value instanceof Ok || value instanceof Err; + }, + + all: resultAll, + + /** + * Partitions an array of results into successful values and errors. + * @param results The array of results to partition. + * @returns An object containing arrays of extracted values and errors. + * ```ts + * Result.partition([ok(1), err('error'), ok(3)]) // { values: [1, 3], errors: ['error'] } + * Result.partition([ok(1), ok(2)]) // { values: [1, 2], errors: [] } + * ``` + */ + partition(results: Result[]): ResultPartitionResult { + const values: T[] = []; + const errors: E[] = []; + + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.isOk()) { + values.push(result.value); + } else { + errors.push(result.err); + } + } + + return { values, errors }; + }, + + /** + * Extracts all values from Ok instances in an array of results. + * @param results The array of results to compact. + * @returns An array containing only the values from Ok instances. + * ```ts + * Result.compact([ok(1), err('error'), ok(3)]) // [1, 3] + * Result.compact([err('error1'), err('error2')]) // [] + * ``` + */ + compact(results: Result[]): T[] { + const values: T[] = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.isOk()) values.push(result.value); + } + return values; + }, + + /** + * Determines whether any result in an array is successful. + * @param results The array of results to test. + * @returns `true` if at least one result is Ok, `false` otherwise. + * ```ts + * Result.some([ok(1), err('error'), ok(3)]) // true + * Result.some([err('error1'), err('error2')]) // false + * Result.some([]) // false + * ``` + */ + some(results: Result[]): boolean { + return results.some((result) => result.isOk()); + }, + + /** + * Determines whether every result in an array is successful. + * @param results The array of results to test. + * @returns `true` if all results are Ok, `false` otherwise. + * ```ts + * Result.every([ok(1), ok(2)]) // true + * Result.every([ok(1), err('error')]) // false + * Result.every([]) // true + * ``` + */ + every(results: Result[]): boolean { + return results.every((result) => result.isOk()); + }, +} as const; + +/** + * Namespace providing async utility methods for working with collections of result promises. + */ +export const ResultPromise = { + /** + * Creates a result promise by executing a function and catching any thrown errors. + * @param fn Function to execute. + * @param errorMap Optional function to transform caught errors. + * @returns A promise that resolves to Ok containing the function's return value, or Err containing the caught error. + * ```ts + * await ResultPromise.from(() => fetch('/api').then(r => r.json())) // Ok({data: ...}) or Err(TypeError) if fetch fails + * await ResultPromise.from(() => Promise.reject(new Error('failed'))) // Err(Error('failed')) — typed as Result + * await ResultPromise.from(() => Promise.reject(new Error('failed')), () => 'Request failed') // Err('Request failed') — typed as Result + * ``` + */ + async from(fn: () => Promise, errorMap?: (error: unknown) => E): ResultPromise { + try { + const value = await fn(); + return ok(value); + } catch (error) { + return err(errorMap ? errorMap(error) : (error as E)); + } + }, + + all: resultPromiseAll, + + /** + * Partitions an array of result promises into successful values and errors. + * @param promises The array of result promises to partition. + * @returns A promise that resolves to an object containing arrays of extracted values and errors. + * @remarks Rejects if any input promise rejects. + * ```ts + * await ResultPromise.partition([Promise.resolve(ok(1)), Promise.resolve(err('error'))]) // { values: [1], errors: ['error'] } + * ``` + */ + async partition(promises: Promise>[]): Promise> { + const resolved = await Promise.all(promises); + return Result.partition(resolved); + }, + + /** + * Extracts all values from Ok instances in an array of result promises. + * @param promises The array of result promises to compact. + * @returns A promise that resolves to an array containing only the values from Ok instances. + * @remarks Rejects if any input promise rejects. + * ```ts + * await ResultPromise.compact([Promise.resolve(ok(1)), Promise.resolve(err('error'))]) // [1] + * ``` + */ + async compact(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Result.compact(resolved); + }, + + /** + * Determines whether any result promise in an array resolves to a successful result. + * @param promises The array of result promises to test. + * @returns A promise that resolves to `true` if at least one result is Ok, `false` otherwise. + * @remarks Rejects if any input promise rejects. + * ```ts + * await ResultPromise.some([Promise.resolve(ok(1)), Promise.resolve(err('error'))]) // true + * await ResultPromise.some([Promise.resolve(err('error1')), Promise.resolve(err('error2'))]) // false + * ``` + */ + async some(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Result.some(resolved); + }, + + /** + * Determines whether every result promise in an array resolves to a successful result. + * @param promises The array of result promises to test. + * @returns A promise that resolves to `true` if all results are Ok, `false` otherwise. + * @remarks Rejects if any input promise rejects. + * ```ts + * await ResultPromise.every([Promise.resolve(ok(1)), Promise.resolve(ok(2))]) // true + * await ResultPromise.every([Promise.resolve(ok(1)), Promise.resolve(err('error'))]) // false + * ``` + */ + async every(promises: Promise>[]): Promise { + const resolved = await Promise.all(promises); + return Result.every(resolved); + }, +} as const; diff --git a/src/result/spec/filter.spec.ts b/src/result/spec/filter.spec.ts new file mode 100644 index 0000000..aca215c --- /dev/null +++ b/src/result/spec/filter.spec.ts @@ -0,0 +1,335 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Result, ResultInterface, Ok, Err, ok, err } from '../'; + +// Helper to use the interface signatures which correctly unify T and E +const ri = (r: Result): ResultInterface => r; + +describe('Result.filter - Runtime Tests', () => { + describe('filter with Ok', () => { + it('should return Ok when predicate returns true', () => { + // Arrange + const okResult: ResultInterface = ok(42); + const predicate = vi.fn((x: number) => x > 0); + const errorFactory = vi.fn((x: number) => `${x} is not positive`); + + // Act + const result = okResult.filter(predicate, errorFactory); + + // Assert + expect(predicate).toHaveBeenCalledWith(42); + expect(predicate).toHaveBeenCalledTimes(1); + expect(errorFactory).not.toHaveBeenCalled(); + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it('should return Err with errorFactory result when predicate returns false', () => { + // Arrange + const okResult: ResultInterface = ok(42); + const predicate = vi.fn((x: number) => x < 0); + const errorFactory = vi.fn((x: number) => `${x} is not negative`); + + // Act + const result = okResult.filter(predicate, errorFactory); + + // Assert + expect(predicate).toHaveBeenCalledWith(42); + expect(predicate).toHaveBeenCalledTimes(1); + expect(errorFactory).toHaveBeenCalledWith(42); + expect(errorFactory).toHaveBeenCalledTimes(1); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('42 is not negative'); + } + }); + + it('should work with various predicate conditions', () => { + // Arrange + const testCases = [ + { value: 5, predicate: (x: number) => x > 3, errorFactory: (x: number) => `${x} <= 3`, expected: true }, + { value: 2, predicate: (x: number) => x > 3, errorFactory: (x: number) => `${x} <= 3`, expected: false }, + { value: 'hello', predicate: (x: string) => x.length > 3, errorFactory: (x: string) => `"${x}" too short`, expected: true }, + { value: 'hi', predicate: (x: string) => x.length > 3, errorFactory: (x: string) => `"${x}" too short`, expected: false }, + ]; + + testCases.forEach(({ value, predicate, errorFactory, expected }) => { + // Act + const result = ri(ok(value) as Result).filter( + predicate as (v: typeof value) => boolean, + errorFactory as (v: typeof value) => string + ); + + // Assert + if (expected) { + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toBe(value); + } + } else { + expect(result.isErr()).toBe(true); + } + }); + }); + + it('should handle complex object filtering', () => { + // Arrange + const users = [ + { name: 'Alice', age: 25, active: true }, + { name: 'Bob', age: 17, active: true }, + { name: 'Charlie', age: 30, active: false }, + ]; + + users.forEach((user) => { + // Act + const activeAdult = ri(ok(user) as Result).filter( + (u) => u.active && u.age >= 18, + (u) => `${u.name} is not an active adult` + ); + + // Assert + if (user.name === 'Alice') { + expect(activeAdult.isOk()).toBe(true); + if (activeAdult.isOk()) { + expect(activeAdult.value).toBe(user); + } + } else { + expect(activeAdult.isErr()).toBe(true); + } + }); + }); + + it('should handle type predicate filtering', () => { + // Arrange + const mixedValues: (string | number)[] = [42, 'hello', 100, 'world']; + + mixedValues.forEach((value) => { + // Arrange + const result: ResultInterface = ok(value); + + // Act + const numberResult = result.filter( + (x): x is number => typeof x === 'number', + (x) => `${x} is not a number` + ); + const stringResult = result.filter( + (x): x is string => typeof x === 'string', + (x) => `${x} is not a string` + ); + + // Assert + if (typeof value === 'number') { + expect(numberResult.isOk()).toBe(true); + expect(stringResult.isErr()).toBe(true); + if (numberResult.isOk()) { + expect(numberResult.value).toBe(value); + } + } else { + expect(numberResult.isErr()).toBe(true); + expect(stringResult.isOk()).toBe(true); + if (stringResult.isOk()) { + expect(stringResult.value).toBe(value); + } + } + }); + }); + + it('should handle null and undefined values correctly', () => { + // Arrange + const nullResult: ResultInterface = ok(null); + const undefinedResult: ResultInterface = ok(undefined); + + // Act + const nullFiltered = nullResult.filter((x) => x === null, () => 'not null'); + const undefinedFiltered = undefinedResult.filter((x) => x === undefined, () => 'not undefined'); + const nullRejected = nullResult.filter((x) => x !== null, () => 'was null'); + const undefinedRejected = undefinedResult.filter((x) => x !== undefined, () => 'was undefined'); + + // Assert + expect(nullFiltered.isOk()).toBe(true); + expect(undefinedFiltered.isOk()).toBe(true); + expect(nullRejected.isErr()).toBe(true); + expect(undefinedRejected.isErr()).toBe(true); + }); + }); + + describe('filter with Err', () => { + it('should return Err without calling predicate or errorFactory', () => { + // Arrange + const errResult: ResultInterface = err('original error'); + const predicate = vi.fn((x: number) => x > 0); + const errorFactory = vi.fn((x: number) => `${x} failed`); + + // Act + const result = errResult.filter(predicate, errorFactory); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(errorFactory).not.toHaveBeenCalled(); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('original error'); + } + }); + + it('should return Err for any predicate', () => { + // Arrange + const errResult: ResultInterface = err('error'); + + // Act + const result1 = errResult.filter((x) => x.length > 0, () => 'empty'); + const result2 = errResult.filter((x) => x.length === 0, () => 'not empty'); + const result3 = errResult.filter((x): x is string => typeof x === 'string', () => 'not string'); + + // Assert + expect(result1.isErr()).toBe(true); + expect(result2.isErr()).toBe(true); + expect(result3.isErr()).toBe(true); + }); + }); + + describe('filter chaining', () => { + it('should allow chaining multiple filters', () => { + // Arrange + const okResult: ResultInterface = ok(42); + + // Act + const result = ri(okResult + .filter((x) => x > 0, (x) => `${x} is not positive`)) + .filter((x) => x < 100, (x) => `${x} is too large`); + const finalResult = ri(result) + .filter((x) => x % 2 === 0, (x) => `${x} is not even`); + + // Assert + expect(finalResult.isOk()).toBe(true); + if (finalResult.isOk()) { + expect(finalResult.value).toBe(42); + } + }); + + it('should short-circuit on first failed filter', () => { + // Arrange + const okResult: ResultInterface = ok(42); + const predicate1 = vi.fn((x: number) => x > 0); + const predicate2 = vi.fn((x: number) => x < 10); // This should fail + const predicate3 = vi.fn((x: number) => x % 2 === 0); // This should not be called + const errorFactory1 = vi.fn(() => 'not positive'); + const errorFactory2 = vi.fn((x: number) => `${x} >= 10`); + const errorFactory3 = vi.fn(() => 'not even'); + + // Act + const step1 = ri(okResult.filter(predicate1, errorFactory1)); + const step2 = ri(step1.filter(predicate2, errorFactory2)); + const result = step2.filter(predicate3, errorFactory3); + + // Assert + expect(predicate1).toHaveBeenCalledWith(42); + expect(predicate2).toHaveBeenCalledWith(42); + expect(predicate3).not.toHaveBeenCalled(); + expect(errorFactory1).not.toHaveBeenCalled(); + expect(errorFactory2).toHaveBeenCalledWith(42); + expect(errorFactory3).not.toHaveBeenCalled(); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('42 >= 10'); + } + }); + }); + + describe('filter error handling', () => { + it('should propagate errors from predicate function', () => { + // Arrange + const okResult: ResultInterface = ok(42); + const error = new Error('Predicate error'); + const predicate = vi.fn(() => { + throw error; + }); + + // Act & Assert + expect(() => { + okResult.filter(predicate, () => 'error'); + }).toThrow('Predicate error'); + + expect(predicate).toHaveBeenCalledWith(42); + }); + + it('should propagate errors from errorFactory function', () => { + // Arrange + const okResult: ResultInterface = ok(42); + const error = new Error('ErrorFactory error'); + + // Act & Assert + expect(() => { + okResult.filter(() => false, () => { throw error; }); + }).toThrow('ErrorFactory error'); + }); + + it('should not call predicate or errorFactory on Err even if they would throw', () => { + // Arrange + const errResult: ResultInterface = err('error'); + const predicate = vi.fn(() => { + throw new Error('Should not be called'); + }); + const errorFactory = vi.fn(() => { + throw new Error('Should not be called'); + }); + + // Act + const result = errResult.filter(predicate, errorFactory); + + // Assert + expect(predicate).not.toHaveBeenCalled(); + expect(errorFactory).not.toHaveBeenCalled(); + expect(result.isErr()).toBe(true); + }); + }); + + describe('filter with edge cases', () => { + it('should handle falsy values correctly', () => { + // Arrange + const falsyValues = [0, false, '', NaN]; + + falsyValues.forEach((value) => { + // Arrange + const result: ResultInterface = ok(value); + + // Act + const truthyResult = result.filter((x) => !!x, () => 'falsy'); + const falsyResult = result.filter((x) => !x, () => 'truthy'); + + // Assert + expect(truthyResult.isErr()).toBe(true); + expect(falsyResult.isOk()).toBe(true); + if (falsyResult.isOk()) { + expect(falsyResult.value).toBe(value); + } + }); + }); + + it('should handle complex nested structures', () => { + // Arrange + const complexObject = { + data: { + items: [ + { id: 1, name: 'Item 1', tags: ['tag1', 'tag2'] }, + { id: 2, name: 'Item 2', tags: ['tag3'] }, + ], + }, + }; + const result: ResultInterface = ok(complexObject); + + // Act + const filtered = result.filter( + (obj) => obj.data.items.length > 0 && obj.data.items.every((item) => item.tags.length > 0), + () => 'validation failed' + ); + + // Assert + expect(filtered.isOk()).toBe(true); + if (filtered.isOk()) { + expect(filtered.value).toBe(complexObject); + } + }); + }); +}); diff --git a/src/result/spec/filter.type-spec.ts b/src/result/spec/filter.type-spec.ts new file mode 100644 index 0000000..48a7ae3 --- /dev/null +++ b/src/result/spec/filter.type-spec.ts @@ -0,0 +1,236 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, ResultInterface, Ok, Err, ok, err } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const ri = (r: Result) => r as unknown as ResultInterface; + +describe("Result.filter - Type Tests", () => { + describe("filter directly on Result union", () => { + it("should accept filter with errorFactory without ri() helper", () => { + const result: Result = ok(42) as Result; + const filtered = result.filter(x => x > 0, () => "not positive"); + expectTypeOf(filtered).toEqualTypeOf | Err>(); + }); + + it("should work with type predicate directly on union", () => { + const result: Result = ok(42) as Result; + const filtered = result.filter( + (x): x is number => typeof x === "number", + () => "not a number" + ); + expectTypeOf(filtered).toEqualTypeOf | Err>(); + }); + + it("should return Ok | Err on known Ok with generic errorFactory", () => { + const filtered = ok(42).filter(x => x > 0, x => `${x} is too small`); + expectTypeOf(filtered).toEqualTypeOf | Err>(); + }); + }); + + describe("filter with type predicate", () => { + it("should narrow type with type predicate", () => { + // Arrange + const unionResult = ri(ok(42) as Result); + + // Act + const numberResult = unionResult.filter( + (x): x is number => typeof x === "number", + (x) => `${x} is not a number` + ); + const stringResult = unionResult.filter( + (x): x is string => typeof x === "string", + (x) => `${x} is not a string` + ); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf>(); + expectTypeOf(stringResult).toEqualTypeOf>(); + }); + + it("should handle complex type predicates", () => { + // Arrange + interface User { type: "user"; name: string; } + interface Admin { type: "admin"; name: string; permissions: string[]; } + type Person = User | Admin; + + const personResult = ri(ok({ type: "user", name: "John" }) as Result); + + // Act + const adminResult = personResult.filter( + (p): p is Admin => p.type === "admin", + (p) => `${p.name} is not an admin` + ); + const userResult = personResult.filter( + (p): p is User => p.type === "user", + (p) => `${p.name} is not a user` + ); + + // Assert + expectTypeOf(adminResult).toEqualTypeOf>(); + expectTypeOf(userResult).toEqualTypeOf>(); + }); + + it("should handle nullable type predicates", () => { + // Arrange + const nullableResult = ri(ok("hello") as Result); + + // Act + const nonNullResult = nullableResult.filter( + (x): x is string => x !== null, + () => "was null" + ); + + // Assert + expectTypeOf(nonNullResult).toEqualTypeOf>(); + }); + + it("should handle array type predicates", () => { + // Arrange + const arrayResult = ri(ok([1, 2, 3]) as Result); + + // Act + const numberArrayResult = arrayResult.filter( + (arr): arr is number[] => arr.every(item => typeof item === "number"), + () => "not all numbers" + ); + + // Assert + expectTypeOf(numberArrayResult).toEqualTypeOf>(); + }); + }); + + describe("filter with boolean predicate", () => { + it("should preserve original type with boolean predicate", () => { + // Arrange + const numberResult = ri(ok(42) as Result); + const stringResult = ri(ok("hello") as Result); + + // Act + const filteredNumber = numberResult.filter(x => x > 0, () => "not positive"); + const filteredString = stringResult.filter(x => x.length > 0, () => "empty"); + + // Assert + expectTypeOf(filteredNumber).toEqualTypeOf>(); + expectTypeOf(filteredString).toEqualTypeOf>(); + }); + + it("should work with complex boolean predicates", () => { + // Arrange + const objectResult = ri<{ id: number; name: string; active: boolean }, string>( + ok({ id: 1, name: "test", active: true }) as Result<{ id: number; name: string; active: boolean }, string> + ); + + // Act + const filteredObject = objectResult.filter( + obj => obj.active && obj.name.length > 0, + (obj) => `${obj.name} is invalid` + ); + + // Assert + expectTypeOf(filteredObject).toEqualTypeOf>(); + }); + }); + + describe("filter with Err", () => { + it("should preserve Err type regardless of predicate", () => { + // Arrange + const errResult = ri(err("error") as Result); + + // Act + const filteredWithBoolean = errResult.filter(x => x > 0, () => "not positive"); + const filteredWithTypePredicate = errResult.filter( + (x): x is number => typeof x === "number", + () => "not a number" + ); + + // Assert + expectTypeOf(filteredWithBoolean).toEqualTypeOf>(); + expectTypeOf(filteredWithTypePredicate).toEqualTypeOf>(); + }); + }); + + describe("filter function signature validation", () => { + it("should be callable with correct predicate and errorFactory signatures", () => { + // Arrange + const result = ri(ok(42) as Result); + const booleanPredicate = (x: number) => x > 0; + const typePredicate = (x: number): x is number => typeof x === "number"; + const errorFactory = (x: number) => `${x} failed validation`; + + // Assert + expectTypeOf(result.filter).toBeCallableWith(booleanPredicate, errorFactory); + expectTypeOf(result.filter).toBeCallableWith(typePredicate, errorFactory); + }); + + it("should not be callable with incorrect predicate signatures", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Assert + // @ts-expect-error - wrong parameter type for predicate + result.filter((x: string) => x.length > 0, () => "error"); + }); + + it("should not be callable without errorFactory", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Assert + // @ts-expect-error - missing errorFactory argument + result.filter((x: number) => x > 0); + }); + + it("should not be callable without any arguments", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Assert + // @ts-expect-error - missing all arguments + result.filter(); + }); + }); + + describe("filter overload resolution", () => { + it("should resolve to type predicate overload when type predicate is provided", () => { + // Arrange + const unionResult = ri(ok(42) as Result); + + // Act + const result = unionResult.filter( + (x): x is number => typeof x === "number", + (x) => `${x} is not a number` + ); + + // Assert - should be Result, not Result + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should resolve to boolean predicate overload when boolean predicate is provided", () => { + // Arrange + const numberResult = ri(ok(42) as Result); + + // Act + const result = numberResult.filter(x => x > 0, () => "not positive"); + + // Assert - should preserve original type + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("filter with generic constraints", () => { + it("should work with generic type constraints", () => { + // Arrange + interface Lengthable { length: number; } + const lengthableResult = ri(ok("hello") as Result); + + // Act + const result = lengthableResult.filter( + (x): x is string => typeof x === "string", + () => "not a string" + ); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/result/spec/flatMap.spec.ts b/src/result/spec/flatMap.spec.ts new file mode 100644 index 0000000..4834ff9 --- /dev/null +++ b/src/result/spec/flatMap.spec.ts @@ -0,0 +1,330 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Result, ResultInterface, Ok, Err, ok, err } from '../'; + +// Helper to use the interface signatures which correctly unify T and E +const ri = (r: Result): ResultInterface => r; + +describe('Result flatMap and flatMapErr - Runtime Tests', () => { + describe('flatMap method', () => { + describe('when Result is Ok', () => { + it('should apply function and flatten the result when function returns Ok', () => { + // Arrange + const result: ResultInterface = ok(5); + const flatMapper = (x: number): Result => ok(x * 2); + + // Act + const flatMapped = result.flatMap(flatMapper); + + // Assert + expect(flatMapped.isOk()).toBe(true); + if (flatMapped.isOk()) { + expect(flatMapped.value).toBe(10); + } + }); + + it('should return Err when function returns Err', () => { + // Arrange + const result: ResultInterface = ok(5); + const flatMapper = (_x: number): Result => err('failed'); + + // Act + const flatMapped = result.flatMap(flatMapper); + + // Assert + expect(flatMapped.isErr()).toBe(true); + if (flatMapped.isErr()) { + expect(flatMapped.err).toBe('failed'); + } + }); + + it('should handle conditional logic in flatMapper', () => { + // Arrange + const positiveResult: ResultInterface = ok(5); + const negativeResult: ResultInterface = ok(-3); + const flatMapper = (x: number): Result => + x > 0 ? ok(x * 2) : err('negative value'); + + // Act + const positiveOutput = positiveResult.flatMap(flatMapper); + const negativeOutput = negativeResult.flatMap(flatMapper); + + // Assert + expect(positiveOutput.isOk()).toBe(true); + expect(negativeOutput.isErr()).toBe(true); + if (positiveOutput.isOk()) { + expect(positiveOutput.value).toBe(10); + } + if (negativeOutput.isErr()) { + expect(negativeOutput.err).toBe('negative value'); + } + }); + + it('should handle type transformations', () => { + // Arrange + const stringResult: ResultInterface = ok('42'); + const parseNumber = (str: string): Result => { + const num = parseInt(str); + return isNaN(num) ? err('parse error') : ok(num); + }; + + // Act + const validOutput = stringResult.flatMap(parseNumber); + const invalidResult: ResultInterface = ok('invalid'); + const invalidOutput = invalidResult.flatMap(parseNumber); + + // Assert + expect(validOutput.isOk()).toBe(true); + expect(invalidOutput.isErr()).toBe(true); + if (validOutput.isOk()) { + expect(validOutput.value).toBe(42); + } + if (invalidOutput.isErr()) { + expect(invalidOutput.err).toBe('parse error'); + } + }); + + it('should work with complex business logic', () => { + // Arrange + type User = { id: number; email: string; verified: boolean }; + type AuthToken = { token: string; userId: number }; + + const user: User = { id: 1, email: 'test@example.com', verified: true }; + const userResult: ResultInterface = ok(user); + + const generateToken = (u: User): Result => { + return u.verified + ? ok({ token: `token_${u.id}`, userId: u.id }) + : err('User not verified'); + }; + + // Act + const tokenOutput = userResult.flatMap(generateToken); + const unverifiedResult: ResultInterface = ok({ ...user, verified: false }); + const unverifiedOutput = unverifiedResult.flatMap(generateToken); + + // Assert + expect(tokenOutput.isOk()).toBe(true); + expect(unverifiedOutput.isErr()).toBe(true); + if (tokenOutput.isOk()) { + expect(tokenOutput.value).toEqual({ token: 'token_1', userId: 1 }); + } + if (unverifiedOutput.isErr()) { + expect(unverifiedOutput.err).toBe('User not verified'); + } + }); + + it('should not mutate the original result', () => { + // Arrange + const original = ok({ value: 10 }); + const originalValue = original.value; + + // Act + const flatMapped = original.flatMap(obj => ok({ ...obj, value: obj.value * 2 })); + + // Assert + expect(original.value).toBe(originalValue); + expect(original.value.value).toBe(10); + if (flatMapped.isOk()) { + expect(flatMapped.value.value).toBe(20); + } + }); + }); + + describe('when Result is Err', () => { + it('should return Err without calling the flatMapper function', () => { + // Arrange + const errResult: ResultInterface = err('error'); + const flatMapperSpy = vi.fn((x: number): Result => ok(x * 2)); + + // Act + const result = errResult.flatMap(flatMapperSpy); + + // Assert + expect(result.isErr()).toBe(true); + expect(flatMapperSpy).not.toHaveBeenCalled(); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + + it('should preserve Err through multiple flatMap operations', () => { + // Arrange + const errResult: ResultInterface = err('original error'); + + // Act + const step1 = ri(errResult.flatMap((x): Result => ok(x * 2))); + const step2 = ri(step1.flatMap((x): Result => ok(x.toString()))); + const result = step2.flatMap((s): Result => ok(s.length)); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('original error'); + } + }); + }); + }); + + describe('flatMapErr method', () => { + describe('when Result is Err', () => { + it('should apply function and flatten the result when function returns Err', () => { + // Arrange + const result = err('error'); + const flatMapper = (e: string) => err(e.toUpperCase()); + + // Act + const flatMapped = result.flatMapErr(flatMapper); + + // Assert + expect(flatMapped.isErr()).toBe(true); + if (flatMapped.isErr()) { + expect(flatMapped.err).toBe('ERROR'); + } + }); + + it('should return Ok when function returns Ok (recovery)', () => { + // Arrange + const result: ResultInterface = err('error'); + const flatMapper = (_e: string): Result => ok('recovered'); + + // Act + const flatMapped = result.flatMapErr(flatMapper); + + // Assert + expect(flatMapped.isOk()).toBe(true); + if (flatMapped.isOk()) { + expect(flatMapped.value).toBe('recovered'); + } + }); + + it('should handle conditional recovery logic', () => { + // Arrange + const recoverableErr: ResultInterface = err('timeout'); + const fatalErr: ResultInterface = err('fatal'); + const recover = (e: string): Result => + e === 'timeout' ? ok(0) : err(`unrecoverable: ${e}`); + + // Act + const recovered = recoverableErr.flatMapErr(recover); + const notRecovered = fatalErr.flatMapErr(recover); + + // Assert + expect(recovered.isOk()).toBe(true); + expect(notRecovered.isErr()).toBe(true); + if (recovered.isOk()) expect(recovered.value).toBe(0); + if (notRecovered.isErr()) expect(notRecovered.err).toBe('unrecoverable: fatal'); + }); + + it('should handle error type transformations', () => { + // Arrange + const result = err('not found'); + const transform = (e: string) => err({ code: 404, message: e }); + + // Act + const transformed = result.flatMapErr(transform); + + // Assert + expect(transformed.isErr()).toBe(true); + if (transformed.isErr()) { + expect(transformed.err).toEqual({ code: 404, message: 'not found' }); + } + }); + }); + + describe('when Result is Ok', () => { + it('should return Ok without calling the flatMapper function', () => { + // Arrange + const okResult: ResultInterface = ok(42); + const flatMapperSpy = vi.fn((e: string): Result => err(e.toUpperCase())); + + // Act + const result = okResult.flatMapErr(flatMapperSpy); + + // Assert + expect(result.isOk()).toBe(true); + expect(flatMapperSpy).not.toHaveBeenCalled(); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it('should preserve Ok through multiple flatMapErr operations', () => { + // Arrange + const okResult: ResultInterface = ok(42); + + // Act + const step1 = ri(okResult.flatMapErr((e): Result => err(e.toUpperCase()))); + const step2 = ri(step1.flatMapErr((e): Result => err(e.length))); + const result = step2.flatMapErr((n): Result => err(`Error length: ${n}`)); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + }); + }); + + describe('flatMap and flatMapErr integration', () => { + it('should work together in complex chains', () => { + // Arrange + const parseAndValidate = (input: string): Result => { + const num = parseInt(input); + if (isNaN(num)) return err('parse error'); + if (num < 0) return err('negative'); + return ok(num); + }; + + // Act + const successInput: ResultInterface = ok('42'); + const successResult = ri(successInput.flatMap(parseAndValidate)) + .flatMapErr((e): Result => err(`Validation failed: ${e}`)); + + const failInput: ResultInterface = ok('invalid'); + const failResult = ri(failInput.flatMap(parseAndValidate)) + .flatMapErr((e): Result => err(`Validation failed: ${e}`)); + + // Assert + expect(successResult.isOk()).toBe(true); + if (successResult.isOk()) expect(successResult.value).toBe(42); + + expect(failResult.isErr()).toBe(true); + if (failResult.isErr()) expect(failResult.err).toBe('Validation failed: parse error'); + }); + + it('should short-circuit on first Err in flatMap chain', () => { + // Arrange + const mapSpy = vi.fn((x: string): Result => ok(x.toUpperCase())); + const flatMapSpy = vi.fn((x: string): Result => ok(x.length)); + + // Act + const input: ResultInterface = ok('test'); + const step1 = ri(input.flatMap((_x): Result => err('stop here'))); + const step2 = ri(step1.flatMap(mapSpy)); + const result = step2.flatMap(flatMapSpy); + + // Assert + expect(result.isErr()).toBe(true); + expect(mapSpy).not.toHaveBeenCalled(); + expect(flatMapSpy).not.toHaveBeenCalled(); + }); + + it('should handle edge cases with falsy values', () => { + // Arrange + const emptyStringResult: ResultInterface = ok(''); + const zeroResult: ResultInterface = ok(0); + const falseResult: ResultInterface = ok(false); + + // Act + const stringOutput = emptyStringResult.flatMap((s): Result => ok(s.length)); + const numberOutput = zeroResult.flatMap((n): Result => ok(n.toString())); + const booleanOutput = falseResult.flatMap((b): Result => ok(!b)); + + // Assert + if (stringOutput.isOk()) expect(stringOutput.value).toBe(0); + if (numberOutput.isOk()) expect(numberOutput.value).toBe('0'); + if (booleanOutput.isOk()) expect(booleanOutput.value).toBe(true); + }); + }); +}); diff --git a/src/result/spec/flatMap.type-spec.ts b/src/result/spec/flatMap.type-spec.ts new file mode 100644 index 0000000..9d2e4fa --- /dev/null +++ b/src/result/spec/flatMap.type-spec.ts @@ -0,0 +1,215 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, ResultInterface, Ok, Err, ok, err } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const ri = (r: Result) => r as unknown as ResultInterface; + +describe("Result flatMap and flatMapErr - Type Tests", () => { + describe("flatMap directly on Result union", () => { + it("should accept callbacks returning Result without ri() helper", () => { + const result: Result = ok(42) as Result; + const flatMapped = result.flatMap(x => x > 0 ? ok(x * 2) : err("negative")); + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should infer correct type when callback returns only Ok", () => { + const result: Result = ok(42) as Result; + const flatMapped = result.flatMap(x => ok(x.toString())); + expectTypeOf(flatMapped).toEqualTypeOf | Err>(); + }); + + it("should return exact callback type on known Ok via R pattern", () => { + const flatMapped = ok(42).flatMap(x => ok(x.toString())); + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + }); + + describe("flatMapErr directly on Result union", () => { + it("should accept callbacks returning Result without ri() helper", () => { + const result: Result = err("fail") as Result; + const flatMapped = result.flatMapErr(e => e === "retry" ? ok(0) : err(e.length)); + expectTypeOf(flatMapped).toEqualTypeOf | Ok | Err>(); + }); + + it("should return exact callback type on known Err via R pattern", () => { + const flatMapped = err("fail").flatMapErr(e => err(e.length)); + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + }); + + describe("flatMap method", () => { + it("should flatten Ok with function returning Result", () => { + // Arrange + const numberResult = ri(ok(42) as Result); + + // Act + const flatMappedOk = numberResult.flatMap(x => ok(x * 2)); + const flatMappedErr = numberResult.flatMap(x => err("error") as Result); + + // Assert + expectTypeOf(flatMappedOk).toEqualTypeOf>(); + expectTypeOf(flatMappedErr).toEqualTypeOf>(); + }); + + it("should handle Err input correctly", () => { + // Arrange + const errResult = ri(err("error") as Result); + + // Act + const flatMapped = errResult.flatMap(x => ok(x * 2) as Result); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should transform types correctly with flatMap", () => { + // Arrange + const stringResult = ri(ok("42") as Result); + + // Act + const parsed = stringResult.flatMap(str => { + const num = parseInt(str); + return isNaN(num) ? err("parse error") : ok(num); + }); + + // Assert + expectTypeOf(parsed).toEqualTypeOf>(); + }); + + it("should handle complex type transformations", () => { + // Arrange + type ParseResult = { value: number; valid: boolean }; + const inputResult = ri(ok("123") as Result); + + // Act + const result = inputResult.flatMap((str): Result => { + const num = parseInt(str); + return isNaN(num) + ? err("invalid number") + : ok({ value: num, valid: true }); + }); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should work with chained flatMap operations", () => { + // Arrange + const initialResult = ri(ok(5) as Result); + + // Act + const chained = ri(initialResult + .flatMap(x => x > 0 ? ok(x * 2) : err("negative") as Result)) + .flatMap(x => x < 20 ? ok(x.toString()) : err("too large") as Result); + + // Assert + expectTypeOf(chained).toEqualTypeOf>(); + }); + + it("should be callable with correct flatMapper function signatures", () => { + // Arrange + const numberResult = ri(ok(42) as Result); + + // Assert + expectTypeOf(numberResult.flatMap).toBeCallableWith((x: number) => ok(x * 2) as Result); + expectTypeOf(numberResult.flatMap).toBeCallableWith((x: number) => err("error") as Result); + }); + }); + + describe("flatMapErr method", () => { + it("should flatten Err with function returning Result", () => { + // Arrange + const errResult = ri(err("error") as Result); + + // Act + const flatMappedErr = errResult.flatMapErr(e => err(e.toUpperCase())); + const flatMappedOk = errResult.flatMapErr(e => ok("recovered") as Result); + + // Assert + expectTypeOf(flatMappedErr).toEqualTypeOf>(); + expectTypeOf(flatMappedOk).toEqualTypeOf>(); + }); + + it("should handle Ok input correctly", () => { + // Arrange + const okResult = ri(ok(42) as Result); + + // Act + const flatMapped = okResult.flatMapErr(e => err(e.length) as Result); + + // Assert + expectTypeOf(flatMapped).toEqualTypeOf>(); + }); + + it("should handle error type transformations", () => { + // Arrange + type ApiError = { code: number; message: string }; + type DisplayError = { title: string }; + const errResult = ri(err({ code: 404, message: "Not found" }) as Result); + + // Act + const transformed = errResult.flatMapErr((e): Result => + err({ title: `Error ${e.code}` }) + ); + + // Assert + expectTypeOf(transformed).toEqualTypeOf>(); + }); + + it("should handle recovery (Err -> Ok)", () => { + // Arrange + const errResult = ri(err("timeout") as Result); + + // Act + const recovered = errResult.flatMapErr((_e): Result => ok(0)); + + // Assert + expectTypeOf(recovered).toEqualTypeOf>(); + }); + + it("should be callable with correct flatMapper function signatures", () => { + // Arrange + const errResult = ri(err("error") as Result); + + // Assert + expectTypeOf(errResult.flatMapErr).toBeCallableWith((e: string) => err(e.length) as Result); + expectTypeOf(errResult.flatMapErr).toBeCallableWith((e: string) => ok(42) as Result); + }); + }); + + describe("flatMap and flatMapErr interaction", () => { + it("should work together in chains with correct type inference", () => { + // Arrange + const result = ri(ok(5) as Result); + + // Act + const chained = ri(result + .flatMap(x => x > 0 ? ok(x.toString()) : err("negative") as Result)) + .flatMapErr(e => err({ message: e }) as Result); + + // Assert + expectTypeOf(chained).toEqualTypeOf>(); + }); + + it("should handle mixed transformations correctly", () => { + // Arrange + type Input = { value: string }; + type Output = { parsed: number }; + + const result = ri(ok({ value: "42" }) as Result); + + // Act + const transformed = ri(result + .flatMap((input): Result => { + const num = parseInt(input.value); + return isNaN(num) ? err("parse error") : ok({ parsed: num }); + })) + .flatMapErr((e): Result => + err({ code: 400, message: e }) + ); + + // Assert + expectTypeOf(transformed).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/result/spec/is.spec.ts b/src/result/spec/is.spec.ts new file mode 100644 index 0000000..e540197 --- /dev/null +++ b/src/result/spec/is.spec.ts @@ -0,0 +1,330 @@ +import { describe, it, expect } from 'vitest'; +import { Result, Ok, Err, ok, err } from '../'; + +describe('Result isOk/isErr - Runtime Tests', () => { + describe('isOk() method', () => { + it('should return true for Ok instances', () => { + // Arrange + const testCases = [ + ok(42), + ok('hello'), + ok(true), + ok({ id: 1 }), + ok([1, 2, 3]), + ok(null), + ok(undefined), + ok(0), + ok(false), + ok('') + ]; + + testCases.forEach(result => { + // Act & Assert + expect(result.isOk()).toBe(true); + }); + }); + + it('should return false for Err instances', () => { + // Arrange + const errResult = err('error'); + + // Act & Assert + expect(errResult.isOk()).toBe(false); + }); + + it('should enable access to value property after type narrowing', () => { + // Arrange + const result: Result = ok('hello') as Result; + + // Act & Assert + if (result.isOk()) { + expect(result.value).toBe('hello'); + expect(typeof result.value).toBe('string'); + } else { + fail('Expected result to be Ok'); + } + }); + + it('should work with complex objects', () => { + // Arrange + const complexObject = { + id: 1, + name: 'test', + nested: { data: [1, 2, 3] }, + fn: (x: number) => x * 2 + }; + const result: Result = ok(complexObject) as Result; + + // Act & Assert + if (result.isOk()) { + expect(result.value).toBe(complexObject); + expect(result.value.id).toBe(1); + expect(result.value.name).toBe('test'); + expect(result.value.nested.data).toEqual([1, 2, 3]); + expect(result.value.fn(5)).toBe(10); + } else { + fail('Expected result to be Ok'); + } + }); + + it('should work with falsy values that are still Ok', () => { + // Arrange + const falsy: Result[] = [ + ok(0) as Result, + ok(false) as Result, + ok('') as Result, + ok(null) as Result, + ok(undefined) as Result + ]; + + falsy.forEach(result => { + // Act & Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value !== undefined || result.value === undefined).toBe(true); + } + }); + }); + }); + + describe('isErr() method', () => { + it('should return true for Err instances', () => { + // Arrange + const errResult = err('error'); + const errAsResult: Result = err('error') as Result; + + // Act & Assert + expect(errResult.isErr()).toBe(true); + expect(errAsResult.isErr()).toBe(true); + }); + + it('should return false for Ok instances', () => { + // Arrange + const testCases = [ + ok(42), + ok('hello'), + ok(true), + ok({ id: 1 }), + ok([1, 2, 3]), + ok(null), + ok(undefined), + ok(0), + ok(false), + ok('') + ]; + + testCases.forEach(result => { + // Act & Assert + expect(result.isErr()).toBe(false); + }); + }); + + it('should enable access to err property after type narrowing', () => { + // Arrange + const result: Result = err('something failed') as Result; + + // Act & Assert + if (result.isErr()) { + expect(result.err).toBe('something failed'); + expect(typeof result.err).toBe('string'); + } else { + fail('Expected result to be Err'); + } + }); + + it('should work with different error types', () => { + // Arrange + const stringErr: Result = err('error') as Result; + const numberErr: Result = err(404) as Result; + const objectErr: Result = err({ code: 'ERR' }) as Result; + + // Act & Assert + expect(stringErr.isErr()).toBe(true); + expect(numberErr.isErr()).toBe(true); + expect(objectErr.isErr()).toBe(true); + }); + }); + + describe('Mutual exclusivity', () => { + it('should be mutually exclusive for Ok instances', () => { + // Arrange + const testCases = [ + ok(42), + ok('hello'), + ok(null), + ok(undefined), + ok(false), + ok(0) + ]; + + testCases.forEach(result => { + // Act & Assert + expect(result.isOk()).toBe(true); + expect(result.isErr()).toBe(false); + expect(result.isOk() && result.isErr()).toBe(false); + }); + }); + + it('should be mutually exclusive for Err instances', () => { + // Arrange + const testCases = [ + err('error'), + err(404), + err(null), + err(undefined), + err(new Error('test')) + ]; + + testCases.forEach(result => { + // Act & Assert + expect(result.isOk()).toBe(false); + expect(result.isErr()).toBe(true); + expect(result.isOk() || result.isErr()).toBe(true); + }); + }); + }); + + describe('Type narrowing in control flow', () => { + it('should enable type-safe value access in if statements', () => { + // Arrange + const result: Result<{ name: string; age: number }, string> = ok({ name: 'John', age: 30 }) as Result<{ name: string; age: number }, string>; + + // Act & Assert + if (result.isOk()) { + expect(result.value.name).toBe('John'); + expect(result.value.age).toBe(30); + expect(typeof result.value.name).toBe('string'); + expect(typeof result.value.age).toBe('number'); + } else { + fail('Expected result to be Ok'); + } + }); + + it('should work with early returns', () => { + // Arrange + function processResult(result: Result): string { + if (result.isErr()) { + return `error: ${result.err}`; + } + return `value: ${result.value}`; + } + + const okResult: Result = ok(42) as Result; + const errResult: Result = err('failed') as Result; + + // Act + const okOutput = processResult(okResult); + const errOutput = processResult(errResult); + + // Assert + expect(okOutput).toBe('value: 42'); + expect(errOutput).toBe('error: failed'); + }); + + it('should work in complex conditional logic', () => { + // Arrange + const result1: Result = ok(10) as Result; + const result2: Result = ok('test') as Result; + const result3: Result = err('no value') as Result; + + // Act & Assert + if (result1.isOk() && result2.isOk()) { + expect(result1.value + result2.value.length).toBe(14); + } else { + fail('Expected both results to be Ok'); + } + + if (result1.isOk() && result3.isErr()) { + expect(result1.value).toBe(10); + expect(result3.err).toBe('no value'); + } else { + fail('Expected result1 to be Ok and result3 to be Err'); + } + }); + + it('should work with negated conditions', () => { + // Arrange + const okResult: Result = ok('hello') as Result; + const errResult: Result = err(404) as Result; + + // Act & Assert + if (!okResult.isErr()) { + expect(okResult.value).toBe('hello'); + } else { + fail('Expected result to be Ok'); + } + + if (!errResult.isOk()) { + expect(errResult.isErr()).toBe(true); + } else { + fail('Expected result to be Err'); + } + }); + }); + + describe('Performance and consistency', () => { + it('should return consistent results across multiple calls', () => { + // Arrange + const okResult = ok(42); + const errResult = err('error'); + + // Act & Assert + expect(okResult.isOk()).toBe(okResult.isOk()); + expect(okResult.isErr()).toBe(okResult.isErr()); + expect(errResult.isOk()).toBe(errResult.isOk()); + expect(errResult.isErr()).toBe(errResult.isErr()); + }); + + it('should be callable without side effects', () => { + // Arrange + const result = ok({ counter: 0 }); + + // Act + const before = result.value.counter; + result.isOk(); + result.isErr(); + result.isOk(); + const after = result.value.counter; + + // Assert + expect(before).toBe(after); + }); + }); + + describe('Edge cases', () => { + it('should handle deeply nested objects', () => { + // Arrange + const deepObject = { + level1: { + level2: { + level3: { + value: 'deep' + } + } + } + }; + const result: Result = ok(deepObject) as Result; + + // Act & Assert + if (result.isOk()) { + expect(result.value.level1.level2.level3.value).toBe('deep'); + } else { + fail('Expected result to be Ok'); + } + }); + + it('should handle Error objects as error values', () => { + // Arrange + const error = new Error('Test error'); + const result: Result = err(error) as Result; + + // Act & Assert + if (result.isErr()) { + expect(result.err).toBeInstanceOf(Error); + expect(result.err.message).toBe('Test error'); + } else { + fail('Expected result to be Err'); + } + }); + }); +}); diff --git a/src/result/spec/is.type-spec.ts b/src/result/spec/is.type-spec.ts new file mode 100644 index 0000000..57f3d85 --- /dev/null +++ b/src/result/spec/is.type-spec.ts @@ -0,0 +1,188 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, Ok, Err, ok, err } from "../"; + +describe("Result isOk/isErr - Type Tests", () => { + describe("isOk() type guard", () => { + it("should act as type guard narrowing Result to Ok", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act & Assert + if (result.isOk()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.value).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is Ok'", () => { + // Arrange + const numberResult: Result = ok(42) as Result; + const stringResult: Result = ok("hello") as Result; + const objectResult: Result<{ id: number }, string> = ok({ id: 1 }) as Result<{ id: number }, string>; + + // Act + const numberCheck = numberResult.isOk(); + const stringCheck = stringResult.isOk(); + const objectCheck = objectResult.isOk(); + + // Assert + expectTypeOf(numberCheck).toEqualTypeOf(); + expectTypeOf(stringCheck).toEqualTypeOf(); + expectTypeOf(objectCheck).toEqualTypeOf(); + }); + + it("should enable access to value property after type narrowing", () => { + // Arrange + const result: Result<{ name: string; age: number }, string> = ok({ name: "John", age: 30 }) as Result<{ name: string; age: number }, string>; + + // Act & Assert + if (result.isOk()) { + expectTypeOf(result.value).toEqualTypeOf<{ name: string; age: number }>(); + expectTypeOf(result.value.name).toEqualTypeOf(); + expectTypeOf(result.value.age).toEqualTypeOf(); + } + }); + + it("should work with complex generic types", () => { + // Arrange + type ComplexType = Array<{ id: number; tags: string[] }>; + const result: Result = ok([{ id: 1, tags: ["a", "b"] }]) as Result; + + // Act & Assert + if (result.isOk()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.value).toEqualTypeOf(); + expectTypeOf(result.value[0]).toEqualTypeOf<{ id: number; tags: string[] }>(); + } + }); + + it("should work with union types", () => { + // Arrange + const result: Result = ok("hello") as Result; + + // Act & Assert + if (result.isOk()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.value).toEqualTypeOf(); + } + }); + }); + + describe("isErr() type guard", () => { + it("should act as type guard narrowing Result to Err", () => { + // Arrange + const result: Result = err("error") as Result; + + // Act & Assert + if (result.isErr()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.err).toEqualTypeOf(); + } + }); + + it("should return type predicate 'this is Err'", () => { + // Arrange + const stringErrResult: Result = err("error") as Result; + const numberErrResult: Result = err(404) as Result; + const objectErrResult: Result = err({ code: "ERR" }) as Result; + + // Act + const stringCheck = stringErrResult.isErr(); + const numberCheck = numberErrResult.isErr(); + const objectCheck = objectErrResult.isErr(); + + // Assert + expectTypeOf(stringCheck).toEqualTypeOf(); + expectTypeOf(numberCheck).toEqualTypeOf(); + expectTypeOf(objectCheck).toEqualTypeOf(); + }); + + it("should narrow to Err with correct error type", () => { + // Arrange + const result: Result = err({ code: 500, message: "Internal" }) as Result; + + // Act & Assert + if (result.isErr()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.err).toEqualTypeOf<{ code: number; message: string }>(); + expectTypeOf(result.err.code).toEqualTypeOf(); + expectTypeOf(result.err.message).toEqualTypeOf(); + } + }); + }); + + describe("Type narrowing patterns", () => { + it("should enable exhaustive type checking with if-else", () => { + // Arrange + const result: Result = ok("test") as Result; + + // Act & Assert + if (result.isOk()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.value).toEqualTypeOf(); + } else { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.err).toEqualTypeOf(); + } + }); + + it("should work with early returns", () => { + // Arrange + function processResult(result: Result): number { + if (result.isErr()) { + return 0; + } + return result.value * 2; + } + + const testResult: Result = ok(5) as Result; + const output = processResult(testResult); + + // Assert + expectTypeOf(output).toEqualTypeOf(); + }); + + it("should work in complex conditional logic", () => { + // Arrange + const result1: Result = ok(1) as Result; + const result2: Result = ok("test") as Result; + + // Act & Assert + if (result1.isOk() && result2.isOk()) { + expectTypeOf(result1).toEqualTypeOf>(); + expectTypeOf(result2).toEqualTypeOf>(); + expectTypeOf(result1.value).toEqualTypeOf(); + expectTypeOf(result2.value).toEqualTypeOf(); + } + }); + + it("should work with negated conditions", () => { + // Arrange + const result: Result = ok(true) as Result; + + // Act & Assert + if (!result.isErr()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.value).toEqualTypeOf(); + } + + if (!result.isOk()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.err).toEqualTypeOf(); + } + }); + }); + + describe("Method chaining with type narrowing", () => { + it("should preserve type information in method chains", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act & Assert + if (result.isOk()) { + const mapped = result.map(x => x.toString()); + expectTypeOf(mapped).toEqualTypeOf>(); + } + }); + }); +}); diff --git a/src/result/spec/map.spec.ts b/src/result/spec/map.spec.ts new file mode 100644 index 0000000..5b74ea5 --- /dev/null +++ b/src/result/spec/map.spec.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Result, Ok, Err, ok, err } from '../'; + +describe('Result map and mapErr - Runtime Tests', () => { + describe('map method', () => { + describe('when Result is Ok', () => { + it('should transform the value using the provided function', () => { + // Arrange + const result = ok(5); + const mapper = (x: number) => x * 2; + + // Act + const mapped = result.map(mapper); + + // Assert + expect(mapped.isOk()).toBe(true); + if (mapped.isOk()) { + expect(mapped.value).toBe(10); + } + }); + + it('should handle different type transformations', () => { + // Arrange + const numberResult = ok(42); + const stringResult = ok('hello'); + const booleanResult = ok(true); + + // Act + const numberToString = numberResult.map(x => x.toString()); + const stringToNumber = stringResult.map(s => s.length); + const booleanToString = booleanResult.map(b => b ? 'yes' : 'no'); + + // Assert + expect(numberToString.isOk()).toBe(true); + expect(stringToNumber.isOk()).toBe(true); + expect(booleanToString.isOk()).toBe(true); + + if (numberToString.isOk()) expect(numberToString.value).toBe('42'); + if (stringToNumber.isOk()) expect(stringToNumber.value).toBe(5); + if (booleanToString.isOk()) expect(booleanToString.value).toBe('yes'); + }); + + it('should handle complex object transformations', () => { + // Arrange + const user = { id: 1, name: 'John', age: 30 }; + const userResult = ok(user); + + // Act + const userSummary = userResult.map(u => ({ + displayName: `${u.name} (${u.age})`, + isAdult: u.age >= 18 + })); + + // Assert + expect(userSummary.isOk()).toBe(true); + if (userSummary.isOk()) { + expect(userSummary.value).toEqual({ + displayName: 'John (30)', + isAdult: true + }); + } + }); + + it('should not mutate the original result', () => { + // Arrange + const original = ok({ count: 5 }); + const originalValue = original.value; + + // Act + const mapped = original.map(obj => ({ ...obj, count: obj.count * 2 })); + + // Assert + expect(original.value).toBe(originalValue); + expect(original.value.count).toBe(5); + if (mapped.isOk()) { + expect(mapped.value.count).toBe(10); + } + }); + }); + + describe('when Result is Err', () => { + it('should return Err without calling the mapper function', () => { + // Arrange + const errResult: Result = err('error') as Result; + const mapperSpy = vi.fn((x: number) => x * 2); + + // Act + const result = errResult.map(mapperSpy); + + // Assert + expect(result.isErr()).toBe(true); + expect(mapperSpy).not.toHaveBeenCalled(); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + + it('should preserve Err through multiple map operations', () => { + // Arrange + const errResult: Result = err('error') as Result; + + // Act + const result = errResult + .map(x => x * 2) + .map(x => x.toString()) + .map(s => s.length); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('error'); + } + }); + }); + }); + + describe('mapErr method', () => { + describe('when Result is Err', () => { + it('should transform the error using the provided function', () => { + // Arrange + const result = err('error'); + const mapper = (e: string) => e.toUpperCase(); + + // Act + const mapped = result.mapErr(mapper); + + // Assert + expect(mapped.isErr()).toBe(true); + if (mapped.isErr()) { + expect(mapped.err).toBe('ERROR'); + } + }); + + it('should handle different error type transformations', () => { + // Arrange + const stringErr = err('not found'); + const numberErr = err(404); + + // Act + const toObject = stringErr.mapErr(e => ({ message: e })); + const toMessage = numberErr.mapErr(code => `Error code: ${code}`); + + // Assert + expect(toObject.isErr()).toBe(true); + expect(toMessage.isErr()).toBe(true); + + if (toObject.isErr()) expect(toObject.err).toEqual({ message: 'not found' }); + if (toMessage.isErr()) expect(toMessage.err).toBe('Error code: 404'); + }); + + it('should not mutate the original error', () => { + // Arrange + const original = err({ code: 500, message: 'Internal' }); + const originalErr = original.err; + + // Act + const mapped = original.mapErr(e => ({ ...e, code: 503 })); + + // Assert + expect(original.err).toBe(originalErr); + expect(original.err.code).toBe(500); + if (mapped.isErr()) { + expect(mapped.err.code).toBe(503); + } + }); + }); + + describe('when Result is Ok', () => { + it('should return Ok without calling the mapper function', () => { + // Arrange + const okResult: Result = ok(42) as Result; + const mapperSpy = vi.fn((e: string) => e.toUpperCase()); + + // Act + const result = okResult.mapErr(mapperSpy); + + // Assert + expect(result.isOk()).toBe(true); + expect(mapperSpy).not.toHaveBeenCalled(); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it('should preserve Ok through multiple mapErr operations', () => { + // Arrange + const okResult: Result = ok(42) as Result; + + // Act + const result = okResult + .mapErr(e => e.toUpperCase()) + .mapErr(e => e.length) + .mapErr(n => `Error length: ${n}`); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + }); + }); + + describe('map and mapErr integration', () => { + it('should allow chaining map and mapErr together', () => { + // Arrange + const okResult: Result = ok(5) as Result; + const errResult: Result = err('error') as Result; + + // Act + const mappedOk = okResult + .map(x => x * 2) + .mapErr(e => e.toUpperCase()); + + const mappedErr = errResult + .map(x => x * 2) + .mapErr(e => e.toUpperCase()); + + // Assert + expect(mappedOk.isOk()).toBe(true); + if (mappedOk.isOk()) expect(mappedOk.value).toBe(10); + + expect(mappedErr.isErr()).toBe(true); + if (mappedErr.isErr()) expect(mappedErr.err).toBe('ERROR'); + }); + + it('should handle edge cases with falsy values', () => { + // Arrange + const emptyStringResult = ok(''); + const zeroResult = ok(0); + const falseResult = ok(false); + + // Act + const stringMapped = emptyStringResult.map(s => s.length); + const numberMapped = zeroResult.map(n => n.toString()); + const booleanMapped = falseResult.map(b => !b); + + // Assert + if (stringMapped.isOk()) expect(stringMapped.value).toBe(0); + if (numberMapped.isOk()) expect(numberMapped.value).toBe('0'); + if (booleanMapped.isOk()) expect(booleanMapped.value).toBe(true); + }); + }); +}); diff --git a/src/result/spec/map.type-spec.ts b/src/result/spec/map.type-spec.ts new file mode 100644 index 0000000..7cfe4e1 --- /dev/null +++ b/src/result/spec/map.type-spec.ts @@ -0,0 +1,231 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, Ok, Err, ok, err } from "../"; + +describe("Result map and mapErr - Type Tests", () => { + describe("map method", () => { + it("should transform Ok to Result with correct type inference", () => { + // Arrange + const numberResult = ok(42); + const stringResult = ok("hello"); + + // Act + const doubledNumber = numberResult.map(x => x * 2); + const uppercaseString = stringResult.map(s => s.toUpperCase()); + const numberToString = numberResult.map(x => x.toString()); + + // Assert + expectTypeOf(doubledNumber).toEqualTypeOf>(); + expectTypeOf(uppercaseString).toEqualTypeOf>(); + expectTypeOf(numberToString).toEqualTypeOf>(); + }); + + it("should preserve error type through map on Result", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act + const mapped = result.map(x => x * 2); + + // Assert + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should handle complex type transformations", () => { + // Arrange + type User = { id: number; name: string }; + type UserDto = { userId: number; displayName: string }; + const userResult = ok({ id: 1, name: "John" } as User); + + // Act + const userDtoResult = userResult.map((user): UserDto => ({ + userId: user.id, + displayName: user.name.toUpperCase() + })); + + // Assert + expectTypeOf(userDtoResult).toEqualTypeOf>(); + }); + + it("should preserve literal types when appropriate", () => { + // Arrange + const literalResult = ok("success" as const); + + // Act + const mappedLiteral = literalResult.map(x => x); + const transformedLiteral = literalResult.map(x => `status: ${x}` as const); + + // Assert + expectTypeOf(mappedLiteral).toEqualTypeOf>(); + expectTypeOf(transformedLiteral).toEqualTypeOf>(); + }); + + it("should work with union types", () => { + // Arrange + const unionResult: Result = ok("hello" as string | number) as Result; + + // Act + const stringified = unionResult.map(x => String(x)); + + // Assert + expectTypeOf(stringified).toEqualTypeOf>(); + }); + + it("should be callable with correct mapper function signatures", () => { + // Arrange + const numberResult = ok(42); + const stringResult = ok("hello"); + + // Assert + expectTypeOf(numberResult.map).toBeCallableWith((x: number) => x * 2); + expectTypeOf(numberResult.map).toBeCallableWith((x: number) => x.toString()); + expectTypeOf(stringResult.map).toBeCallableWith((s: string) => s.length); + + // @ts-expect-error - wrong parameter type for map + numberResult.map((s: string) => s.length); + }); + }); + + describe("mapErr method", () => { + it("should transform Err to Result with correct type inference", () => { + // Arrange + const errResult = err("error"); + + // Act + const mappedErr = errResult.mapErr(e => e.toUpperCase()); + const errToNumber = errResult.mapErr(e => e.length); + + // Assert + expectTypeOf(mappedErr).toEqualTypeOf>(); + expectTypeOf(errToNumber).toEqualTypeOf>(); + }); + + it("should preserve value type through mapErr on Result", () => { + // Arrange + const result: Result = err("error") as Result; + + // Act + const mapped = result.mapErr(e => e.length); + + // Assert + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should handle complex error transformations", () => { + // Arrange + type ApiError = { code: number; message: string }; + type DisplayError = { title: string; description: string }; + const errResult = err({ code: 404, message: "Not found" } as ApiError); + + // Act + const displayErr = errResult.mapErr((e): DisplayError => ({ + title: `Error ${e.code}`, + description: e.message + })); + + // Assert + expectTypeOf(displayErr).toEqualTypeOf>(); + }); + + it("should be callable with correct mapper function signatures", () => { + // Arrange + const errResult: Result = err("error") as Result; + + // Assert + expectTypeOf(errResult.mapErr).toBeCallableWith((e: string) => e.toUpperCase()); + expectTypeOf(errResult.mapErr).toBeCallableWith((e: string) => e.length); + + // @ts-expect-error - wrong parameter type for mapErr + errResult.mapErr((n: number) => n * 2); + }); + }); + + describe("narrow return types on concrete variants", () => { + it("should return Ok when map is called on a known Ok", () => { + const mapped = ok(42).map(x => x.toString()); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Ok when mapErr is called on a known Ok", () => { + const mapped = ok(42).mapErr(e => String(e)); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Ok when flatMapErr is called on a known Ok", () => { + const mapped = ok(42).flatMapErr(e => err(String(e))); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Err when map is called on a known Err", () => { + const mapped = err("fail").map(x => String(x)); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Err when mapErr is called on a known Err", () => { + const mapped = err("fail").mapErr(e => e.length); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Err when flatMap is called on a known Err", () => { + const mapped = err("fail").flatMap(x => ok(String(x))); + expectTypeOf(mapped).toEqualTypeOf>(); + }); + + it("should return Err when filter is called on a known Err", () => { + const filtered = err("fail").filter(); + expectTypeOf(filtered).toEqualTypeOf>(); + }); + + it("should return Ok when flatMap callback returns Ok", () => { + const result = ok(5).flatMap(x => ok(x.toString())); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Result when flatMap callback returns Result", () => { + const result = ok(5).flatMap(x => (x > 0 ? ok(x) : err("neg")) as Result); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Err when flatMapErr callback returns Err", () => { + const result = err("fail").flatMapErr(e => err(e.length)); + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should return Result when flatMapErr callback returns Result", () => { + const result = err("fail").flatMapErr(e => (e === "retry" ? ok(0) : err(e.length)) as Result); + expectTypeOf(result).toEqualTypeOf>(); + }); + }); + + describe("map and mapErr interaction", () => { + it("should work together in chains with correct type inference", () => { + // Arrange + const result: Result = ok(5) as Result; + + // Act + const chained = result + .map(x => x * 2) + .mapErr(e => e.toUpperCase()); + + // Assert + expectTypeOf(chained).toEqualTypeOf>(); + }); + + it("should handle mixed transformations correctly", () => { + // Arrange + type Input = { value: string }; + type Output = { parsed: number }; + type InputError = string; + type OutputError = { code: number; message: string }; + + const result: Result = ok({ value: "42" }) as Result; + + // Act + const transformed = result + .map((input): Output => ({ parsed: parseInt(input.value) })) + .mapErr((e): OutputError => ({ code: 400, message: e })); + + // Assert + expectTypeOf(transformed).toEqualTypeOf>(); + }); + }); +}); diff --git a/src/result/spec/match.spec.ts b/src/result/spec/match.spec.ts new file mode 100644 index 0000000..b5548f3 --- /dev/null +++ b/src/result/spec/match.spec.ts @@ -0,0 +1,270 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Result, Ok, Err, ok, err } from '../'; + +describe('Result.match - Runtime Tests', () => { + describe('match with Ok', () => { + it('should execute onOk callback with the contained value', () => { + // Arrange + const okResult = ok(42); + const onOk = vi.fn((value: number) => `Value: ${value}`); + const onErr = vi.fn(() => 'Error occurred'); + + // Act + const result = okResult.match(onOk, onErr); + + // Assert + expect(onOk).toHaveBeenCalledWith(42); + expect(onOk).toHaveBeenCalledTimes(1); + expect(onErr).not.toHaveBeenCalled(); + expect(result).toBe('Value: 42'); + }); + + it('should return the result of onOk callback', () => { + // Arrange + const okResult = ok(10); + + // Act + const result = okResult.match( + (value) => value * 2, + () => 0 + ); + + // Assert + expect(result).toBe(20); + }); + + it('should work with various data types', () => { + // Arrange + const testCases = [ + { result: ok(42), expected: 'number: 42' }, + { result: ok('hello'), expected: 'string: "hello"' }, + { result: ok(true), expected: 'boolean: true' }, + { result: ok({ id: 1 }), expected: 'object: {"id":1}' }, + { result: ok([1, 2, 3]), expected: 'object: [1,2,3]' }, + ]; + + testCases.forEach(({ result: r, expected }) => { + // Act + const output = r.match( + (value) => `${typeof value}: ${JSON.stringify(value)}`, + () => 'error' + ); + + // Assert + expect(output).toBe(expected); + }); + }); + + it('should handle null and undefined values correctly', () => { + // Arrange + const nullResult = ok(null); + const undefinedResult = ok(undefined); + + // Act + const nullOutput = nullResult.match( + (value) => `Got null: ${value}`, + () => 'Error' + ); + + const undefinedOutput = undefinedResult.match( + (value) => `Got undefined: ${value}`, + () => 'Error' + ); + + // Assert + expect(nullOutput).toBe('Got null: null'); + expect(undefinedOutput).toBe('Got undefined: undefined'); + }); + + it('should handle complex transformations', () => { + // Arrange + const okResult = ok({ name: 'John', age: 30 }); + + // Act + const result = okResult.match( + (person) => ({ + greeting: `Hello, ${person.name}!`, + isAdult: person.age >= 18, + category: person.age < 18 ? 'minor' : person.age < 65 ? 'adult' : 'senior' + }), + () => ({ greeting: 'Hello, stranger!', isAdult: false, category: 'unknown' }) + ); + + // Assert + expect(result).toEqual({ + greeting: 'Hello, John!', + isAdult: true, + category: 'adult' + }); + }); + }); + + describe('match with Err', () => { + it('should execute onErr callback with the contained error', () => { + // Arrange + const errResult: Result = err('something failed'); + const onOk = vi.fn((value: number) => `Value: ${value}`); + const onErr = vi.fn((error: string) => `Error: ${error}`); + + // Act + const result = errResult.match(onOk, onErr); + + // Assert + expect(onErr).toHaveBeenCalledWith('something failed'); + expect(onErr).toHaveBeenCalledTimes(1); + expect(onOk).not.toHaveBeenCalled(); + expect(result).toBe('Error: something failed'); + }); + + it('should return the result of onErr callback', () => { + // Arrange + const errResult: Result = err('failed') as Result; + + // Act + const result = errResult.match( + (value) => value.toUpperCase(), + () => 'DEFAULT' + ); + + // Assert + expect(result).toBe('DEFAULT'); + }); + + it('should handle different error types in onErr', () => { + // Arrange + const stringErr: Result = err('error') as Result; + const numberErr: Result = err(404) as Result; + const objectErr: Result = err({ code: 500 }) as Result; + + // Act + const stringOutput = stringErr.match( + (value) => value.toString(), + (error) => `String error: ${error}` + ); + + const numberOutput = numberErr.match( + (value) => value, + (error) => error * -1 + ); + + const objectOutput = objectErr.match( + (value): { value: number | null; code?: number } => ({ value }), + (error): { value: number | null; code?: number } => ({ value: null, code: error.code }) + ); + + // Assert + expect(stringOutput).toBe('String error: error'); + expect(numberOutput).toBe(-404); + expect(objectOutput).toEqual({ value: null, code: 500 }); + }); + }); + + describe('match with mixed Result types', () => { + it('should handle dynamic result types correctly', () => { + // Arrange + const results: Result[] = [ + ok('hello') as Result, + err('error') as Result, + ok('world') as Result + ]; + + // Act + const outputs = results.map(r => + r.match( + (value) => value.toUpperCase(), + (error) => `ERR: ${error}` + ) + ); + + // Assert + expect(outputs).toEqual(['HELLO', 'ERR: error', 'WORLD']); + }); + + it('should work in conditional contexts', () => { + // Arrange + const getValue = (hasValue: boolean): Result => + hasValue ? ok(42) as Result : err('not found') as Result; + + // Act + const withValue = getValue(true).match( + (value) => `Found: ${value}`, + (error) => `Error: ${error}` + ); + + const withoutValue = getValue(false).match( + (value) => `Found: ${value}`, + (error) => `Error: ${error}` + ); + + // Assert + expect(withValue).toBe('Found: 42'); + expect(withoutValue).toBe('Error: not found'); + }); + }); + + describe('match error handling', () => { + it('should propagate errors from onOk callback', () => { + // Arrange + const okResult = ok(42); + const error = new Error('onOk error'); + + // Act & Assert + expect(() => { + okResult.match( + () => { throw error; }, + () => 'fallback' + ); + }).toThrow('onOk error'); + }); + + it('should propagate errors from onErr callback', () => { + // Arrange + const errResult: Result = err('error') as Result; + const error = new Error('onErr error'); + + // Act & Assert + expect(() => { + errResult.match( + (value) => value.toString(), + () => { throw error; } + ); + }).toThrow('onErr error'); + }); + }); + + describe('match with async callbacks', () => { + it('should handle async onOk callback', async () => { + // Arrange + const okResult = ok(42); + + // Act + const result = await okResult.match( + async (value) => { + await new Promise(resolve => setTimeout(resolve, 10)); + return `Async: ${value}`; + }, + async () => 'Async: Error' + ); + + // Assert + expect(result).toBe('Async: 42'); + }); + + it('should handle async onErr callback', async () => { + // Arrange + const errResult: Result = err('error'); + + // Act + const result = await errResult.match( + async (value) => `Async: ${value}`, + async (error) => { + await new Promise(resolve => setTimeout(resolve, 10)); + return `Async error: ${error}`; + } + ); + + // Assert + expect(result).toBe('Async error: error'); + }); + }); +}); diff --git a/src/result/spec/match.type-spec.ts b/src/result/spec/match.type-spec.ts new file mode 100644 index 0000000..82d8c14 --- /dev/null +++ b/src/result/spec/match.type-spec.ts @@ -0,0 +1,230 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, ResultInterface, Ok, Err, ok, err } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const ri = (r: Result) => r as unknown as ResultInterface; + +describe("Result.match - Type Tests", () => { + describe("match with Ok", () => { + it("should infer return type from both callback return types", () => { + // Arrange + const okResult = ok(42); + + // Act + const stringResult = okResult.match( + (value) => `Value: ${value}`, + () => "Error" + ); + + const numberResult = okResult.match( + (value) => value * 2, + () => 0 + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + expectTypeOf(numberResult).toEqualTypeOf(); + }); + + it("should infer union type when callbacks return different types", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Act + const unionResult = result.match( + (value) => value.toString(), + () => 0 + ); + + // Assert + expectTypeOf(unionResult).toEqualTypeOf(); + }); + + it("should preserve complex return types", () => { + // Arrange + type ComplexType = { data: string[]; count: number }; + const okResult = ok({ id: 1, name: "test" }); + + // Act + const complexResult = okResult.match( + (value): ComplexType => ({ data: [value.name], count: 1 }), + (): ComplexType => ({ data: [], count: 0 }) + ); + + // Assert + expectTypeOf(complexResult).toEqualTypeOf(); + }); + + it("should handle generic types correctly", () => { + // Arrange + const okResult = ok([1, 2, 3]); + + // Act + const mappedResult = okResult.match( + (arr) => arr.map(x => x.toString()), + () => [] + ); + + // Assert + expectTypeOf(mappedResult).toEqualTypeOf(); + }); + }); + + describe("match with Err", () => { + it("should infer return type from callback return types", () => { + // Arrange + const errResult = ri(err("error") as Result); + + // Act + const stringResult = errResult.match( + (value) => `Value: ${value}`, + (error) => `Error: ${error}` + ); + + // Assert + expectTypeOf(stringResult).toEqualTypeOf(); + }); + + it("should handle union return types with Err", () => { + // Arrange + const errResult = ri(err("error") as Result); + + // Act + const unionResult = errResult.match( + (value) => value.length, + () => "empty" + ); + + // Assert + expectTypeOf(unionResult).toEqualTypeOf(); + }); + }); + + describe("match callback parameter types", () => { + it("should provide correct parameter type to onOk callback", () => { + // Arrange + const numberResult = ri(ok(42) as Result); + const stringResult = ri(ok("hello") as Result); + const objectResult = ri<{ id: number; name: string }, string>(ok({ id: 1, name: "test" }) as Result<{ id: number; name: string }, string>); + + // Act & Assert + numberResult.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + () => 0 + ); + + stringResult.match( + (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value; + }, + () => "" + ); + + objectResult.match( + (value) => { + expectTypeOf(value).toEqualTypeOf<{ id: number; name: string }>(); + return value; + }, + () => ({ id: 0, name: "" }) + ); + }); + + it("should provide correct parameter type to onErr callback", () => { + // Arrange + const result = ri(err({ code: 404, message: "Not found" }) as Result); + + // Act & Assert + result.match( + (value) => value, + (error) => { + expectTypeOf(error).toEqualTypeOf<{ code: number; message: string }>(); + return error.code; + } + ); + }); + + it("should require compatible return types from both callbacks", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Act & Assert - these should not raise errors (valid calls) + result.match( + (value) => value.toString(), + () => "default" + ); + + result.match( + (value) => value, + () => 0 + ); + }); + }); + + describe("match function signature validation", () => { + it("should be callable with correct callback signatures", () => { + // Arrange + const result = ri(ok(42) as Result); + const onOk = (value: number) => value * 2; + const onErr = (error: string) => error.length; + + // Assert + expectTypeOf(result.match).toBeCallableWith(onOk, onErr); + }); + + it("should not be callable with incorrect callback signatures", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Assert + // @ts-expect-error - wrong parameter type for onOk + result.match((value: string) => value, () => ""); + // @ts-expect-error - wrong parameter type for onErr + result.match((x: number) => x, (value: number) => value); + }); + + it("should not be callable with missing callbacks", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Assert + // @ts-expect-error - missing onErr callback + result.match((x: number) => x); + // @ts-expect-error - missing all callbacks + result.match(); + }); + }); + + describe("match with complex types", () => { + it("should handle async callbacks correctly", () => { + // Arrange + const okResult = ok(42); + + // Act + const asyncResult = okResult.match( + async (value) => `Async: ${value}`, + async () => "Async: Error" + ); + + // Assert + expectTypeOf(asyncResult).toEqualTypeOf>(); + }); + + it("should handle callback functions as return values", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Act + const functionResult = result.match( + (value) => (x: number) => x + value, + () => (x: number) => x + ); + + // Assert + expectTypeOf(functionResult).toEqualTypeOf<(x: number) => number>(); + }); + }); +}); diff --git a/src/result/spec/namespace.spec.ts b/src/result/spec/namespace.spec.ts new file mode 100644 index 0000000..ad11479 --- /dev/null +++ b/src/result/spec/namespace.spec.ts @@ -0,0 +1,510 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Result, ResultInterface, Ok, Err, ok, err } from '../'; + +describe('Result namespace - Runtime Tests', () => { + describe('Result.from', () => { + it('should return Ok when function succeeds', () => { + // Arrange + const fn = () => 42; + + // Act + const result = Result.from(fn); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it('should return Err when function throws', () => { + // Arrange + const error = new Error('test error'); + const fn = () => { throw error; }; + + // Act + const result = Result.from(fn); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe(error); + } + }); + + it('should apply errorMap when function throws and errorMap is provided', () => { + // Arrange + const fn = () => { throw new Error('original'); }; + const errorMap = (e: unknown) => `Mapped: ${(e as Error).message}`; + + // Act + const result = Result.from(fn, errorMap); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('Mapped: original'); + } + }); + + it('should not call errorMap when function succeeds', () => { + // Arrange + const fn = () => 42; + const errorMapSpy = vi.fn((e: unknown) => `Mapped: ${e}`); + + // Act + const result = Result.from(fn, errorMapSpy); + + // Assert + expect(result.isOk()).toBe(true); + expect(errorMapSpy).not.toHaveBeenCalled(); + }); + + it('should handle JSON.parse correctly', () => { + // Arrange & Act + const validResult = Result.from(() => JSON.parse('{"valid": true}')); + const invalidResult = Result.from(() => JSON.parse('invalid')); + + // Assert + expect(validResult.isOk()).toBe(true); + if (validResult.isOk()) { + expect(validResult.value).toEqual({ valid: true }); + } + + expect(invalidResult.isErr()).toBe(true); + }); + + it('should handle functions returning falsy values', () => { + // Arrange & Act + const zeroResult = Result.from(() => 0); + const falseResult = Result.from(() => false); + const emptyResult = Result.from(() => ''); + const nullResult = Result.from(() => null); + const undefinedResult = Result.from(() => undefined); + + // Assert + expect(zeroResult.isOk()).toBe(true); + expect(falseResult.isOk()).toBe(true); + expect(emptyResult.isOk()).toBe(true); + expect(nullResult.isOk()).toBe(true); + expect(undefinedResult.isOk()).toBe(true); + + if (zeroResult.isOk()) expect(zeroResult.value).toBe(0); + if (falseResult.isOk()) expect(falseResult.value).toBe(false); + if (emptyResult.isOk()) expect(emptyResult.value).toBe(''); + if (nullResult.isOk()) expect(nullResult.value).toBeNull(); + if (undefinedResult.isOk()) expect(undefinedResult.value).toBeUndefined(); + }); + }); + + describe('Result.isResult', () => { + it('should return true for Ok instances', () => { + // Arrange & Act & Assert + expect(Result.isResult(ok(42))).toBe(true); + expect(Result.isResult(ok('hello'))).toBe(true); + expect(Result.isResult(ok(null))).toBe(true); + expect(Result.isResult(new Ok(42))).toBe(true); + }); + + it('should return true for Err instances', () => { + // Arrange & Act & Assert + expect(Result.isResult(err('error'))).toBe(true); + expect(Result.isResult(err(404))).toBe(true); + expect(Result.isResult(err(null))).toBe(true); + expect(Result.isResult(new Err('error'))).toBe(true); + }); + + it('should return false for non-Result values', () => { + // Arrange & Act & Assert + expect(Result.isResult(42)).toBe(false); + expect(Result.isResult('hello')).toBe(false); + expect(Result.isResult(null)).toBe(false); + expect(Result.isResult(undefined)).toBe(false); + expect(Result.isResult(true)).toBe(false); + expect(Result.isResult({ value: 42 })).toBe(false); + expect(Result.isResult([1, 2, 3])).toBe(false); + expect(Result.isResult({ isOk: () => true })).toBe(false); + }); + }); + + describe('Result.all', () => { + it('should return Ok with all values when all results are Ok', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + ok(2) as Result, + ok(3) as Result, + ]; + + // Act + const result = Result.all(results); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual([1, 2, 3]); + } + }); + + it('should return the first Err when any result is Err', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + err('first error') as Result, + ok(3) as Result, + err('second error') as Result, + ]; + + // Act + const result = Result.all(results); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('first error'); + } + }); + + it('should return Ok with empty array for empty input', () => { + // Arrange + const results: Result[] = []; + + // Act + const result = Result.all(results); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual([]); + } + }); + + it('should handle single element arrays', () => { + // Arrange + const okResults: Result[] = [ok(42) as Result]; + const errResults: Result[] = [err('error') as Result]; + + // Act + const okResult = Result.all(okResults); + const errResult = Result.all(errResults); + + // Assert + expect(okResult.isOk()).toBe(true); + if (okResult.isOk()) expect(okResult.value).toEqual([42]); + + expect(errResult.isErr()).toBe(true); + if (errResult.isErr()) expect(errResult.err).toBe('error'); + }); + + it('should handle complex value types', () => { + // Arrange + type User = { id: number; name: string }; + const results: Result[] = [ + ok({ id: 1, name: 'Alice' }) as Result, + ok({ id: 2, name: 'Bob' }) as Result, + ]; + + // Act + const result = Result.all(results); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual([ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]); + } + }); + }); + + describe('Result.partition', () => { + it('should partition results into values and errors', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + err('error1') as Result, + ok(3) as Result, + err('error2') as Result, + ]; + + // Act + const partitioned = Result.partition(results); + + // Assert + expect(partitioned.values).toEqual([1, 3]); + expect(partitioned.errors).toEqual(['error1', 'error2']); + }); + + it('should handle all Ok results', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + ok(2) as Result, + ]; + + // Act + const partitioned = Result.partition(results); + + // Assert + expect(partitioned.values).toEqual([1, 2]); + expect(partitioned.errors).toEqual([]); + }); + + it('should handle all Err results', () => { + // Arrange + const results: Result[] = [ + err('error1') as Result, + err('error2') as Result, + ]; + + // Act + const partitioned = Result.partition(results); + + // Assert + expect(partitioned.values).toEqual([]); + expect(partitioned.errors).toEqual(['error1', 'error2']); + }); + + it('should handle empty array', () => { + // Arrange + const results: Result[] = []; + + // Act + const partitioned = Result.partition(results); + + // Assert + expect(partitioned.values).toEqual([]); + expect(partitioned.errors).toEqual([]); + }); + }); + + describe('Result.compact', () => { + it('should extract values from Ok instances', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + err('error') as Result, + ok(3) as Result, + ]; + + // Act + const values = Result.compact(results); + + // Assert + expect(values).toEqual([1, 3]); + }); + + it('should return empty array when all are Err', () => { + // Arrange + const results: Result[] = [ + err('error1') as Result, + err('error2') as Result, + ]; + + // Act + const values = Result.compact(results); + + // Assert + expect(values).toEqual([]); + }); + + it('should return all values when all are Ok', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + ok(2) as Result, + ok(3) as Result, + ]; + + // Act + const values = Result.compact(results); + + // Assert + expect(values).toEqual([1, 2, 3]); + }); + + it('should handle empty array', () => { + // Arrange + const results: Result[] = []; + + // Act + const values = Result.compact(results); + + // Assert + expect(values).toEqual([]); + }); + + it('should handle falsy Ok values', () => { + // Arrange + const results: Result[] = [ + ok(0) as Result, + ok(false) as Result, + ok('') as Result, + err('error') as Result, + ]; + + // Act + const values = Result.compact(results); + + // Assert + expect(values).toEqual([0, false, '']); + }); + }); + + describe('Result.some', () => { + it('should return true when at least one result is Ok', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + err('error') as Result, + ok(3) as Result, + ]; + + // Act + const result = Result.some(results); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all results are Err', () => { + // Arrange + const results: Result[] = [ + err('error1') as Result, + err('error2') as Result, + ]; + + // Act + const result = Result.some(results); + + // Assert + expect(result).toBe(false); + }); + + it('should return false for empty array', () => { + // Arrange + const results: Result[] = []; + + // Act + const result = Result.some(results); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when all results are Ok', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + ok(2) as Result, + ]; + + // Act + const result = Result.some(results); + + // Assert + expect(result).toBe(true); + }); + }); + + describe('Result.every', () => { + it('should return true when all results are Ok', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + ok(2) as Result, + ]; + + // Act + const result = Result.every(results); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when any result is Err', () => { + // Arrange + const results: Result[] = [ + ok(1) as Result, + err('error') as Result, + ]; + + // Act + const result = Result.every(results); + + // Assert + expect(result).toBe(false); + }); + + it('should return true for empty array', () => { + // Arrange + const results: Result[] = []; + + // Act + const result = Result.every(results); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all results are Err', () => { + // Arrange + const results: Result[] = [ + err('error1') as Result, + err('error2') as Result, + ]; + + // Act + const result = Result.every(results); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('Integration tests', () => { + it('should work with Result.from and Result.all together', () => { + // Arrange + const parse = (s: string) => Result.from(() => JSON.parse(s), () => `Invalid JSON: ${s}`); + + // Act + const allValid = Result.all([ + parse('{"a": 1}'), + parse('{"b": 2}'), + ]); + const someInvalid = Result.all([ + parse('{"a": 1}'), + parse('invalid'), + ]); + + // Assert + expect(allValid.isOk()).toBe(true); + if (allValid.isOk()) { + expect(allValid.value).toEqual([{ a: 1 }, { b: 2 }]); + } + + expect(someInvalid.isErr()).toBe(true); + if (someInvalid.isErr()) { + expect(someInvalid.err).toBe('Invalid JSON: invalid'); + } + }); + + it('should work with Result.from and Result.partition together', () => { + // Arrange + const inputs = ['1', 'invalid', '3', 'also invalid']; + const results = inputs.map(s => + Result.from(() => parseInt(s), () => `Bad: ${s}`) + ).map((r): Result => + (r as ResultInterface).flatMap((n): Result => isNaN(n) ? err(`NaN from parse`) : ok(n)) + ); + + // Act + const partitioned = Result.partition(results); + + // Assert + expect(partitioned.values).toEqual([1, 3]); + expect(partitioned.errors).toEqual(['NaN from parse', 'NaN from parse']); + }); + }); +}); diff --git a/src/result/spec/namespace.type-spec.ts b/src/result/spec/namespace.type-spec.ts new file mode 100644 index 0000000..65538ac --- /dev/null +++ b/src/result/spec/namespace.type-spec.ts @@ -0,0 +1,208 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, Ok, Err, ok, err, ResultPartitionResult } from "../"; + +describe("Result namespace - Type Tests", () => { + describe("Result.from", () => { + it("should infer Ok type from function return type", () => { + // Arrange & Act + const numberResult = Result.from(() => 42); + const stringResult = Result.from(() => "hello"); + const objectResult = Result.from(() => ({ id: 1 })); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf>(); + expectTypeOf(stringResult).toEqualTypeOf>(); + expectTypeOf(objectResult).toEqualTypeOf>(); + }); + + it("should infer error type from errorMap", () => { + // Arrange & Act + const stringErrResult = Result.from(() => 42, () => "error"); + const numberErrResult = Result.from(() => 42, () => 404); + const objectErrResult = Result.from(() => 42, () => ({ code: 500 })); + + // Assert + expectTypeOf(stringErrResult).toEqualTypeOf>(); + expectTypeOf(numberErrResult).toEqualTypeOf>(); + expectTypeOf(objectErrResult).toEqualTypeOf>(); + }); + + it("should accept unknown error type in errorMap parameter", () => { + // Arrange & Act + const result = Result.from( + () => 42, + (error: unknown) => `Error: ${error}` + ); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should reject async functions with a type-level error", () => { + // @ts-expect-error — async functions should not be passed to Result.from + const result = Result.from(async () => 42); + + expectTypeOf(result).toBeString(); + }); + + it("should reject async functions even with errorMap", () => { + // @ts-expect-error — async functions should not be passed to Result.from + const result = Result.from(async () => 42, () => "error"); + + expectTypeOf(result).toBeString(); + }); + }); + + describe("Result.isResult", () => { + it("should act as type guard narrowing to Result", () => { + // Arrange + const value: unknown = ok(42); + + // Act & Assert + if (Result.isResult(value)) { + expectTypeOf(value).toEqualTypeOf>(); + } + }); + + it("should accept any value", () => { + // Assert + expectTypeOf(Result.isResult).toBeCallableWith(42); + expectTypeOf(Result.isResult).toBeCallableWith("hello"); + expectTypeOf(Result.isResult).toBeCallableWith(null); + expectTypeOf(Result.isResult).toBeCallableWith(undefined); + expectTypeOf(Result.isResult).toBeCallableWith(ok(42)); + expectTypeOf(Result.isResult).toBeCallableWith(err("error")); + }); + + it("should return boolean", () => { + // Arrange & Act + const result = Result.isResult(ok(42)); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + }); + + describe("Result.all", () => { + it("should return Result for Result[]", () => { + // Arrange + const results: Result[] = [ok(1), ok(2)] as Result[]; + + // Act + const allResult = Result.all(results); + + // Assert + expectTypeOf(allResult).toEqualTypeOf>(); + }); + + it("should handle complex types", () => { + // Arrange + type User = { id: number; name: string }; + const results: Result[] = [] as Result[]; + + // Act + const allResult = Result.all(results); + + // Assert + expectTypeOf(allResult).toEqualTypeOf>(); + }); + + it("should return Ok when all inputs are known Ok", () => { + const allResult = Result.all([ok(1), ok(2), ok(3)]); + expectTypeOf(allResult).toEqualTypeOf>(); + }); + }); + + describe("Result.partition", () => { + it("should return ResultPartitionResult", () => { + // Arrange + const results: Result[] = [] as Result[]; + + // Act + const partitioned = Result.partition(results); + + // Assert + expectTypeOf(partitioned).toEqualTypeOf>(); + expectTypeOf(partitioned.values).toEqualTypeOf(); + expectTypeOf(partitioned.errors).toEqualTypeOf(); + }); + + it("should handle complex types", () => { + // Arrange + type User = { id: number; name: string }; + type ApiError = { code: number; message: string }; + const results: Result[] = [] as Result[]; + + // Act + const partitioned = Result.partition(results); + + // Assert + expectTypeOf(partitioned).toEqualTypeOf>(); + expectTypeOf(partitioned.values).toEqualTypeOf(); + expectTypeOf(partitioned.errors).toEqualTypeOf(); + }); + }); + + describe("Result.compact", () => { + it("should return T[] for Result[]", () => { + // Arrange + const results: Result[] = [] as Result[]; + + // Act + const compacted = Result.compact(results); + + // Assert + expectTypeOf(compacted).toEqualTypeOf(); + }); + + it("should handle complex types", () => { + // Arrange + type User = { id: number; name: string }; + const results: Result[] = [] as Result[]; + + // Act + const compacted = Result.compact(results); + + // Assert + expectTypeOf(compacted).toEqualTypeOf(); + }); + }); + + describe("Result.some", () => { + it("should return boolean", () => { + // Arrange + const results: Result[] = [] as Result[]; + + // Act + const result = Result.some(results); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should accept Result[]", () => { + // Assert + expectTypeOf(Result.some).toBeCallableWith([] as Result[]); + expectTypeOf(Result.some).toBeCallableWith([ok(1)] as Result[]); + }); + }); + + describe("Result.every", () => { + it("should return boolean", () => { + // Arrange + const results: Result[] = [] as Result[]; + + // Act + const result = Result.every(results); + + // Assert + expectTypeOf(result).toEqualTypeOf(); + }); + + it("should accept Result[]", () => { + // Assert + expectTypeOf(Result.every).toBeCallableWith([] as Result[]); + expectTypeOf(Result.every).toBeCallableWith([ok(1)] as Result[]); + }); + }); +}); diff --git a/src/result/spec/nullable.spec.ts b/src/result/spec/nullable.spec.ts new file mode 100644 index 0000000..c8fe7b3 --- /dev/null +++ b/src/result/spec/nullable.spec.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'vitest'; +import { Result, Ok, Err, ok, err } from '../'; + +describe('Result toNullable/toUndefined - Runtime Tests', () => { + describe('toNullable method', () => { + describe('when Result is Ok', () => { + it('should return the contained value', () => { + // Arrange + const result = ok(42); + + // Act + const value = result.toNullable(); + + // Assert + expect(value).toBe(42); + }); + + it('should return various types of values', () => { + // Arrange & Act & Assert + expect(ok(42).toNullable()).toBe(42); + expect(ok('hello').toNullable()).toBe('hello'); + expect(ok(true).toNullable()).toBe(true); + expect(ok({ id: 1 }).toNullable()).toEqual({ id: 1 }); + expect(ok([1, 2, 3]).toNullable()).toEqual([1, 2, 3]); + }); + + it('should return falsy values correctly', () => { + // Arrange & Act & Assert + expect(ok(0).toNullable()).toBe(0); + expect(ok(false).toNullable()).toBe(false); + expect(ok('').toNullable()).toBe(''); + }); + + it('should return the exact same reference for objects', () => { + // Arrange + const obj = { name: 'test', data: [1, 2, 3] }; + const result = ok(obj); + + // Act + const value = result.toNullable(); + + // Assert + expect(value).toBe(obj); + }); + }); + + describe('when Result is Err', () => { + it('should return null', () => { + // Arrange + const result: Result = err('error') as Result; + + // Act + const value = result.toNullable(); + + // Assert + expect(value).toBeNull(); + }); + + it('should return null regardless of error type', () => { + // Arrange + const stringErr: Result = err('error') as Result; + const numberErr: Result = err(404) as Result; + const objectErr: Result = err({ code: 500 }) as Result; + + // Act & Assert + expect(stringErr.toNullable()).toBeNull(); + expect(numberErr.toNullable()).toBeNull(); + expect(objectErr.toNullable()).toBeNull(); + }); + }); + }); + + describe('toUndefined method', () => { + describe('when Result is Ok', () => { + it('should return the contained value', () => { + // Arrange + const result = ok(42); + + // Act + const value = result.toUndefined(); + + // Assert + expect(value).toBe(42); + }); + + it('should return various types of values', () => { + // Arrange & Act & Assert + expect(ok(42).toUndefined()).toBe(42); + expect(ok('hello').toUndefined()).toBe('hello'); + expect(ok(true).toUndefined()).toBe(true); + expect(ok({ id: 1 }).toUndefined()).toEqual({ id: 1 }); + expect(ok([1, 2, 3]).toUndefined()).toEqual([1, 2, 3]); + }); + + it('should return falsy values correctly', () => { + // Arrange & Act & Assert + expect(ok(0).toUndefined()).toBe(0); + expect(ok(false).toUndefined()).toBe(false); + expect(ok('').toUndefined()).toBe(''); + }); + + it('should return the exact same reference for objects', () => { + // Arrange + const obj = { name: 'test', data: [1, 2, 3] }; + const result = ok(obj); + + // Act + const value = result.toUndefined(); + + // Assert + expect(value).toBe(obj); + }); + }); + + describe('when Result is Err', () => { + it('should return undefined', () => { + // Arrange + const result: Result = err('error') as Result; + + // Act + const value = result.toUndefined(); + + // Assert + expect(value).toBeUndefined(); + }); + + it('should return undefined regardless of error type', () => { + // Arrange + const stringErr: Result = err('error') as Result; + const numberErr: Result = err(404) as Result; + const objectErr: Result = err({ code: 500 }) as Result; + + // Act & Assert + expect(stringErr.toUndefined()).toBeUndefined(); + expect(numberErr.toUndefined()).toBeUndefined(); + expect(objectErr.toUndefined()).toBeUndefined(); + }); + }); + }); + + describe('toNullable and toUndefined integration', () => { + it('should behave consistently for Ok values', () => { + // Arrange + const values = [42, 'hello', true, { id: 1 }, [1, 2, 3], 0, false, '']; + + values.forEach(value => { + // Act + const nullable = ok(value).toNullable(); + const undef = ok(value).toUndefined(); + + // Assert + expect(nullable).toBe(value); + expect(undef).toBe(value); + }); + }); + + it('should behave consistently for Err values', () => { + // Arrange + const errors = ['error', 404, new Error('test'), { code: 500 }]; + + errors.forEach(error => { + // Act + const nullable = (err(error) as Result).toNullable(); + const undef = (err(error) as Result).toUndefined(); + + // Assert + expect(nullable).toBeNull(); + expect(undef).toBeUndefined(); + }); + }); + + it('should work after map/flatMap chains', () => { + // Arrange + const okResult: Result = ok(5) as Result; + const errResult: Result = err('error') as Result; + + // Act + const okNullable = okResult.map(x => x * 2).toNullable(); + const okUndefined = okResult.map(x => x * 2).toUndefined(); + const errNullable = errResult.map(x => x * 2).toNullable(); + const errUndefined = errResult.map(x => x * 2).toUndefined(); + + // Assert + expect(okNullable).toBe(10); + expect(okUndefined).toBe(10); + expect(errNullable).toBeNull(); + expect(errUndefined).toBeUndefined(); + }); + }); +}); diff --git a/src/result/spec/nullable.type-spec.ts b/src/result/spec/nullable.type-spec.ts new file mode 100644 index 0000000..e4a3ce5 --- /dev/null +++ b/src/result/spec/nullable.type-spec.ts @@ -0,0 +1,151 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, ResultInterface, Ok, Err, ok, err } from "../"; + +// Helper to use the interface signatures which correctly unify T and E +const ri = (r: Result) => r as unknown as ResultInterface; + +describe("Result toNullable/toUndefined - Type Tests", () => { + describe("toNullable method", () => { + it("should return T for Ok", () => { + // Arrange + const numberOk = ok(42); + const stringOk = ok("hello"); + const objectOk = ok({ id: 1, name: "test" }); + + // Act + const numberValue = numberOk.toNullable(); + const stringValue = stringOk.toNullable(); + const objectValue = objectOk.toNullable(); + + // Assert + expectTypeOf(numberValue).toEqualTypeOf(); + expectTypeOf(stringValue).toEqualTypeOf(); + expectTypeOf(objectValue).toEqualTypeOf<{ id: number; name: string }>(); + }); + + it("should return null for Err", () => { + // Arrange + const stringErr = err("error"); + const numberErr = err(404); + + // Act + const nullFromString = stringErr.toNullable(); + const nullFromNumber = numberErr.toNullable(); + + // Assert + expectTypeOf(nullFromString).toEqualTypeOf(); + expectTypeOf(nullFromNumber).toEqualTypeOf(); + }); + + it("should return T | null for Result", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act + const value = result.toNullable(); + + // Assert + expectTypeOf(value).toEqualTypeOf(); + }); + + it("should handle complex types", () => { + // Arrange + type ComplexType = { data: Array<{ id: number }> }; + const result: Result = ok({ data: [{ id: 1 }] }) as Result; + + // Act + const value = result.toNullable(); + + // Assert + expectTypeOf(value).toEqualTypeOf(); + }); + }); + + describe("toUndefined method", () => { + it("should return T for Ok", () => { + // Arrange + const numberOk = ok(42); + const stringOk = ok("hello"); + const objectOk = ok({ id: 1, name: "test" }); + + // Act + const numberValue = numberOk.toUndefined(); + const stringValue = stringOk.toUndefined(); + const objectValue = objectOk.toUndefined(); + + // Assert + expectTypeOf(numberValue).toEqualTypeOf(); + expectTypeOf(stringValue).toEqualTypeOf(); + expectTypeOf(objectValue).toEqualTypeOf<{ id: number; name: string }>(); + }); + + it("should return undefined for Err", () => { + // Arrange + const stringErr = err("error"); + const numberErr = err(404); + + // Act + const undefFromString = stringErr.toUndefined(); + const undefFromNumber = numberErr.toUndefined(); + + // Assert + expectTypeOf(undefFromString).toEqualTypeOf(); + expectTypeOf(undefFromNumber).toEqualTypeOf(); + }); + + it("should return T | undefined for Result", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act + const value = result.toUndefined(); + + // Assert + expectTypeOf(value).toEqualTypeOf(); + }); + + it("should handle complex types", () => { + // Arrange + type ComplexType = { data: Array<{ id: number }> }; + const result: Result = ok({ data: [{ id: 1 }] }) as Result; + + // Act + const value = result.toUndefined(); + + // Assert + expectTypeOf(value).toEqualTypeOf(); + }); + }); + + describe("toNullable and toUndefined after transformations", () => { + it("should have correct types after map", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act + const mappedNullable = result.map(x => x.toString()).toNullable(); + const mappedUndefined = result.map(x => x.toString()).toUndefined(); + + // Assert + expectTypeOf(mappedNullable).toEqualTypeOf(); + expectTypeOf(mappedUndefined).toEqualTypeOf(); + }); + + it("should have correct types after flatMap", () => { + // Arrange + const result = ri(ok(42) as Result); + + // Act + const flatMappedNullable = result + .flatMap(x => ok(x.toString()) as Result) + .toNullable(); + const flatMappedUndefined = result + .flatMap(x => ok(x.toString()) as Result) + .toUndefined(); + + // Assert + expectTypeOf(flatMappedNullable).toEqualTypeOf(); + expectTypeOf(flatMappedUndefined).toEqualTypeOf(); + }); + }); +}); diff --git a/src/result/spec/promise.spec.ts b/src/result/spec/promise.spec.ts new file mode 100644 index 0000000..481147d --- /dev/null +++ b/src/result/spec/promise.spec.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Result, ResultPromise, ok, err } from '../'; + +describe('ResultPromise namespace - Runtime Tests', () => { + describe('ResultPromise.from', () => { + it('should return Ok when async function resolves', async () => { + // Arrange + const fn = () => Promise.resolve(42); + + // Act + const result = await ResultPromise.from(fn); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toBe(42); + } + }); + + it('should return Err when async function rejects', async () => { + // Arrange + const error = new Error('async error'); + const fn = () => Promise.reject(error); + + // Act + const result = await ResultPromise.from(fn); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe(error); + } + }); + + it('should apply errorMap when async function rejects and errorMap is provided', async () => { + // Arrange + const fn = () => Promise.reject(new Error('original')); + const errorMap = (e: unknown) => `Mapped: ${(e as Error).message}`; + + // Act + const result = await ResultPromise.from(fn, errorMap); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('Mapped: original'); + } + }); + + it('should not call errorMap when async function succeeds', async () => { + // Arrange + const fn = () => Promise.resolve(42); + const errorMapSpy = vi.fn((e: unknown) => `Mapped: ${e}`); + + // Act + const result = await ResultPromise.from(fn, errorMapSpy); + + // Assert + expect(result.isOk()).toBe(true); + expect(errorMapSpy).not.toHaveBeenCalled(); + }); + + it('should handle async functions returning falsy values', async () => { + // Arrange & Act + const zeroResult = await ResultPromise.from(() => Promise.resolve(0)); + const falseResult = await ResultPromise.from(() => Promise.resolve(false)); + const emptyResult = await ResultPromise.from(() => Promise.resolve('')); + const nullResult = await ResultPromise.from(() => Promise.resolve(null)); + + // Assert + expect(zeroResult.isOk()).toBe(true); + expect(falseResult.isOk()).toBe(true); + expect(emptyResult.isOk()).toBe(true); + expect(nullResult.isOk()).toBe(true); + + if (zeroResult.isOk()) expect(zeroResult.value).toBe(0); + if (falseResult.isOk()) expect(falseResult.value).toBe(false); + if (emptyResult.isOk()) expect(emptyResult.value).toBe(''); + if (nullResult.isOk()) expect(nullResult.value).toBeNull(); + }); + + it('should handle thrown errors (not just rejections)', async () => { + // Arrange + const fn = async () => { + throw new Error('thrown'); + }; + + // Act + const result = await ResultPromise.from(fn); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect((result.err as Error).message).toBe('thrown'); + } + }); + }); + + describe('ResultPromise.all', () => { + it('should return Ok with all values when all promises resolve to Ok', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(ok(2) as Result), + Promise.resolve(ok(3) as Result), + ]; + + // Act + const result = await ResultPromise.all(promises); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual([1, 2, 3]); + } + }); + + it('should return the first Err when any promise resolves to Err', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(err('first error') as Result), + Promise.resolve(ok(3) as Result), + Promise.resolve(err('second error') as Result), + ]; + + // Act + const result = await ResultPromise.all(promises); + + // Assert + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.err).toBe('first error'); + } + }); + + it('should return Ok with empty array for empty input', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await ResultPromise.all(promises); + + // Assert + expect(result.isOk()).toBe(true); + if (result.isOk()) { + expect(result.value).toEqual([]); + } + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.reject(new Error('promise rejected')), + ]; + + // Act & Assert + await expect(ResultPromise.all(promises)).rejects.toThrow('promise rejected'); + }); + }); + + describe('ResultPromise.partition', () => { + it('should partition resolved results into values and errors', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(err('error1') as Result), + Promise.resolve(ok(3) as Result), + Promise.resolve(err('error2') as Result), + ]; + + // Act + const partitioned = await ResultPromise.partition(promises); + + // Assert + expect(partitioned.values).toEqual([1, 3]); + expect(partitioned.errors).toEqual(['error1', 'error2']); + }); + + it('should handle all Ok results', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(ok(2) as Result), + ]; + + // Act + const partitioned = await ResultPromise.partition(promises); + + // Assert + expect(partitioned.values).toEqual([1, 2]); + expect(partitioned.errors).toEqual([]); + }); + + it('should handle all Err results', async () => { + // Arrange + const promises = [ + Promise.resolve(err('error1') as Result), + Promise.resolve(err('error2') as Result), + ]; + + // Act + const partitioned = await ResultPromise.partition(promises); + + // Assert + expect(partitioned.values).toEqual([]); + expect(partitioned.errors).toEqual(['error1', 'error2']); + }); + + it('should handle empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const partitioned = await ResultPromise.partition(promises); + + // Assert + expect(partitioned.values).toEqual([]); + expect(partitioned.errors).toEqual([]); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.reject(new Error('promise rejected')), + ]; + + // Act & Assert + await expect(ResultPromise.partition(promises)).rejects.toThrow('promise rejected'); + }); + }); + + describe('ResultPromise.compact', () => { + it('should extract values from Ok instances', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(err('error') as Result), + Promise.resolve(ok(3) as Result), + ]; + + // Act + const values = await ResultPromise.compact(promises); + + // Assert + expect(values).toEqual([1, 3]); + }); + + it('should return empty array when all are Err', async () => { + // Arrange + const promises = [ + Promise.resolve(err('error1') as Result), + Promise.resolve(err('error2') as Result), + ]; + + // Act + const values = await ResultPromise.compact(promises); + + // Assert + expect(values).toEqual([]); + }); + + it('should return all values when all are Ok', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(ok(2) as Result), + Promise.resolve(ok(3) as Result), + ]; + + // Act + const values = await ResultPromise.compact(promises); + + // Assert + expect(values).toEqual([1, 2, 3]); + }); + + it('should handle empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const values = await ResultPromise.compact(promises); + + // Assert + expect(values).toEqual([]); + }); + + it('should handle falsy Ok values', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(0) as Result), + Promise.resolve(ok(false) as Result), + Promise.resolve(ok('') as Result), + Promise.resolve(err('error') as Result), + ]; + + // Act + const values = await ResultPromise.compact(promises); + + // Assert + expect(values).toEqual([0, false, '']); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.reject(new Error('promise rejected')), + ]; + + // Act & Assert + await expect(ResultPromise.compact(promises)).rejects.toThrow('promise rejected'); + }); + }); + + describe('ResultPromise.some', () => { + it('should return true when at least one promise resolves to Ok', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(err('error') as Result), + ]; + + // Act + const result = await ResultPromise.some(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all promises resolve to Err', async () => { + // Arrange + const promises = [ + Promise.resolve(err('error1') as Result), + Promise.resolve(err('error2') as Result), + ]; + + // Act + const result = await ResultPromise.some(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return false for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await ResultPromise.some(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return true when all promises resolve to Ok', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(ok(2) as Result), + ]; + + // Act + const result = await ResultPromise.some(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.reject(new Error('promise rejected')), + ]; + + // Act & Assert + await expect(ResultPromise.some(promises)).rejects.toThrow('promise rejected'); + }); + }); + + describe('ResultPromise.every', () => { + it('should return true when all promises resolve to Ok', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(ok(2) as Result), + ]; + + // Act + const result = await ResultPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when any promise resolves to Err', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.resolve(err('error') as Result), + ]; + + // Act + const result = await ResultPromise.every(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should return true for empty array', async () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = await ResultPromise.every(promises); + + // Assert + expect(result).toBe(true); + }); + + it('should return false when all promises resolve to Err', async () => { + // Arrange + const promises = [ + Promise.resolve(err('error1') as Result), + Promise.resolve(err('error2') as Result), + ]; + + // Act + const result = await ResultPromise.every(promises); + + // Assert + expect(result).toBe(false); + }); + + it('should reject if any input promise rejects', async () => { + // Arrange + const promises = [ + Promise.resolve(ok(1) as Result), + Promise.reject(new Error('promise rejected')), + ]; + + // Act & Assert + await expect(ResultPromise.every(promises)).rejects.toThrow('promise rejected'); + }); + }); + + describe('Integration tests', () => { + it('should work with ResultPromise.from and ResultPromise.all together', async () => { + // Arrange + const fetchValue = (n: number) => + ResultPromise.from(() => Promise.resolve(n * 2), () => 'fetch error'); + + // Act + const allResult = await ResultPromise.all([ + fetchValue(1), + fetchValue(2), + fetchValue(3), + ]); + + // Assert + expect(allResult.isOk()).toBe(true); + if (allResult.isOk()) { + expect(allResult.value).toEqual([2, 4, 6]); + } + }); + + it('should work with ResultPromise.from and ResultPromise.partition together', async () => { + // Arrange + const tryParse = (s: string) => + ResultPromise.from( + async () => { + const n = parseInt(s); + if (isNaN(n)) throw new Error('NaN'); + return n; + }, + () => `Invalid: ${s}` + ); + + // Act + const partitioned = await ResultPromise.partition([ + tryParse('1'), + tryParse('invalid'), + tryParse('3'), + tryParse('also invalid'), + ]); + + // Assert + expect(partitioned.values).toEqual([1, 3]); + expect(partitioned.errors).toEqual(['Invalid: invalid', 'Invalid: also invalid']); + }); + }); +}); diff --git a/src/result/spec/promise.type-spec.ts b/src/result/spec/promise.type-spec.ts new file mode 100644 index 0000000..65cbefd --- /dev/null +++ b/src/result/spec/promise.type-spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, ResultPromise, ResultPartitionResult, ok, err } from "../"; + +describe("ResultPromise namespace - Type Tests", () => { + describe("ResultPromise.from", () => { + it("should infer Ok type from async function return type", () => { + // Arrange & Act + const numberResult = ResultPromise.from(() => Promise.resolve(42)); + const stringResult = ResultPromise.from(() => Promise.resolve("hello")); + const objectResult = ResultPromise.from(() => Promise.resolve({ id: 1 })); + + // Assert + expectTypeOf(numberResult).toEqualTypeOf>>(); + expectTypeOf(stringResult).toEqualTypeOf>>(); + expectTypeOf(objectResult).toEqualTypeOf>>(); + }); + + it("should infer error type from errorMap", () => { + // Arrange & Act + const stringErrResult = ResultPromise.from( + () => Promise.resolve(42), + () => "error" + ); + const numberErrResult = ResultPromise.from( + () => Promise.resolve(42), + () => 404 + ); + + // Assert + expectTypeOf(stringErrResult).toEqualTypeOf>>(); + expectTypeOf(numberErrResult).toEqualTypeOf>>(); + }); + + it("should accept unknown error type in errorMap parameter", () => { + // Arrange & Act + const result = ResultPromise.from( + () => Promise.resolve(42), + (error: unknown) => `Error: ${error}` + ); + + // Assert + expectTypeOf(result).toEqualTypeOf>>(); + }); + }); + + describe("ResultPromise.all", () => { + it("should return Promise>", () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const allResult = ResultPromise.all(promises); + + // Assert + expectTypeOf(allResult).toEqualTypeOf>>(); + }); + + it("should handle complex types", () => { + // Arrange + type User = { id: number; name: string }; + const promises: Promise>[] = []; + + // Act + const allResult = ResultPromise.all(promises); + + // Assert + expectTypeOf(allResult).toEqualTypeOf>>(); + }); + }); + + describe("ResultPromise.partition", () => { + it("should return Promise>", () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const partitioned = ResultPromise.partition(promises); + + // Assert + expectTypeOf(partitioned).toEqualTypeOf>>(); + }); + + it("should handle complex types", () => { + // Arrange + type User = { id: number; name: string }; + type ApiError = { code: number; message: string }; + const promises: Promise>[] = []; + + // Act + const partitioned = ResultPromise.partition(promises); + + // Assert + expectTypeOf(partitioned).toEqualTypeOf>>(); + }); + }); + + describe("ResultPromise.compact", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const compacted = ResultPromise.compact(promises); + + // Assert + expectTypeOf(compacted).toEqualTypeOf>(); + }); + + it("should handle complex types", () => { + // Arrange + type User = { id: number; name: string }; + const promises: Promise>[] = []; + + // Act + const compacted = ResultPromise.compact(promises); + + // Assert + expectTypeOf(compacted).toEqualTypeOf>(); + }); + }); + + describe("ResultPromise.some", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = ResultPromise.some(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should accept Promise>[]", () => { + // Assert + expectTypeOf(ResultPromise.some).toBeCallableWith([] as Promise>[]); + }); + }); + + describe("ResultPromise.every", () => { + it("should return Promise", () => { + // Arrange + const promises: Promise>[] = []; + + // Act + const result = ResultPromise.every(promises); + + // Assert + expectTypeOf(result).toEqualTypeOf>(); + }); + + it("should accept Promise>[]", () => { + // Assert + expectTypeOf(ResultPromise.every).toBeCallableWith([] as Promise>[]); + }); + }); +}); diff --git a/src/result/spec/result.spec.ts b/src/result/spec/result.spec.ts new file mode 100644 index 0000000..790f5b5 --- /dev/null +++ b/src/result/spec/result.spec.ts @@ -0,0 +1,324 @@ +import { describe, it, expect } from 'vitest'; +import { Result, Ok, Err, ok, err } from '../'; + +describe('Result constructors and factories - Runtime Tests', () => { + describe('Ok constructor', () => { + it('should create Ok instance with provided value', () => { + // Arrange + const value = 42; + + // Act + const result = new Ok(value); + + // Assert + expect(result).toBeInstanceOf(Ok); + expect(result.value).toBe(value); + }); + + it('should store different types of values correctly', () => { + // Arrange + const numberValue = 42; + const stringValue = 'hello'; + const objectValue = { id: 1, name: 'test' }; + const arrayValue = [1, 2, 3]; + const functionValue = (x: number) => x * 2; + + // Act + const numberOk = new Ok(numberValue); + const stringOk = new Ok(stringValue); + const objectOk = new Ok(objectValue); + const arrayOk = new Ok(arrayValue); + const functionOk = new Ok(functionValue); + + // Assert + expect(numberOk.value).toBe(numberValue); + expect(stringOk.value).toBe(stringValue); + expect(objectOk.value).toBe(objectValue); + expect(arrayOk.value).toBe(arrayValue); + expect(functionOk.value).toBe(functionValue); + }); + + it('should handle null and undefined values', () => { + // Arrange & Act + const nullOk = new Ok(null); + const undefinedOk = new Ok(undefined); + + // Assert + expect(nullOk.value).toBeNull(); + expect(undefinedOk.value).toBeUndefined(); + }); + + it('should implement ResultInterface methods', () => { + // Arrange + const okInstance = new Ok(42); + + // Assert + expect(typeof okInstance.isOk).toBe('function'); + expect(typeof okInstance.isErr).toBe('function'); + expect(typeof okInstance.map).toBe('function'); + expect(typeof okInstance.mapErr).toBe('function'); + expect(typeof okInstance.flatMap).toBe('function'); + expect(typeof okInstance.flatMapErr).toBe('function'); + expect(typeof okInstance.match).toBe('function'); + expect(typeof okInstance.filter).toBe('function'); + expect(typeof okInstance.toNullable).toBe('function'); + expect(typeof okInstance.toUndefined).toBe('function'); + expect(typeof okInstance.toString).toBe('function'); + }); + + it('should correctly identify as Ok', () => { + // Arrange + const okInstance = new Ok(42); + + // Act & Assert + expect(okInstance.isOk()).toBe(true); + expect(okInstance.isErr()).toBe(false); + }); + }); + + describe('Err constructor', () => { + it('should create Err instance with provided error', () => { + // Arrange + const error = 'something went wrong'; + + // Act + const result = new Err(error); + + // Assert + expect(result).toBeInstanceOf(Err); + expect(result.err).toBe(error); + }); + + it('should store different types of errors correctly', () => { + // Arrange + const stringError = 'error message'; + const numberError = 404; + const objectError = { code: 'NOT_FOUND', message: 'Not found' }; + const errorInstance = new Error('Test error'); + + // Act + const stringErr = new Err(stringError); + const numberErr = new Err(numberError); + const objectErr = new Err(objectError); + const errorErr = new Err(errorInstance); + + // Assert + expect(stringErr.err).toBe(stringError); + expect(numberErr.err).toBe(numberError); + expect(objectErr.err).toBe(objectError); + expect(errorErr.err).toBe(errorInstance); + }); + + it('should implement ResultInterface methods', () => { + // Arrange + const errInstance = new Err('error'); + + // Assert + expect(typeof errInstance.isOk).toBe('function'); + expect(typeof errInstance.isErr).toBe('function'); + expect(typeof errInstance.map).toBe('function'); + expect(typeof errInstance.mapErr).toBe('function'); + expect(typeof errInstance.flatMap).toBe('function'); + expect(typeof errInstance.flatMapErr).toBe('function'); + expect(typeof errInstance.match).toBe('function'); + expect(typeof errInstance.filter).toBe('function'); + expect(typeof errInstance.toNullable).toBe('function'); + expect(typeof errInstance.toUndefined).toBe('function'); + expect(typeof errInstance.toString).toBe('function'); + }); + + it('should correctly identify as Err', () => { + // Arrange + const errInstance = new Err('error'); + + // Act & Assert + expect(errInstance.isOk()).toBe(false); + expect(errInstance.isErr()).toBe(true); + }); + }); + + describe('ok() factory function', () => { + it('should create Ok instance with provided value', () => { + // Arrange + const value = 42; + + // Act + const result = ok(value); + + // Assert + expect(result).toBeInstanceOf(Ok); + expect(result.value).toBe(value); + }); + + it('should work with various data types', () => { + // Arrange + const testCases = [ + 42, + 'hello', + true, + { id: 1, name: 'test' }, + [1, 2, 3], + null, + undefined, + (x: number) => x * 2 + ]; + + testCases.forEach(testValue => { + // Act + const result = ok(testValue); + + // Assert + expect(result).toBeInstanceOf(Ok); + expect(result.value).toBe(testValue); + expect(result.isOk()).toBe(true); + expect(result.isErr()).toBe(false); + }); + }); + + it('should create new instances for each call', () => { + // Arrange + const value = 42; + + // Act + const first = ok(value); + const second = ok(value); + + // Assert + expect(first).toBeInstanceOf(Ok); + expect(second).toBeInstanceOf(Ok); + expect(first).not.toBe(second); + expect(first.value).toBe(second.value); + }); + }); + + describe('err() factory function', () => { + it('should create Err instance with provided error', () => { + // Arrange + const error = 'failed'; + + // Act + const result = err(error); + + // Assert + expect(result).toBeInstanceOf(Err); + expect(result.err).toBe(error); + }); + + it('should work with various error types', () => { + // Arrange + const testCases = [ + 'error message', + 404, + new Error('Test error'), + { code: 'ERR', message: 'Something failed' }, + null, + undefined, + ]; + + testCases.forEach(testError => { + // Act + const result = err(testError); + + // Assert + expect(result).toBeInstanceOf(Err); + expect(result.err).toBe(testError); + expect(result.isOk()).toBe(false); + expect(result.isErr()).toBe(true); + }); + }); + + it('should create new instances for each call', () => { + // Arrange + const error = 'error'; + + // Act + const first = err(error); + const second = err(error); + + // Assert + expect(first).toBeInstanceOf(Err); + expect(second).toBeInstanceOf(Err); + expect(first).not.toBe(second); + expect(first.err).toBe(second.err); + }); + }); + + describe('toString()', () => { + it('should return "Ok(...)" for Ok instances', () => { + // Arrange & Act & Assert + expect(ok(42).toString()).toBe('Ok(42)'); + expect(ok('hello').toString()).toBe('Ok(hello)'); + expect(ok(true).toString()).toBe('Ok(true)'); + expect(ok(null).toString()).toBe('Ok(null)'); + expect(ok(undefined).toString()).toBe('Ok(undefined)'); + }); + + it('should return "Err(...)" for Err instances', () => { + // Arrange & Act & Assert + expect(err('error').toString()).toBe('Err(error)'); + expect(err(404).toString()).toBe('Err(404)'); + expect(err(null).toString()).toBe('Err(null)'); + expect(err(undefined).toString()).toBe('Err(undefined)'); + }); + + it('should handle object values in toString()', () => { + // Arrange + const obj = { id: 1 }; + + // Act & Assert + expect(ok(obj).toString()).toBe('Ok([object Object])'); + expect(err(obj).toString()).toBe('Err([object Object])'); + }); + }); + + describe('Integration tests', () => { + it('should allow creating mixed arrays of Results', () => { + // Arrange & Act + const results: Result[] = [ + ok(1) as Result, + ok(2) as Result, + err('error') as Result, + ok(3) as Result + ]; + + // Assert + expect(results).toHaveLength(4); + expect(results[0].isOk()).toBe(true); + expect(results[1].isOk()).toBe(true); + expect(results[2].isErr()).toBe(true); + expect(results[3].isOk()).toBe(true); + }); + + it('should enable type narrowing in runtime', () => { + // Arrange + const result: Result = ok('hello') as Result; + + // Act & Assert + if (result.isOk()) { + expect(result.value).toBe('hello'); + expect(typeof result.value).toBe('string'); + } else { + fail('Expected result to be Ok'); + } + }); + + it('should handle edge cases correctly', () => { + // Arrange & Act + const zeroOk = ok(0); + const falseOk = ok(false); + const emptyOk = ok(''); + const emptyArrayOk = ok([]); + + // Assert - All should be Ok instances even with "falsy" values + expect(zeroOk.isOk()).toBe(true); + expect(falseOk.isOk()).toBe(true); + expect(emptyOk.isOk()).toBe(true); + expect(emptyArrayOk.isOk()).toBe(true); + + expect(zeroOk.value).toBe(0); + expect(falseOk.value).toBe(false); + expect(emptyOk.value).toBe(''); + expect(emptyArrayOk.value).toEqual([]); + }); + }); +}); diff --git a/src/result/spec/result.type-spec.ts b/src/result/spec/result.type-spec.ts new file mode 100644 index 0000000..d79a9f9 --- /dev/null +++ b/src/result/spec/result.type-spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { Result, Ok, Err, ok, err, ResultInterface } from "../"; + +describe("Result constructors and factories - Type Tests", () => { + describe("Ok constructor", () => { + it("should create Ok with correct type inference", () => { + // Arrange & Act + const numberOk = new Ok(42); + const stringOk = new Ok("hello"); + const objectOk = new Ok({ id: 1, name: "test" }); + const arrayOk = new Ok([1, 2, 3]); + + // Assert + expectTypeOf(numberOk).toEqualTypeOf>(); + expectTypeOf(stringOk).toEqualTypeOf>(); + expectTypeOf(objectOk).toEqualTypeOf>(); + expectTypeOf(arrayOk).toEqualTypeOf>(); + }); + + it("should implement ResultInterface with correct type parameters", () => { + // Arrange + const okPrimitive = new Ok(42); + const okObject = new Ok({ value: "test" }); + + // Assert + expectTypeOf(okPrimitive).toMatchTypeOf>(); + expectTypeOf(okObject).toMatchTypeOf>(); + }); + + it("should be assignable to Result", () => { + // Arrange + const numberOk = new Ok(42); + const stringOk = new Ok("hello"); + + // Assert + expectTypeOf(numberOk).toMatchTypeOf>(); + expectTypeOf(stringOk).toMatchTypeOf>(); + }); + + it("should preserve complex generic types", () => { + // Arrange + type ComplexType = { data: Array<{ id: number; tags: string[] }> }; + const complexValue: ComplexType = { data: [{ id: 1, tags: ["a", "b"] }] }; + const complexOk = new Ok(complexValue); + + // Assert + expectTypeOf(complexOk).toEqualTypeOf>(); + expectTypeOf(complexOk).toMatchTypeOf>(); + }); + + it("should handle nullable and undefined types correctly", () => { + // Arrange + const nullableOk = new Ok(null); + const undefinedOk = new Ok(undefined); + const unionOk = new Ok("hello" as string | null); + + // Assert + expectTypeOf(nullableOk).toEqualTypeOf>(); + expectTypeOf(undefinedOk).toEqualTypeOf>(); + expectTypeOf(unionOk).toEqualTypeOf>(); + }); + }); + + describe("Err constructor", () => { + it("should create Err with correct type inference", () => { + // Arrange & Act + const stringErr = new Err("error"); + const numberErr = new Err(404); + const objectErr = new Err({ code: "NOT_FOUND", message: "Not found" }); + + // Assert + expectTypeOf(stringErr).toEqualTypeOf>(); + expectTypeOf(numberErr).toEqualTypeOf>(); + expectTypeOf(objectErr).toEqualTypeOf>(); + }); + + it("should implement ResultInterface with correct type parameters", () => { + // Arrange + const errInstance = new Err("error"); + + // Assert + expectTypeOf(errInstance).toMatchTypeOf>(); + }); + + it("should be assignable to Result for any T", () => { + // Arrange + const errInstance = new Err("error"); + + // Assert + expectTypeOf(errInstance).toMatchTypeOf>(); + expectTypeOf(errInstance).toMatchTypeOf>(); + }); + + it("should have .err property with correct type", () => { + // Arrange + const stringErr = new Err("error"); + const objectErr = new Err({ code: 500, message: "Internal error" }); + + // Assert + expectTypeOf(stringErr.err).toEqualTypeOf(); + expectTypeOf(objectErr.err).toEqualTypeOf<{ code: number; message: string }>(); + }); + }); + + describe("ok() factory function", () => { + it("should infer correct return type from value", () => { + // Arrange & Act + const numberOk = ok(42); + const stringOk = ok("hello"); + const booleanOk = ok(true); + const objectOk = ok({ id: 1 }); + const arrayOk = ok([1, 2, 3]); + + // Assert + expectTypeOf(numberOk).toEqualTypeOf>(); + expectTypeOf(stringOk).toEqualTypeOf>(); + expectTypeOf(booleanOk).toEqualTypeOf>(); + expectTypeOf(objectOk).toEqualTypeOf>(); + expectTypeOf(arrayOk).toEqualTypeOf>(); + }); + + it("should preserve literal types", () => { + // Arrange & Act + const literalNumber = ok(42 as const); + const literalString = ok("hello" as const); + const literalObject = ok({ type: "user" } as const); + + // Assert + expectTypeOf(literalNumber).toEqualTypeOf>(); + expectTypeOf(literalString).toEqualTypeOf>(); + expectTypeOf(literalObject).toEqualTypeOf>(); + }); + + it("should handle function types correctly", () => { + // Arrange + const simpleFunc = (x: number) => x * 2; + const asyncFunc = async (x: string) => x.toUpperCase(); + + // Act + const funcOk = ok(simpleFunc); + const asyncFuncOk = ok(asyncFunc); + + // Assert + expectTypeOf(funcOk).toEqualTypeOf number>>(); + expectTypeOf(asyncFuncOk).toEqualTypeOf Promise>>(); + }); + + it("should be callable with any type", () => { + // Assert + expectTypeOf(ok).toBeCallableWith(42); + expectTypeOf(ok).toBeCallableWith("string"); + expectTypeOf(ok).toBeCallableWith(null); + expectTypeOf(ok).toBeCallableWith(undefined); + expectTypeOf(ok).toBeCallableWith({ complex: { nested: "object" } }); + }); + }); + + describe("err() factory function", () => { + it("should infer correct return type from error", () => { + // Arrange & Act + const stringErr = err("error"); + const numberErr = err(404); + const objectErr = err({ code: "ERR", message: "failed" }); + + // Assert + expectTypeOf(stringErr).toEqualTypeOf>(); + expectTypeOf(numberErr).toEqualTypeOf>(); + expectTypeOf(objectErr).toEqualTypeOf>(); + }); + + it("should be callable with any type", () => { + // Assert + expectTypeOf(err).toBeCallableWith("error"); + expectTypeOf(err).toBeCallableWith(404); + expectTypeOf(err).toBeCallableWith(new Error("test")); + expectTypeOf(err).toBeCallableWith(null); + }); + + it("should be assignable to any Result", () => { + // Arrange + const errResult = err("error"); + + // Assert + expectTypeOf(errResult).toMatchTypeOf>(); + expectTypeOf(errResult).toMatchTypeOf>(); + expectTypeOf(errResult).toMatchTypeOf>(); + }); + }); + + describe("Type narrowing with constructors", () => { + it("should enable proper type narrowing with isOk/isErr", () => { + // Arrange + const result: Result = ok(42) as Result; + + // Act & Assert + if (result.isOk()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.value).toEqualTypeOf(); + } + + if (result.isErr()) { + expectTypeOf(result).toEqualTypeOf>(); + expectTypeOf(result.err).toEqualTypeOf(); + } + }); + }); +}); diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..a4a28d2 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,5 @@ +/** + * Checks if a value is nullish (null or undefined). + */ +export const isNullish = (value: unknown): value is null | undefined => + value === null || value === undefined; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..81da3d6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["es2022"], + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.type-spec.ts", "src/**/*.test.ts"] +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 0000000..026f85f --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["src/**/*.spec.ts", "src/**/*.d-spec.ts", "src/**/*.test.ts", "src/**/*.infer.ts"] +} diff --git a/tsconfig.spec.json b/tsconfig.spec.json new file mode 100644 index 0000000..2a935b3 --- /dev/null +++ b/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["vitest/globals", "node"], + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..e2f0728 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + dts: true, + sourcemap: true, + clean: true, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a08af4a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + projects: [ + { + test: { + name: 'unit', + include: ['src/**/*.spec.ts'], + }, + }, + { + test: { + name: 'types', + include: ['src/**/*.type-spec.ts'], + typecheck: { + enabled: true, + only: true, + include: ['src/**/*.type-spec.ts'], + tsconfig: './tsconfig.json', + }, + }, + }, + ], + }, +});