diff --git a/changelog/unreleased/1558.md b/changelog/unreleased/1558.md new file mode 100644 index 0000000000..659c268a46 --- /dev/null +++ b/changelog/unreleased/1558.md @@ -0,0 +1,3 @@ +- `types/nullable`: `match` accepts independent result types for its two + branches and no longer widens the result to `Nullable` when they agree. + `map` is now derived from it; its own signature is unchanged diff --git a/fjs/types/nullable/module.f.mjs b/fjs/types/nullable/module.f.mjs index e169fcdfc9..f29e40ca57 100644 --- a/fjs/types/nullable/module.f.mjs +++ b/fjs/types/nullable/module.f.mjs @@ -11,14 +11,31 @@ import { assert } from '../../asserts/module.f.mjs' import { fn } from '../function/module.f.mjs' /** - * @type {(f: (value: T) => R) => (value: Nullable) => Nullable} + * Folds a `Nullable` into a single value: `f` for a present value, `none` + * for `null`. + * + * The two branches carry independent result types, and `R2` lives on its own + * curry step on purpose. With `T`, `R1` and `R2` all on the outer generic they + * are instantiated together at `match(f)` — before `none` exists — so `R2` has + * nothing to infer from and collapses to `unknown`. Inferring it at the second + * call instead is what lets {@link map} be derived below. + * + * @type {(f: (_: T) => R1) => (none: () => R2) => (_: Nullable) => R1 | R2} */ -export const map = f => value => value === null ? null : f(value) +export const match = f => none => value => value === null ? none() : f(value) + +/** The absent branch `map` fixes `match`'s `none` to. */ +const noneIsNull = () => null /** - * @type {(f: (_: T) => R) => (none: () => R) => (_: Nullable) => Nullable} + * Projects the present value of a `Nullable`, passing `null` through. + * + * `map` is `match` with the absent branch fixed to `null`, so the + * `value === null` guard is written once, in `match`. + * + * @type {(f: (value: T) => R) => (value: Nullable) => Nullable} */ -export const match = f => none => value => value === null ? none() : f(value) +export const map = f => match(f)(noneIsNull) /** * @type {(value: Nullable) => Option} diff --git a/fjs/types/nullable/proof.f.mjs b/fjs/types/nullable/proof.f.mjs index 60406206a2..b60aa97c97 100644 --- a/fjs/types/nullable/proof.f.mjs +++ b/fjs/types/nullable/proof.f.mjs @@ -1,3 +1,8 @@ +/** + * @import { Assert } from '../../asserts/types.ts' + * @import { Equal } from '../ts/types.ts' + */ + import { fromUndefined, map, match, toOption } from './module.f.mjs' import { assert, assertEq } from '../../asserts/module.f.mjs' @@ -24,6 +29,16 @@ export const proof = [ assertEq(double(3), 6) assertEq(double(null), -1) }, + () => { + // The two branches carry independent result types: `describe` is + // `(_: Nullable) => number | string`, not one unified `R`. + /** @type {(v: number) => number} */ + const twice = v => v * 2 + const describe = match(twice)(() => 'none') + /** @typedef {Assert, number | string>>} _Branches */ + assertEq(describe(3), 6) + assertEq(describe(null), 'none') + }, () => { assertEq(fromUndefined(undefined), null, 0) assertEq(fromUndefined(5), 5, 1) diff --git a/fjs/types/nullable/todo/map-from-match.md b/fjs/types/nullable/todo/map-from-match.md deleted file mode 100644 index b7cb35b5af..0000000000 --- a/fjs/types/nullable/todo/map-from-match.md +++ /dev/null @@ -1,78 +0,0 @@ -## map-from-match. Derive `map` from `match` so the null-guard lives once - -**Priority:** P5 -**Status:** open - -### Problem - -`fjs/types/nullable/module.f.mjs:18-23` writes the null-dispatch guard twice: - -```js -export const map = f => value => value === null ? null : f(value) - -export const match = f => none => value => value === null ? none() : f(value) -``` - -`map` is exactly `match` with the `none` branch fixed to `() => null`. This -module is the codebase's canonical home for absence handling — the -`at-nullable-map` todo routes consumers *to* it — yet internally the -`value === null ? … : f(value)` projection is duplicated between its own two -combinators. - -### Proposal - -`match`'s current JSDoc type ties both branches to one `R`: -`(f: (_: T) => R) => (none: () => R) => (_: Nullable) => Nullable`. -Deriving `map = f => match(f)(() => null)` against that type does **not** -type-check: `none: () => R` requires `null` assignable to the generic `R`, -which fails (TS2322) for an unconstrained `R`, and a `.mjs` call site has no -JSDoc syntax to explicitly instantiate `match`'s `R` at `Nullable` the way -a `.ts` cast could. - -Tightening `match`'s return type is therefore a **required first step**, not -an optional cleanup — and it must decouple the two branches, not just narrow -the existing shared `R`, since `f` and `none` genuinely return different -types in the derivation (`R` vs. `null`). - -**`R1`/`R2` must live on separate curry steps, not the same generic -function.** All of `T`, `R1`, `R2` on one outer generic -(`(f: (_: T) => R1) => (none: () => R2) => …`) are instantiated -together at the *first* call `match(f)` — before `none` is even supplied — -so `R2` has no argument to infer from at that point and collapses to -`unknown`; `match(f)(() => null)` then types as -`(_: Nullable) => unknown`, which still fails against `map`'s declared -`Nullable` return. `R2` needs its own generic step, inferred from `none` -at the *second* call: - -```js -/** - * @type {(f: (_: T) => R1) => (none: () => R2) => (_: Nullable) => R1 | R2} - */ -export const match = f => none => value => value === null ? none() : f(value) - -/** - * @type {(f: (value: T) => R) => (value: Nullable) => Nullable} - */ -export const map = f => match(f)(() => null) -``` - -Now `T`/`R1` are inferred at `match(f)` (from `f`), and `R2` is inferred -separately at `(() => null)` (from `none`), giving `R1 | R2` = `R | null` = -`Nullable` — with no cast needed. Existing `match` call sites, which -supply both `f` and `none` together, still unify to the same result and are -unaffected. - -### Tasks - -- [ ] Generalize `match`'s JSDoc type to independent `R1`/`R2` branch types - (required for the derivation to type-check). -- [ ] Derive `map` from `match`. -- [ ] `npx tsc`, `fjs t`; nullable proofs pass unchanged. - -### Related - -- [../../ordered_map/todo/at-nullable-map.md](../../ordered_map/todo/at-nullable-map.md) - — routes a consumer through `nullable.map`; same caliber, different - direction (consumer→combinator vs inside the combinator pair). -- [../../function/operator/todo/derive-concat-from-join.md](../../function/operator/todo/derive-concat-from-join.md) - — the same derive-one-from-its-sibling pattern elsewhere.