types/rtti/ts: render trailing omittable positions optional - #1708
Conversation
The shared acceptance table covered a tuple with exactly one trailing `option`, which reads as "the last position may be absent" rather than the rule the readers actually implement: a position is required exactly when its set excludes `undefined`, per position, so an array may stop at the last required one however many optional positions follow. Add rows for `[number, bigint, option(string), option(null)]` — both optional positions absent, one present, both present, a required one missing, and a present-but-mistyped optional — so all three readers (`parse`, `validate`, and the data form) stay pinned to it. No behavior change; the readers already answer these rows this way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
functionalscript | 9f6e131 | Commit Preview URL Branch Preview URL |
Aug 26 2026, 04:19 PM |
RTTI states one rule for absence — an absent member reads as `undefined`,
so a member is required exactly when its set excludes `undefined`.
`parse` reads that rule on the way in and contradicts it on the way out:
it materializes the member it just decided was absent, building
`[42, undefined]` and `{ a: 1, b: undefined }`.
On the array kind that loses data. JSON has no `undefined`, so the value
`parse` builds does not survive the format it was most likely read from:
`[42, undefined]` serializes as `[42,null]`, which the same schema then
rejects. The struct kind round-trips only because `JSON.stringify`
already applies the rule this issue asks `parse` to apply.
The issue records the proposal (omit the key; drop the trailing
`undefined` run on tuples), the two cases the implementation must answer
rather than discover, and the one thing that needs deciding first: the
struct half is free because `StructTs` already renders such a key
optional, while `TupleTs` maps to a required-length tuple, so a dropped
result would not inhabit its own declared type. Both attempted
optional-position derivations are written down with their errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e82031f574
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The acceptance table is consumed only by `sameAcceptanceAsParse` and `sameAcceptanceInTheDataForm`, which compare the three readers with one another. That pins agreement, not behavior: a regression all three shared would keep them agreeing and both proofs would pass, so the rows added for several trailing optional positions did not protect the rule they were added for. Write the expected answer down beside them. `trailingOptionalPositions` runs the same five cases through `validate`, `parse` and the data form and asserts `ok`/`error` outright, so no shared regression passes. Verified by flipping one expectation, which fails the proof. The rows stay: they still carry what this cannot, that the readers report an identical error payload, not merely the same tag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 29ed8851a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
o2alexanderfedin
left a comment
There was a problem hiding this comment.
trailingOptionalPositions closes the real gap, and it does so better than the fix I was about to suggest — running all five cases through all three readers catches a shared regression in triplicate rather than only in the validator.
The gap was worth naming: before that commit, the five new rows fed only the two differential proofs, which assert that the readers agree, never what the verdict is. So I deleted the PR's entire subject — made option(t) stop admitting undefined in all three readers — and all 54 rows stayed in agreement with both loops passing. Nothing in the repo asserted that [2,4n] is accepted for a four-position schema. At this head that mutation fails trailingOptionalPositions by name, while the differential loops still pass; the commit message states the defect exactly.
Each of the five assertions now bites as a unique catcher, including the two I couldn't demonstrate before: only-the-last-may-be-absent → accepted([2,4n]); all-or-nothing → accepted([2,4n,'x']); open tuple capped at declared-1 → accepted([2,4n,'x',null]); absence-past-the-end-never-fails → rejected([2]); present-but-unchecked → rejected([2,4n,5]).
The behaviour itself is correct and fully independent — all eight presence subsets on a four-position schema agree across the three readers — and it composes cleanly with the open-tuple caveat rather than contradicting it: accepted lengths are {2,3,4,5,…}, both properties falling out of constContainerValidate iterating the schema's entries. The 167-line issue doc I verified end to end: the reproduction is exact including {path:['1'],message:'no match'}, both TypeScript claims compile as described (TS2344 and TS2322 verbatim), every citation resolves, and the scope is accurate in both directions — exactly two rebuild functions at six sites, with nothing on the data side to include.
npm test 3379/0, tsc clean.
Three things left:
The body has no Changelog: section at all — Changelog: none is the right content for a proof-and-todo-only change. It also still describes only the first of three commits: the 167-line issue doc goes unmentioned, and so does trailingOptionalPositions, which is the most valuable thing in the diff.
One line would pin the hole case. A later optional present while an earlier one is absent is the sharpest witness of per-position independence — it's exactly what all-or-nothing forbids and what truncation can't express — and no assertion states it, new or old. accepted([2, 4n, , null]) closes it and would make present-but-unchecked mutations fail on a second front.
Two carried nits: the filename parse-omits-undefined-members names the proposed behaviour where the defect is that parse materializes them (the heading inside the file gets it right), and neither the comment nor the doc says how "stop short" composes with "run long".
The accepted cases were all dense prefixes, which show only that an optional *suffix* may be truncated. The rule is stronger and is what the readers implement: a position is required exactly when its set excludes `undefined`, independently of the others, so position 2 may be omitted while position 3 is present. Add `[2, 4n, , null]` and its dense spelling `[2, 4n, undefined, null]` — a hole and an explicit `undefined` are the same value, so both are accepted — and the mirror on a required position, `[2, , 'x', null]`, which fails however much of the rest is present. Rename the proof to `optionalPositions`: it no longer covers only trailing ones. Verified by flipping the sparse accepted case to `rejected`, which fails the proof. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8a4b6c7dde
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`close` is a separate reader on all three: `closeContainerValidate` and `closeContainerParse` are their own factories, and the data form gives a closed tuple its own conversion. The proof covered only the open form, so a regression confined to the closed paths — rejecting `[2, 4n]` or `[2, 4n, , null]` — would have passed it, and the closed case elsewhere in this file has just one optional position. Run every case through `close(t)` as well. Closing narrows which values are members, not which positions are required, so all eight answers must match; the one case where it does change the answer, an element past the declared positions, is asserted separately in both directions. Verified by making `closeTupleValidate`'s `fits` require the full declared length, which fails the proof. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 424e56435e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`TupleTs` mapped a schema tuple to a required-length tuple, so `Ts<T>` contradicted what both readers accept: `[number, option(string)]` admits `[42]`, but `Ts<>` of it required two elements. `StructTs` has always rendered an admits-`undefined` key optional; this is the same rule on the other kind. `Ts<[number, bigint, option(boolean), option(string)]>` is now `readonly[number, bigint, (boolean|undefined)?, (string|undefined)?]`. Deriving it generically needed three moves, each defeating an error that sank the obvious spelling, and the doc comment records all three so they are not simplified away: resolve `Ts<>` once per position and split the mapped tuple rather than the schema (testing `undefined extends Ts<Last>` during the walk raises TS2589); `Extract<…, readonly unknown[]>` to make a mapped type spreadable (TS2574 otherwise, and an `&` intersection leaks into the rendered type); and `extends infer M extends …` so each intermediate resolves before use in rest position. Only the trailing run: TypeScript forbids a required element after an optional one, so an interior position admitting `undefined` stays required with `undefined` in its type. That narrows the spelling, not the set — such a position may still be absent at runtime, which `../validate/proof.f.mjs`'s `optionalPositions` pins. Open-ness remains unrendered: a longer array is still a member of the set, and the trailing rest element is still the derivation TypeScript will not carry generically. This unblocks the tuple half of `todo/parse-omits-undefined-members.md`, which was filed needing exactly this decision; its status and tasks are updated to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
`const extra = [2, 4n, 'x', null, 'extra']` relied on default widening, which `fjs/AGENTS.md` prohibits for a literal-initialized `const`: tsc inferred `(string | number | bigint | null)[]` — mutable, no tuple, no literal types — where the rest of this proof pins its values. It is inert today, since `every` takes `Unknown`, but the value sits in a proof about tuple-position typing, which is exactly where a widened literal stops carrying what the proof is about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f7cb65292
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
o2alexanderfedin
left a comment
There was a problem hiding this comment.
The TupleTs change is right and better derived than I expected. It keys off the set, not the syntax of option(...) — a Const undefined, a hand-spelled three-way or(string, number, undefined), and the unknown tag all optionalize — so the changelog wording is accurate rather than overstated. Verified with 11 invariant Equal pins plus value-level accept/@ts-expect-error cases, tsc exit 0.
Type and runtime agree at the bottom end on every schema I probed, all three readers, all presence subsets including holes and explicit undefined. The one place the claim could quietly have been false — [number, unknown], where the type now says position 1 is omittable — holds: [1] is ok/ok/ok. The top-end asymmetry (runtime open, Ts<T> exact) is unchanged and the README's new wording states it correctly.
It also closes a hole that was there before: validate([number, option(string)])([42]) returns the length-1 array, which at 424e5643 did not inhabit its own Ts<T>. It does now.
_tupleOption and _tupleInteriorOption pin it. Reverting TupleTs to the required-length rendering fails _tupleOption by name (TS2344, sole error). Worth recording why the third mutation is not a gap: optionalizing every admits-undefined position rather than only the trailing run survives, and that is correct — TypeScript itself normalizes an optional element before a required one back to required, so "only the trailing run" is enforced by the compiler and both spellings are the same type. I checked that against the full battery rather than assuming it.
Blast radius is clean: all 74 Check</Check3< pins are Equal-based, so a hand-written pin against a tuple schema with trailing optionals would have failed to compile. Every option(...) in a tuple position lives inside rtti's own proofs; the external uses in mcp/cas and media/json/schema are struct keys.
One blocking item and two minor:
The body still has no Changelog: section. I raised this at 29ed8851 and it is still absent — CONTRIBUTING.md:201 makes it mandatory, and the body becomes the squash commit. changelog/unreleased/1708.md is now correct and conforming; it just needs its list item copied into the body. The title and body are also stale in a way that now matters more than before: both still describe only the old proof rows, and neither mentions the types.ts rendering change or the BREAKING flag. This started as a test-cases PR and is now a public type-API break.
parse-omits-undefined-members.md:120 overstates what is pinned. It says an interior position admitting undefined may still be absent at runtime "and ../validate/proof.f.mjs's optionalPositions pins exactly that." It does not — that proof's hole row is accepted([2, 4n, , null]), where position 2 is followed by another omittable position, not a required one. No runtime proof anywhere covers a [option(X), required] shape. The claim is true (I confirmed [<hole>, 1] and [undefined, 1] are ok/ok/ok), just unpinned — _tupleInteriorOption has no runtime counterpart. One row closes it, or soften the sentence.
7f7cb6529 is dead weight. The @type {const} cast on extra changes no compile or test outcome — dropping it leaves 3379/0 and zero tsc diagnostics. Harmless and consistent with the line above it, so keep it if you like the symmetry; just noting it pins nothing. The row it sits in is live: flipping either side of the open/closed split fails optionalPositions by name.
Gates: npm test 3379/0 at head, identical to 424e5643 — the delta adds no runtime test, which is expected for a type-level change. tsc --noEmit exit 0.
`TupleTs` splits off the trailing run of positions admitting `undefined`, which needs a known length. A schema array of non-fixed length — what `.map()` produces — matches neither `readonly [...I, L]` nor any tuple pattern, so `RequiredPart`'s `readonly []` fallback made `Ts<T>` render it as the empty tuple, dropping the element type the homomorphic mapping used to preserve. `Ts<readonly (typeof number)[]>` was `readonly number[]` before this branch and `readonly []` after it. Fall back to `M` instead. That branch is reached by exactly two shapes: the empty tuple, where `M` *is* `readonly []`, and a non-fixed-length array, which has no trailing position to split off and must keep its element type. One token, and no length test — dispatching on `number extends T['length']` also fixes it but instantiates too deeply, raising TS2589 in `mcp/cas`, `media/note` and `media/revision`. Pin all three renderings in `ts/proof.f.mjs`, which had no `Ts<>` type-level assertions at all: the non-fixed-length case, the optional trailing run, and an interior optional staying required. Verified the first fires by restoring the `readonly []` fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
`dynamicSchema`, `optionalTail` and `interiorOption` existed only to be pointed at by `typeof` in a typedef, so every runtime reader — the code-quality check included — saw three unused variables. The repo's own idiom anchors such asserts on values that already exist (`typeof boolean` on the imported schema, `typeof list` on a const the proof also runs), and mine had no runtime use to anchor to. Spell the schemas as types instead: `Or<readonly [typeof boolean, undefined]>` is what `option(boolean)` has, and the non-fixed-length case needs no value at all. These are type-level facts, so a type expression is the more honest spelling as well as the one that leaves nothing unused. Re-verified `_NonFixedLength` still fires by restoring the `readonly []` fallback in `RequiredPart`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
One conflict, in `fjs/types/rtti/validate/proof.f.mjs`: both sides added a proof entry at the same insertion point after the acceptance tables — `optionalPositions` here, `sparseTuple` from #1712 on main. They are independent, so both are kept, this branch's first so that each comment's "the two tables/proofs above" still names the two table-driven proofs. #1712 makes the readers walk a tuple schema by length, which is the *schema* side of "a hole and a declared `undefined` are one thing". This branch's sparse cases are sparse *values* read by indexing, so they are untouched by it — re-ran them to confirm rather than assume. It also deleted `sparse-tuple-schema-entries.md`, which `parse-omits-undefined-members.md` linked to. Repoint that reference at what shipped and at `schema-walk-own-indices.md`, the part of it still open; the schema side is now settled in favour of the reading the issue already assumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6942c1669
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
o2alexanderfedin
left a comment
There was a problem hiding this comment.
The production change is correct, minimal, and genuinely pinned. Head moved twice while I was checking — findings are against a6942c166, which includes your own merge of main.
RequiredPart's fallback going from readonly [] to M fixes three shapes, not the one the commit message names: readonly (N|B)[] and (N|B)[] both rendered readonly [], and so did readonly [N, ...S[]], while readonly [...S[], OS] rendered readonly [(string|undefined)?] with the rest dropped. The rest-tuple repair is real but unpinned — worth a line if you want it held.
No fixed-length rendering moved: all 11 rows I measured at 7f7cb6529 are byte-identical at head, interior-stays-required included. a351949d8's respelling of the pins as schema types is faithful to the real constructor, not just its JSDoc — Equal<typeof option(string), Or<readonly [typeof string, undefined]>> is true.
Mutations: _NonFixedLength and _OptionalTail each die on their own; restoring the readonly [] fallback dies with _NonFixedLength as the sole error, so that pin is the only guard on the regression; _tupleOption and _tupleInteriorOption both still bite. One row is enforced by TypeScript rather than by the assert — making the interior optional is inexpressible (TS1257) — but dropping its | undefined does die, so it isn't dead weight.
The type disagrees with the runtime in both directions on the non-fixed-length shape (it admits [], [1], [1,1] that runtime rejects, and rejects [1,2n,'extra'] that runtime accepts). Both are inherent to an erased length, and strictly better than readonly [], which admitted exactly one value the runtime rejects and rejected every value it accepts. Top-end asymmetry unchanged.
Gates: npm test 3379/0 at the branch tip and 3392/0 at the merge — the +13 is main's side (#1712's rows), not this delta. tsc --noEmit exit 0 both. No new casts; the delta actually removes two. changelog/unreleased/1708.md correctly needs no new item — the commit that introduced the readonly [] bug never reached main, so the broken rendering never shipped.
Still the one blocking item: the body has no Changelog: section. I raised this at 29ed8851 and at 7f7cb6529; four commits have landed since and none touched it. CONTRIBUTING.md:201 makes it mandatory and the body becomes the squash commit. The file is correct and conforming — copy its list item in. The title and description are also still stale: both describe only the five original proof rows, and neither mentions the types.ts rendering change or that it is BREAKING. This PR is a public type-API break now, and nothing in what merges to main will say so.
Also still open: parse-omits-undefined-members.md:120 claims optionalPositions pins that an interior admits-undefined position may be absent at runtime. It doesn't — the hole row is still accepted([2, 4n, , null]), position 2 followed by another omittable one. I checked whether #1712 supplied a new candidate on main; validate/proof.f.mjs:130's [option(number), null] is a [schema, value] pair with a scalar schema, not a [option(X), required] tuple. _tupleInteriorOption still has no runtime counterpart.
The @type {const} I mentioned last time: this delta removed its own, and the one at validate/proof.f.mjs:261 is untouched and harmless.
`TupleTs` peels the last position off to find the trailing optional run, which assumes a fixed length. A variadic tuple — `[...(typeof number)[], option(string)]` — matches the peel pattern happily, but the prefix it leaves behind has unknown length, so the reconstruction flattened it. `Ts<>` then admitted `[1, 'x', 2]`, a string in the number prefix, which no schema of that shape validates. Confirmed against the previous homomorphic mapping, which rejects it. Guard the split on `number extends M['length']` instead, and hand back the mapping unchanged when the length is not fixed. That covers both shapes this branch got wrong for the same reason — a variadic tuple and a plain schema array both have `length: number` and no last position to peel — so `RequiredPart`'s fallback goes back to `readonly []`, reached now only by the empty tuple. Testing the length on `M` is what makes this affordable: `M` is a resolved tuple of ordinary types, where the earlier attempt tested `T['length']` on the recursive schema type and raised TS2589 in three modules. Pin `_VariadicPrefix` beside `_NonFixedLength`, and verify both fire by removing the guard — `Equal<>` is unreliable on variadic tuples, so a pin that merely looks right is not enough here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f8804bccb3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…gth composition Three gaps a reviewer named, all real. `parse-omits-undefined-members.md` claimed `optionalPositions` pins that an interior omittable position may be absent at runtime. It does not: its hole is at position 2 of four, followed by another omittable position, so truncation explains it just as well. `interiorOptionBeforeRequired` is the case that cannot be explained that way — `[option(string), number]` accepts `[, 5]`, where the required position after the hole is present. It also pins that absence is positional rather than a shift: `[5]` puts the number at position 0 and is rejected. `Ts<readonly [N, ...S[]]>` — a rest element after a fixed prefix — is held by the same `length` guard as the variadic prefix but had no pin. `_RestTuple` closes that; all three now die when the guard is disabled. Neither the proof nor the issue said how stopping short composes with running long. They are independent, so the open form's accepted lengths run from the last required position upwards with no gap and no cap, and `close` caps the top while leaving the bottom alone. Stated in the comment, with a six-element value to hold the upper half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
|
@o2alexanderfedin — thank you, and first an apology: your review at The blocking item is fixed. The title and description are rewritten; the description now carries a
interiorOptionBeforeRequired // [option(string), number]
every(assertOk)([, 5]) //< a hole at position 0, required position present
every(assertOk)([undefined, 5]) //< the same value, spelled densely
every(assertError)([5]) //< `number` at position 1 is requiredVerified across all three readers before pinning. The rest-tuple repair is pinned. Stop-short × run-long is now stated. They are independent, so the open form's accepted lengths run from the last required position upwards with no gap and no cap; One thing I am not doing, with reasons. The filename Head is now Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b4adaeeb50
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…readers `interiorOptionBeforeRequired` exercised only the open schema, so a closed-path regression on it would have passed every assertion — `optionalPositions` puts its schema through `close` for exactly that reason, and the new helper should have followed suit when it was added. Run both, and assert the one answer closing changes here too: an element past the declared positions is a member of the open set and not the closed one. Confirmed all six answers first — closed agrees with open on every value except the extra. Verified the coverage is real rather than nominal with a closed-only regression that a hole reaches: counting own keys instead of length in `closeTupleValidate`'s `fits` rejects `[, 5]`, whose hole leaves one own key for two declared positions. That now fails this helper; before it failed only `optionalPositions`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
`RequiredPart<M>` and `OmittablePart<M>` are each conditionals over a naked `M`, so a union of tuple schemas distributed through them separately and the spread recombined every prefix with every suffix. For `[number, option(string)] | [string, option(boolean), option(number)]` that admitted `[number, boolean]` and `[string, string]`, neither of which matches either branch. The previous mapping rejected both. `Ts<T>` was unaffected — it distributes over the union before reaching `TupleTs` — but `TupleTs` is exported and wrong on its own, which is where a caller instantiating it directly would have hit this. Move the body into `SplitTs`, whose first conditional tests a naked `M` so the union distributes there instead: each member is split and rebuilt whole, then rejoined. Pinned as assignability, since the claim is about which values the union admits. Each of the three mechanisms in this transform now has exactly one pin that dies with it: undistributing kills `_UnionKeepsBranchCorrelation`, neutering the length guard kills `_VariadicPrefixRejectsMixedPrefix`, reverting the fallback kills `_OptionalMember`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
…pe-parsing-br4jql
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12f3df73f5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
`changelog/README.md` caps an entry at "about three wrapped lines, ~250 characters" and sends rationale to the pull request description. This one ran 10 lines and 757 bytes — the derivation, the reason only the trailing run renders optional, and the `StructTs` parallel, none of which is a release note. The directory's norm agrees: the longer files here are multi-entry (`1693.md` is 14 entries over 37 lines), so ~3 lines per entry is what they already average. This was a single entry at 10. Keeps the marker, what changed, and the migration note; the rest is in the description, which the file name points a reader to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
…br4jql' into claude/rtti-option-type-parsing-br4jql
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Head moved twice while I was checking; this is against 777542724, and the body was rewritten mid-review too.
The net types.ts change versus my last review is just the number extends M['length'] guard plus the SplitTs extraction — and what those commits do is narrow the PR back to main's behaviour, not extend it. Three-way probes (a6942c166 / head / main) make that concrete:
f8804bccbfixed a real regression of this PR: a variadic schema tuple was flattened, soTs<[...number[], option(string)]>renderedreadonly (string | number | undefined)[]and admitted astringin thenumberprefix. Main never had that.990912e23fixed a second one:TupleTs<A|B>cross-multiplied, soreadonly [1, true]— A's prefix with B's suffix — was assignable. Main rejects it. Only reachable through the exportedTupleTs, as your message says.cd2f99c6bis intra-PR churn — its shape rendersreadonly [number, string?]identically ata6942c166, head and main. It repairs the fallback thatf8804bccbitself had changed three commits earlier. Fine, just noting the commit message reads as if it were a pre-existing bug.
All 11 fixed-length renderings are byte-identical to my reference table, so nothing moved for consumers. I checked the Ts<> consumers directly — ci/common, protocol/{mcp,json_rpc}, media/{json,note,revision}, mcp/{cas,evo} — and found no tuple schema with a trailing option() at all; they're struct schemas going through the untouched StructTs. No call site should gain a ? from these commits.
All 11 new assertion rows die individually, and every kill is a TS2344 at the pin's own line — a named proof, never a TS1257 inexpressibility rejection. Each source mutation dies at exactly one pin.
Three things:
The body's Changelog: item is HTML-escaped. The marker, placement and **BREAKING CHANGES:** prefix are all correct now, and the title and description are fixed — but the body contains Ts<T> where the file has `Ts<T>`, 7 entity occurrences body-wide. My first fetch had plain <, so the most recent body edit introduced it. Since the body becomes the squash commit verbatim, Ts<T> lands in git history, and CONTRIBUTING wants the item identical to the entry file. Unescaping fixes it. New shape for the list: correct in every structural respect, wrong at the character level.
Two pins survive every single-point mutation. _NonFixedLength and _RestTuple are killed by none of {guard neutered, fallback → readonly [], distribution undone, full TupleTs revert} — only by the guard-and-fallback double mutation. Guard and fallback both return M for those shapes, so they're redundant. Worth knowing because b4adaeeb5's message claims _RestTuple "closes that; all three now die when the guard is disabled" — true when written, falsified by cd2f99c6b's fallback restore. The squash discards that message, so this is informational; _RestTuple still documents intent, it just doesn't discriminate any mechanism.
parse-omits-undefined-members.md:120 still cites the wrong proof. interiorOptionBeforeRequired is a genuine [option(X), required] runtime row now, run through open and closed readers — that's the substance fixed. But the doc still says optionalPositions "pins exactly that", which is the sentence b4adaeeb5's own message calls wrong, and interiorOptionBeforeRequired appears nowhere in that file. The file is already in your diff.
One non-blocking observation, pre-existing rather than introduced: a schema whose own last member is optional takes the fallback and keeps the whole mapping, so a preceding omittable position isn't optionalized even where TypeScript could spell it — Ts<[N, option(B), (S)?]> gives readonly [number, boolean | undefined, string?] where (boolean|undefined)? is expressible. Renders identically at main and at a6942c166, so the fallback preserves it rather than causing it. A sentence in RequiredPart's comment would cover it.
No new changelog item needed — these commits move the renderings back to main's behaviour, so the public delta is unchanged and the shortened item is still accurate.
Gates: npm test 3398/0, tsc --noEmit exit 0, no casts introduced, branch up to date with main.
Three things a reviewer verified against this branch, all confirmed here before changing anything. `parse-omits-undefined-members.md` still credited `optionalPositions` with pinning that an interior omittable position may be absent at runtime. `b4adaee` added the row that actually does — the doc kept the old citation, and `interiorOptionBeforeRequired` appeared nowhere in the file. Cite it, and say why `optionalPositions` cannot stand in: its hole is followed by another omittable position. `_NonFixedLength` and `_RestTuple` survive every single-point mutation — the guard and the fallback both answer `M` for those two shapes, so only removing both at once moves them. They document intent rather than discriminate a mechanism, and the comment now says so, naming the three rows that do pin one each. A schema whose own last member is optional takes the fallback and keeps the whole mapping, so an omittable position before it is not optionalized even where TypeScript could spell it: `[N, option(B), (S)?]` renders `boolean | undefined` where `(boolean | undefined)?` is expressible. The homomorphic mapping rendered it the same way, so the fallback preserves that rather than introducing it — recorded in `RequiredPart`. The changelog entry drops its angle brackets and apostrophe. GitHub rewrites both in a pull request body — `<T>` is stripped as a tag and `'` becomes `'` — so an entry containing either cannot be reproduced verbatim in the `Changelog:` section that `CONTRIBUTING.md:197` requires to match it. `Ts` alone is unambiguous under the entry's own topic prefix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
|
@o2alexanderfedin — all three addressed in The escaped Changelog item. You are right that it lands in history, and my fix for the previous round caused it. What I found chasing it is that neither spelling round-trips through the PR body:
Unescaping does not fix it: I tried exactly that before rewording, and Since a body containing either character cannot reproduce the file verbatim, I reworded the entry to avoid both rather than keep trading one corruption for another: Verified by fetching the stored body back and comparing the section to the file: byte-identical, no entities. The two redundant pins. Confirmed exactly as you describe: The stale citation. Fixed, and thank you for catching that I repaired the substance and left the sentence. The doc now cites Your non-blocking observation is recorded in On Head is Generated by Claude Code |
…`Ts<>` Review on #1716 found three claims that do not hold, all verified here. `closeContainerValidate` ends in `extra.length === 0 && fits(...)`, and `&&` short-circuits: an explicit trailing `undefined` is an own enumerable entry, so the extra check rejects it and `fits` is never reached; only a hole, which is no entry, reaches `fits`. Neutralizing both tuple `fits` settles it — the explicit `undefined` still errors, the hole flips to ok. So the file's headline attribution was wrong, and at the edag schema two of the three neighbour values are rejected for exactly the reason its README gives, not one. The task list for answer A therefore named the wrong knob: it is the `undeclaredEntries` filter, which is shared with the struct kind, so A has to answer `close({ a: number })` against `{ a: 1, b: undefined }` too. The `option(propertyLambda)` implication does not follow — position 3 there is declared, so neither half governs it, and all four spellings validate under every answer. Re-derived around `or(close, close)`, which the extra check does govern, and the paragraph now says why the `option` spelling is out of scope. Adds answer C, which the review proposed, and the argument against moving either half: `arraySetValidate` already records that the length check is what keeps a closed tuple's set equal to what `Ts<>` and JSON Schema render it as. That reframes the issue as a choice between two correspondences, and on the evidence B — the incumbent, documented — is the answer. Its own last task said C would leave edag's "exactly one" claim true; C admits a hole-padded second spelling, so it would not. Also: the acceptance table already pins the hole row, only the explicit `undefined` one is missing; #1708 is open, so the conditional tense. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xp3Axb2CF3pSjedM9sqmM
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Approving. Both must-fix items are closed.
The entity count is 0 (was 7) and the Changelog: section is md5-identical to changelog/unreleased/1708.md, last section, single trailer. The citation now points at interiorOptionBeforeRequired, and I verified that proof genuinely covers [option(X), required] — [, 5], [undefined, 5] and ['x', 5] accepted, [5] rejected, all three readers, both open and closed, with closing flipping only the 'extra' row.
Both types.ts and proof.f.mjs +7 blocks are pure // comments — stripping comment lines leaves both files byte-identical and the 22 @typedef pins unchanged in count and content — so no rendering could have moved and there was nothing new to mutation-test. The Y2 note is accurate: I re-ran all four single-point mutations and each of the three mechanisms the comment names is killed by exactly the pin it names, while _NonFixedLength and _RestTuple still die under none. Documenting rather than fixing is a fine disposition for two rows that state intent. Blast radius nil — walking every consumer schema graph found 9 tuple-kind nodes and none with a trailing position admitting undefined, and merging main is a no-op.
Two things worth knowing, neither blocking:
The rationale in cb0b1ea24's message is false. It says GitHub rewrites <T> and ' in a PR body. It doesn't — #1716's body carries a raw unescaped Ts<T>, and #1714 and #1707 carry raw < and several raw apostrophes, all with zero entities. The escaping came from whatever wrote 1708's body, not from GitHub. Nothing ships wrong, since the squash takes the description and the branch commit message evaporates. But the fix landed on the entry file rather than on the body that was actually malformed, so the Ts<T> → Ts precision loss was unnecessary (defensible on its own under the types/rtti/ts topic prefix), and the failure can recur.
One loose clause at parse-omits-undefined-members.md:118: "so truncation explains that row equally well" is literally false — truncation predicts rejection of optionalPositions's [2, 4n, , null], and all three readers accept it. It also contradicts that proof's own comment at validate/proof.f.mjs:247, "Omission is independent, not just truncation." The defensible point is that the hole sits inside the trailing omittable run, so it can't witness absence at a position the renderer marks required.
Gates: npm test 3398/0 at head and at 777542724, tsc --noEmit exit 0, purge matched nothing, no new changelog item warranted.
…not say The clause said its hole row is one "truncation explains equally well". That is false in the direction it claims: truncation predicts `[2, 4n, , null]` would be *rejected*, and all three readers accept it — which is what that proof's own comment already records, two files apart and contradicting this sentence. The defensible point is the one the row cannot reach: its hole falls inside the trailing omittable run, so no position the renderer marks required follows it. That is why `interiorOptionBeforeRequired` exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR
o2alexanderfedin
left a comment
There was a problem hiding this comment.
Still approving — the delta is documentation only, one file, +5/-2, no .f.mjs or .ts moved, so the approval still covers the code.
The corrected clause is true. optionalPositions's schema is [number, bigint, option(string), option(null)], so the trailing omittable run is positions 2-3 and the hole sits at 2 — inside it, with only an optional position after. The hole row is accepted by all three readers in both open and closed form, which is what makes the old "truncation explains that row equally well" false: truncation needs a dense prefix and would predict rejection. The new parenthetical now agrees with validate/proof.f.mjs:247's own comment instead of denying it, and matches what interiorOptionBeforeRequired's comment says. The interiorOptionBeforeRequired citation is untouched and still correct.
Gates: npm test 3398/0 — the +2 over the sibling branches are this PR's own proofs, so both optionalPositions and interiorOptionBeforeRequired do execute under npm test. tsc --noEmit exit 0.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9f6e131df9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
|
||
| type RequiredPart<M extends readonly unknown[]> = | ||
| M extends readonly [...infer I extends readonly unknown[], infer L] | ||
| ? undefined extends L ? RequiredPart<I> : M |
There was a problem hiding this comment.
Derive optionality from the schema, not its widened rendering
When a tuple ends in an empty struct schema, StructTs<{}> reduces to unknown, so this test treats the position as omittable even though the {} schema rejects undefined. Consequently, Ts<readonly [typeof number, {}]> becomes readonly [number, unknown?] and permits [1], while both parse([number, {}])([1]) and validate([number, {}])([1]) return an error for the missing position. Determine undefined membership from the schema rather than the potentially widened Ts output, and pin this case with an Assert<Equal<…>> proof.
AGENTS.md reference: fjs/AGENTS.md:L85-L91
Useful? React with 👍 / 👎.
`parse-omits-undefined-members.md` is in the tree now, so the references to it as a pending PR are stale: answer A cites it directly, the `option` note says the type-level spelling exists rather than that it would, and the Related entry names the file instead of the PR. An earlier draft of this file said to fold into that one if it landed first. Reading it in place, that is wrong, and the entry now says so: it asks what `parse` *builds* at a **declared** position it found absent, this asks whether an **undeclared** trailing `undefined` or hole is a member at all. Either can be answered without the other, and answer A here is the only thing that would make them meet. Re-verified at this head that #1708 changed no runtime acceptance: the four schemas by three spellings, the `or(close, close)` rows, and the four edag neighbours all answer exactly as before across `validate`, `parse` and the data form. `arraySetValidate`'s comment, which this file quotes, is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xp3Axb2CF3pSjedM9sqmM
A review bot reports that B's documentation task is already satisfied: "Closed containers" says `close(c)` admits the declared members and no others, lists `close([number])` as "arrays of exactly one number", and requires a wrapped const rest for undeclared members that must be `undefined`. Half right, and worth taking. The explicit-`undefined` half *is* inferable — the wrapped-const rest would be pointless if a bare `close(c)` admitted one — so asking for it again risks a second telling that contradicts the first. The **length** half is not: a hole is not an enumerable entry, and no passage says a closed container bounds `value.length`, which is the only thing that rejects `[1, ,]` and the case the `Ts<>`/JSON Schema defence is actually about. So B's task now asks for that one sentence and says explicitly not to restate the other half. The bot's other finding — #1708 called open, with a conditional folding instruction — was already fixed in `ce5feaf`; it reviewed the parent commit.
RTTI has one rule for absence, stated in
fjs/types/rtti/README.mdfor bothcontainer kinds: an absent member reads as
undefined, so a member is requiredexactly when its set excludes
undefined.[number, bigint, option(string), option(null)]therefore accepts[2, 4n].The readers already implemented that. Three things here follow from it.
The renderer now agrees, which is the public API break.
TupleTsmapped a schema tuple to a required-length tuple, so the rendered type
contradicted what the readers accept. It now splits off the trailing run of
positions admitting
undefinedand renders that run optional — the ruleStructTsalready applied per key, and what the runtime printer alreadyemits. Only the trailing run: TypeScript forbids a required element after an
optional one, so an interior such position stays required with
undefinedinits type.
The derivation needed four pieces, each earned against a concrete failure: map
once and split the result (splitting the schema evaluates the renderer twice
per position and hits TS2589);
Extractagainstreadonly unknown[]to make amapped type spreadable (the TS2574 the old doc comment recorded as the
blocker); a
number extends M['length']guard so a non-fixed-length schemaarray or a variadic tuple keeps its mapping instead of being flattened; and a
naked
MinSplitTsso a union of tuple schemas is split per member ratherthan recombining every prefix with every suffix.
The proofs now assert what the readers answer, not just that they agree.
The shared acceptance table feeds two differential proofs, which compare the
three readers with one another and never state a verdict — so a regression all
three shared passed them.
optionalPositionsruns the cases throughvalidate,parseand the data form and assertsok/erroroutright, on theopen and closed forms alike, including a hole before a later present position
and the one case where closing changes the answer.
interiorOptionBeforeRequiredpins the sharpest witness of per-positionabsence:
[option(string), number]accepts[, 5]and rejects[5].ts/proof.f.mjsgains the first type-level assertions on the renderer it hasever had, covering the fixed, non-fixed-length, variadic-prefix, rest-tuple,
optional-member and union shapes. Three are assignability rather than equality,
because an equality pin reports the variadic shape as unchanged whether or not
its guard is in place — it passed over the bug it existed for. Each mechanism
in the transform has one pin that dies with it, verified by mutation; the other
two rows document intent, since the guard and the fallback answer alike for
those shapes.
parsestill contradicts the rule on the way out, materializing the memberit just decided was absent, which loses data through JSON on the array kind
(
[42, undefined]serializes as[42,null], which the same schema thenrejects). That is filed as
fjs/types/rtti/todo/parse-omits-undefined-members.mdrather than fixed here.Changelog:
types/rtti/ts:Tsrenders the trailing positions ofa tuple schema that admit
undefinedas optional, matching whatparseandvalidateaccept. A hand-written type pinned against one needs the?.https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR