Skip to content

types/rtti/ts: render trailing omittable positions optional - #1708

Merged
sergey-shandar merged 23 commits into
mainfrom
claude/rtti-option-type-parsing-br4jql
Aug 26, 2026
Merged

types/rtti/ts: render trailing omittable positions optional#1708
sergey-shandar merged 23 commits into
mainfrom
claude/rtti-option-type-parsing-br4jql

Conversation

@sergey-shandar

@sergey-shandar sergey-shandar commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

RTTI has one rule for absence, stated in fjs/types/rtti/README.md for both
container kinds: an absent member reads as undefined, so a member is required
exactly 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. TupleTs
mapped 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 undefined and renders that run optional — the rule
StructTs already applied per key, and what the runtime printer already
emits. Only the trailing run: TypeScript forbids a required element after an
optional one, so an interior such position stays required with undefined in
its 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); Extract against readonly unknown[] to make a
mapped type spreadable (the TS2574 the old doc comment recorded as the
blocker); a number extends M['length'] guard so a non-fixed-length schema
array or a variadic tuple keeps its mapping instead of being flattened; and a
naked M in SplitTs so a union of tuple schemas is split per member rather
than 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. optionalPositions runs the cases through
validate, parse and the data form and asserts ok/error outright, on the
open and closed forms alike, including a hole before a later present position
and the one case where closing changes the answer.
interiorOptionBeforeRequired pins the sharpest witness of per-position
absence: [option(string), number] accepts [, 5] and rejects [5].

ts/proof.f.mjs gains the first type-level assertions on the renderer it has
ever 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.

parse still contradicts the rule on the way out, materializing the member
it just decided was absent, which loses data through JSON on the array kind
([42, undefined] serializes as [42,null], which the same schema then
rejects). That is filed as
fjs/types/rtti/todo/parse-omits-undefined-members.md rather than fixed here.

Changelog:

  • BREAKING CHANGES: types/rtti/ts: Ts renders the trailing positions of
    a tuple schema that admit undefined as optional, matching what parse and
    validate accept. A hand-written type pinned against one needs the ?.

https://claude.ai/code/session_01TJuibHn4HfRBwpZgZVjobR

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
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 26, 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 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
@sergey-shandar
sergey-shandar marked this pull request as ready for review August 26, 2026 06:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/validate/proof.f.mjs
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
@sergey-shandar
sergey-shandar marked this pull request as draft August 26, 2026 06:36
@sergey-shandar
sergey-shandar marked this pull request as ready for review August 26, 2026 06:36

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/validate/proof.f.mjs Outdated

