Skip to content

types/nullable: derive map from match so the null guard lives once - #1558

Merged
sergey-shandar merged 4 commits into
mainfrom
claude/todo-implementation-rifq4g
Aug 14, 2026
Merged

types/nullable: derive map from match so the null guard lives once#1558
sergey-shandar merged 4 commits into
mainfrom
claude/todo-implementation-rifq4g

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

Implements fjs/types/nullable/todo/map-from-match.md (deleted here).

Why

The module that is the codebase's canonical home for absence handling wrote the null dispatch twice:

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 absent branch fixed to () => null.

What

match moves first and map derives from it:

export const match = f => none => value => value === null ? none() : f(value)

const noneIsNull = () => null

export const map = f => match(f)(noneIsNull)

Deriving it required generalizing match's type first — this is a prerequisite, not a cleanup. With both branches tied to one R, none: () => R demands null be assignable to an unconstrained R (TS2322), and a .mjs call site has no syntax to instantiate R at Nullable<R> explicitly.

The two result types also need separate curry steps. On one outer generic they are instantiated together at match(f) — before none is supplied — so R2 has nothing to infer from and collapses to unknown. Inferring it at the second call gives R1 | R2 = R | null:

/** @type {<T, R1>(f: (_: T) => R1) => <R2>(none: () => R2) => (_: Nullable<T>) => R1 | R2} */

noneIsNull is hoisted to module scope rather than written inline, per §6.3 — it captures nothing.

Effect on the public API

map's emitted declaration is byte-identical to before (diffed .d.mts output across the change). match's is strictly more general:

// before
match: <T, R>(f: (_: T) => R) => (none: () => R) => (_: Nullable<T>) => Nullable<R>
// after
match: <T, R1>(f: (_: T) => R1) => <R2>(none: () => R2) => (_: Nullable<T>) => R1 | R2

More permissive on input (branches no longer forced to unify) and more precise on output (a caller whose branches agree gets R, not a spurious R | null). No caller can break; the repo-wide npx tsc confirms it.

Verification

  • npx tsc clean.
  • fjs test: 2692 pass, 0 fail.
  • npm run cov: fjs/types/nullable/module.f.mjs at 100% lines/branches/functions.
  • New proof case pins the independent-branch typing with Assert<Equal<ReturnType<typeof describe>, number | string>>, and I negative-tested it — narrowing the claim to number makes tsc fail, so the assertion bites rather than passing vacuously.

🤖 Generated with Claude Code

https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs


Generated by Claude Code

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ff5a0f23c48ec2bd7fc9eb07eb5c205efab2dbc7, baseline origin/main = cb1fcdc457ecb5ba3a20d994e4ba4e0c31d9577e (also the merge-base).

The derivation is equivalent

map's old body was f => value => value === null ? null : f(value); the new one is match(f)(noneIsNull), which inlines to value => value === null ? noneIsNull() : f(value) with noneIsNull = () => null. So the substitution is structurally exact, but I swept it anyway rather than trusting the read:

  • Targeted domain sweep, old vs new over null, undefined, 0, -0, '', false, NaN, 1, 'x', true, [], {}, 0n, a symbol, a function, ±Infinity, Date, RegExp, Map: 0 divergences in result (compared with Object.is), in thrown-error message, and in mapper call count. undefined in particular goes down the present branch in both, unchanged.
  • 200,000 randomized probes over that value pool crossed with six mappers (identity, wrapping, () => null, () => undefined, typeof, and one that throws): 0 divergences, and 0 cases where the mapper was invoked on null.
  • Explicit null check: map(() => { invoked = true })(null) returns null with invoked === false.
  • A throwing mapper propagates identically on the present branch and is not reached on the null branch in either form.
  • Negative control: substituting a deliberately wrong map (value === undefined ? null : f(value)) into the same comparator produces a divergence, so the harness can in fact see one.

No divergence to classify as regression or latent-bug-fix — unlike #1533, the two forms here are the same expression, closer to #1537.

Public surface, read from the emitted declarations

Not from bin/extract.mjs. npm run prepack in both trees, then fjs/types/nullable/module.f.d.mts directly:

  • mapbyte-identical to main: export declare const map: <T, R>(f: (value: T) => R) => (value: Nullable<T>) => Nullable<R>;. The changelog's "its own signature is unchanged" checks out.
  • match<T, R>(f: (_: T) => R) => (none: () => R) => (_: Nullable<T>) => Nullable<R> becomes <T, R1>(f: (_: T) => R1) => <R2>(none: () => R2) => (_: Nullable<T>) => R1 | R2. That loosens the none parameter and narrows the result, so it is not breaking for consumers, and the **BREAKING CHANGES:** prefix is correctly absent. match's runtime body is untouched.
  • No new export type, so no §6.2 _-prefix question. The @typedef the proof adds is _Branches — underscore-prefixed and local to a proof.
  • @module header and its blank line survive into the .d.mts unchanged.

The design rationale is load-bearing, and I checked it

The new proof case's Assert<Equal<ReturnType<typeof describe>, number | string>> is live, not decorative: changing it to number | boolean produces error TS2344: Type 'false' does not satisfy the constraint 'true'.

