types/nullable: derive map from match so the null guard lives once - #1558
Conversation
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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
o2alexanderfedin
left a comment
There was a problem hiding this comment.
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 withObject.is), in thrown-error message, and in mapper call count.undefinedin 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 onnull. - Explicit null check:
map(() => { invoked = true })(null)returnsnullwithinvoked === 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:
map— byte-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 thenoneparameter 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@typedefthe proof adds is_Branches— underscore-prefixed and local to a proof. @moduleheader and its blank line survive into the.d.mtsunchanged.
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.
We don't need a PR number inside the changelog note. New AGENTS.md: |
Deploying with
|
| 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
left a comment
There was a problem hiding this comment.
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 prepackfrom a cleaned tree → exit 0, both passes.npm test→ 2708 pass, 0 fail, against 2707 on2df303a9. 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.mtsandproof.f.d.mts. Nothing else in the public surface moved. I compared the emitted.d.mtsdirectly rather than going throughbin/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 belowmatchin the file.matchgoes 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 loosenednoneand a narrowed result, so an existing call site keeps type-checking and gets a result assignable to what it had. @moduleheader survives declaration emit (grep -c '@module'→ 1). No new unprefixed publicexport type: the only type added is_Branches, an underscore-prefixed@typedeflocal to a proof closure, andnoneIsNullis 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.
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:
mapis exactlymatchwith the absent branch fixed to() => null.What
matchmoves first andmapderives from it:Deriving it required generalizing
match's type first — this is a prerequisite, not a cleanup. With both branches tied to oneR,none: () => Rdemandsnullbe assignable to an unconstrainedR(TS2322), and a.mjscall site has no syntax to instantiateRatNullable<R>explicitly.The two result types also need separate curry steps. On one outer generic they are instantiated together at
match(f)— beforenoneis supplied — soR2has nothing to infer from and collapses tounknown. Inferring it at the second call givesR1 | R2=R | null:/** @type {<T, R1>(f: (_: T) => R1) => <R2>(none: () => R2) => (_: Nullable<T>) => R1 | R2} */noneIsNullis 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.mtsoutput across the change).match's is strictly more general:More permissive on input (branches no longer forced to unify) and more precise on output (a caller whose branches agree gets
R, not a spuriousR | null). No caller can break; the repo-widenpx tscconfirms it.Verification
npx tscclean.fjs test: 2692 pass, 0 fail.npm run cov:fjs/types/nullable/module.f.mjsat 100% lines/branches/functions.Assert<Equal<ReturnType<typeof describe>, number | string>>, and I negative-tested it — narrowing the claim tonumbermakestscfail, so the assertion bites rather than passing vacuously.🤖 Generated with Claude Code
https://claude.ai/code/session_016HvbYkBMYWwQECL7myLhqs
Generated by Claude Code