From 5a9deb23b22b99086cae94f63d11f049c825b5ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:00:45 +0000 Subject: [PATCH 1/2] types/nullable: derive map from match so the null guard lives once map was `value === null ? null : f(value)` and match was `value === null ? none() : f(value)` -- the same null dispatch written twice in the module that is the codebase's canonical home for absence handling. Deriving map required generalizing match's type first: with both branches tied to one R, `none: () => R` demands null be assignable to an unconstrained R. The two result types also need separate curry steps -- on one outer generic they instantiate together at match(f), before `none` exists, so R2 collapses to unknown. With R2 inferred at the second call, match(f)(noneIsNull) types as R | null and map's public signature is unchanged, byte-identical in the emitted declarations. match's own type is strictly more general than before: independent branches, and a result that no longer carries a spurious `| null` when both branches agree. Existing callers are unaffected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- fjs/types/nullable/module.f.mjs | 25 ++++++-- fjs/types/nullable/proof.f.mjs | 15 +++++ fjs/types/nullable/todo/map-from-match.md | 78 ----------------------- 3 files changed, 36 insertions(+), 82 deletions(-) delete mode 100644 fjs/types/nullable/todo/map-from-match.md 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. From ff5a0f23c48ec2bd7fc9eb07eb5c205efab2dbc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 19:01:29 +0000 Subject: [PATCH 2/2] changelog: entry for the nullable map/match derivation Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs --- changelog/unreleased/1558.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/unreleased/1558.md 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