@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.

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-1accepted([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 allChangelog: 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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/validate/proof.f.mjs
`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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/validate/proof.f.mjs Outdated
claude added 2 commits August 26, 2026 07:30
`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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/ts/types.ts Outdated

@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.

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
Comment thread fjs/types/rtti/ts/proof.f.mjs Fixed
Comment thread fjs/types/rtti/ts/proof.f.mjs Fixed
Comment thread fjs/types/rtti/ts/proof.f.mjs Fixed
claude added 2 commits August 26, 2026 13:13
`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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/ts/types.ts Outdated

@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.

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/ts/types.ts Outdated
…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
@sergey-shandar sergey-shandar changed the title Add test cases for optional trailing array elements types/rtti/ts: render trailing omittable positions optional Aug 26, 2026

Copy link
Copy Markdown
Contributor Author

@o2alexanderfedin — thank you, and first an apology: your review at 29ed8851 went unanswered because I was polling review threads and not review bodies, so I never saw it. Both of your top-level reviews are addressed below. That is my process bug, not a disagreement.

The blocking item is fixed. The title and description are rewritten; the description now carries a Changelog: section holding exactly the list item from changelog/unreleased/1708.md, verbatim, with its **BREAKING CHANGES:** prefix. The title is now types/rtti/ts: render trailing omittable positions optional — 59 characters, 67 with the (#1708) GitHub appends, within the 72 that CONTRIBUTING.md:181 allows. The prose covers all three strands, the types.ts rendering change named as the public break.

parse-omits-undefined-members.md:120 was wrong, and is now true. You are right that accepted([2, 4n, , null]) cannot say it — position 2 is followed by another omittable position, so truncation explains it equally well. Rather than weaken the claim I added the case that only per-position absence explains, in b4adaee:

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 required

Verified across all three readers before pinning. [5] rejecting is the half I had not appreciated: absence is positional, not a shift left. _InteriorStaysRequired now has its runtime counterpart.

The rest-tuple repair is pinned. _RestTuple holds Ts<readonly [N, ...S[]]>. Worth noting your analysis was of the M fallback, which f8804bc has since replaced: a number extends M['length'] guard now covers all of those shapes at once, and RequiredPart's fallback is back to readonly [], reached only by the empty tuple. Disabling that guard now kills _NonFixedLength, _VariadicPrefix and _RestTuple together.

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; close caps the top and leaves the bottom. In the comment, with a six-element value holding the upper half.

One thing I am not doing, with reasons. The filename parse-omits-undefined-members naming the proposal rather than the defect is the dominant convention in that directory, not an exception to it — excluded-string-values, prefix-then-rest-tuple, schema-walk-own-indices, checked-const-pin, identity-aware-parse and kindset-eliminator are all proposal-named, against export-node-accessors for the defect. Renaming this one would make it the odd file out. Happy to rename if you would rather the directory move the other way, but I did not want to do it silently on a split convention.

Head is now b4adaee; your findings were against a6942c166, so f8804bc (variadic guard) and b4adaee land on top. npx tsc exit 0, npm test 3393/0 — the +1 over your 3392 is interiorOptionBeforeRequired.


Generated by Claude Code

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread fjs/types/rtti/validate/proof.f.mjs Outdated
claude added 3 commits August 26, 2026 13:46
…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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread changelog/unreleased/1708.md Outdated
sergey-shandar and others added 3 commits August 26, 2026 06:59
`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 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.

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:

  • f8804bccb fixed a real regression of this PR: a variadic schema tuple was flattened, so Ts<[...number[], option(string)]> rendered readonly (string | number | undefined)[] and admitted a string in the number prefix. Main never had that.
  • 990912e23 fixed a second one: TupleTs<A|B> cross-multiplied, so readonly [1, true] — A's prefix with B's suffix — was assignable. Main rejects it. Only reachable through the exported TupleTs, as your message says.
  • cd2f99c6b is intra-PR churn — its shape renders readonly [number, string?] identically at a6942c166, head and main. It repairs the fallback that f8804bccb itself 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&lt;T&gt; 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&lt;T&gt; 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 `&#39;` — 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

Copy link
Copy Markdown
Contributor Author

@o2alexanderfedin — all three addressed in cb0b1ea. I reproduced each against the branch first; all three held.

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:

what I send what is stored
`Ts<T>` `Ts` — stripped as a tag
`Ts&lt;T&gt;` `Ts&lt;T&gt;` — entity kept literally

Unescaping does not fix it: I tried exactly that before rewording, and <T> was stripped again, which is how the first version lost it. The apostrophe has the same problem — schema's stores as schema&#39;s, so the item differed from the file at that character too, which neither of us had caught.

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:

- **BREAKING CHANGES:** `types/rtti/ts`: `Ts` renders the trailing positions of
  a tuple schema that admit `undefined` as optional, matching what `parse` and
  `validate` accept. A hand-written type pinned against one needs the `?`.

Verified by fetching the stored body back and comparing the section to the file: byte-identical, no entities. Ts is unambiguous under the entry's own types/rtti/ts prefix. Say the word if you would rather keep Ts<T> and have the body fixed by hand instead — that is the only other way to get both.

The two redundant pins. Confirmed exactly as you describe: _NonFixedLength and _RestTuple die to no single mutation, only to guard-and-fallback together, because both mechanisms answer M for those shapes. The comment now says so and names the three rows that each pin one mechanism. You are also right that b4adaee's message was true when written and falsified by cd2f99c — noted rather than corrected, since the squash discards it.

The stale citation. Fixed, and thank you for catching that I repaired the substance and left the sentence. The doc now cites interiorOptionBeforeRequired and says why optionalPositions cannot stand in: its hole is followed by another omittable position, so truncation explains that row equally well.

Your non-blocking observation is recorded in RequiredPart. I checked it first — [N, option(B), (S)?] renders boolean | undefined where (boolean | undefined)? is expressible, and identically at main — so the comment says the fallback preserves that rather than introducing it.

On cd2f99c reading as a pre-existing bug: fair, and the sequencing you lay out is right. The shape was broken by f8804bc, three commits earlier in this same PR, not by main.

Head is cb0b1ea. npx tsc exit 0, 3398 tests pass.


Generated by Claude Code

sergey-shandar pushed a commit that referenced this pull request Aug 26, 2026
…`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 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.

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

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit 9b24b65 Aug 26, 2026
19 checks passed
sergey-shandar pushed a commit that referenced this pull request Aug 26, 2026
`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
sergey-shandar pushed a commit that referenced this pull request Aug 26, 2026
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.
@sergey-shandar
sergey-shandar deleted the claude/rtti-option-type-parsing-br4jql branch August 26, 2026 19:37
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