And the "R2 must live on its own curry step" claim in the doc comment (inherited from the deleted todo) reproduces: moving R2 onto the outer generic — <T, R1, R2>(f: (_: T) => R1) => (none: () => R2) => … — makes that same assertion fail, because R2 collapses to unknown at match(f). So the comment describes a real constraint rather than a rationalization.

Gates

npx tsc --noEmit 0. npm run prepack 0 from a clean tree, both trees. npm test 2692 pass / 0 fail vs 2691 on main — exactly the one new proof case. linkcheck broken-link sets are identical to main, and nothing else in the repo referenced the deleted todo/map-from-match.md, so removing it strands nothing.

One thing to fix

changelog/unreleased/1558.md has no PR link:

- `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

Both sibling entries in changelog/unreleased/ end with one — 1554.md and 1556.md — as does 1559.md on the open PR next door. §8.3 has entries link the PR and only the PR, so this wants a trailing

  [#1558](https://github.com/functionalscript/functionalscript/pull/1558)

Purely mechanical, and the only thing I found. Everything else here verifies clean.

@sergey-shandar

Copy link
Copy Markdown
Contributor Author

One thing to fix

changelog/unreleased/1558.md has no PR link:

We don't need a PR number inside the changelog note. New AGENTS.md:
"Write entries in the Topic: short description style, with no PR number or link inside the file — the file name already carries the number, and a renderer derives the link from it."

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript 7205fdb Commit Preview URL

Branch Preview URL
Aug 14 2026, 08:10 PM

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 7205fdb9001a912061c9eba8f4ab249a52e6a02c, rebased onto origin/main 2df303a9. Approving — and withdrawing my earlier finding, which the rebase has made wrong.

The changelog finding is resolved, but not the way I asked for

At ff5a0f23 I said changelog/unreleased/1558.md was missing its [#1558](…/pull/1558) link. 1558.md is byte-identical now — the link is still absent — and that is now correct, because the rule inverted underneath it. The 0.45.0 release commit 8804e783 (#1555) rewrote §8.3:

- **Link the PR, and nothing else.** The single link in an entry must point to
+- **No links.** The file name is the PR number, so an entry neither repeats it
+  nor links to the PR.

8804e783 is four first-parent commits back from the tip, so the base this PR sat on when I reviewed it still carried the old rule; the finding was right then and is wrong now. 1558.md as written is exactly what the current §8.3 and changelog/README.md ask for: Topic: short description form, no link, no heading, list items only, ~190 characters against the ~250 stated budget, and no **BREAKING CHANGES:** prefix — correctly, since loosening a parameter and narrowing a return break no caller.

Worth flagging so nobody "fixes" it: the three sibling entries 1554.md, 1556.md and 1559.md all still carry inline PR links, so 1558.md is the odd one out by eye while being the only one of the four that matches the current rule. Those three are already merged and not this PR's business.

Re-verified against the new base

fjs/types/nullable/module.f.mjs and proof.f.mjs are unchanged since ff5a0f23, so the behavioural work I did then stands: 0 divergences between old and new map over a targeted falsy/edge sweep plus 200,000 randomized probes comparing result, thrown message and call count; 0 mapper invocations on null; a divergent negative control. I did not redo it. What I did redo, because the base moved:

  • npx tsc --noEmit → exit 0.
  • npm run prepack from a cleaned tree → exit 0, both passes.
  • npm test2708 pass, 0 fail, against 2707 on 2df303a9. The +1 is the one new proof case, as expected.
  • Emitted declarations, whole repo: exactly two files differ from main, fjs/types/nullable/module.f.d.mts and proof.f.d.mts. Nothing else in the public surface moved. I compared the emitted .d.mts directly rather than going through bin/extract.mjs, which is name-keyed and truncates multi-line aliases.
  • In module.f.d.mts, map's declaration is character-for-character what main emits — export declare const map: <T, R>(f: (value: T) => R) => (value: Nullable<T>) => Nullable<R>; — it has only moved below match in the file. match goes from <T, R>(f: (_: T) => R) => (none: () => R) => (_: Nullable<T>) => Nullable<R> to <T, R1>(f: (_: T) => R1) => <R2>(none: () => R2) => (_: Nullable<T>) => R1 | R2: a loosened none and a narrowed result, so an existing call site keeps type-checking and gets a result assignable to what it had.
  • @module header survives declaration emit (grep -c '@module' → 1). No new unprefixed public export type: the only type added is _Branches, an underscore-prefixed @typedef local to a proof closure, and noneIsNull is a module-private const.

The Assert<Equal<…>> on describe's return type is live — I confirmed at the earlier head that falsifying it produces TS2344, and the assertion is unchanged.

The derivation itself is the right shape: map = match(f)(noneIsNull) leaves exactly one value === null test in the module, and the JSDoc explains why R2 needs its own curry step rather than leaving the next reader to rediscover that match(f) instantiates before none exists.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 14, 2026
Merged via the queue into main with commit de959db Aug 14, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/todo-implementation-rifq4g branch August 14, 2026 20:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants