Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d24983a
types/rtti: pin that every trailing optional position may be absent
claude Aug 26, 2026
e82031f
types/rtti: file the issue for `parse` omitting `undefined` members
claude Aug 26, 2026
29ed885
types/rtti: assert the expected answer for the trailing-optional rows
claude Aug 26, 2026
8a4b6c7
types/rtti: cover omission before a later optional position
claude Aug 26, 2026
424e564
types/rtti: run the optional-position cases against the closed form too
claude Aug 26, 2026
148357a
types/rtti/ts: render a tuple's trailing omittable positions optional
claude Aug 26, 2026
7f7cb65
types/rtti: pin the `extra` literal in the optional-position proof
claude Aug 26, 2026
303376d
types/rtti/ts: keep a non-fixed-length tuple schema's element type
claude Aug 26, 2026
a351949
types/rtti/ts: spell the `Ts<>` pins as schema types, not values
claude Aug 26, 2026
a6942c1
Merge origin/main into claude/rtti-option-type-parsing-br4jql
claude Aug 26, 2026
f8804bc
types/rtti/ts: keep a variadic tuple schema's shape
claude Aug 26, 2026
b4adaee
types/rtti: pin interior-before-required, the rest tuple, and the len…
claude Aug 26, 2026
cd2f99c
types/rtti/ts: keep a schema's own optional tuple member
claude Aug 26, 2026
b2f1484
types/rtti: run the interior-before-required case through the closed …
claude Aug 26, 2026
990912e
types/rtti/ts: split a union of tuple schemas per member
claude Aug 26, 2026
12f3df7
Merge remote-tracking branch 'origin/main' into claude/rtti-option-ty…
claude Aug 26, 2026
527a0ba
Merge branch 'main' into claude/rtti-option-type-parsing-br4jql
sergey-shandar Aug 26, 2026
b3ff284
changelog: cut 1708 to the documented length
claude Aug 26, 2026
7775427
Merge remote-tracking branch 'origin/claude/rtti-option-type-parsing-…
claude Aug 26, 2026
cb0b1ea
types/rtti: fix the stale proof citation, and note two limits
claude Aug 26, 2026
81666ff
Merge branch 'main' into claude/rtti-option-type-parsing-br4jql
sergey-shandar Aug 26, 2026
e3dae1e
types/rtti/todo: correct the claim about what `optionalPositions` can…
claude Aug 26, 2026
9f6e131
Merge branch 'main' into claude/rtti-option-type-parsing-br4jql
sergey-shandar Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/unreleased/1708.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- **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 `?`.
14 changes: 11 additions & 3 deletions fjs/types/rtti/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,9 +163,17 @@ a narrower one — so `StructTs` already renders `Struct` openly with no extra
work. A tuple type is exact-length by default, and expressing "these
positions, plus anything after" needs a rest element applied *generically*
over an arbitrary schema tuple `T`; that specific derivation is what
TypeScript can't carry through (`TupleTs`'s doc comment has the two concrete
errors). So `Ts<T>` renders `Tuple` closed even though the schema is open —
one kind needed no workaround, the other has none.
TypeScript can't carry through (`TupleTs`'s doc comment has the concrete
errors). So `Ts<T>` renders `Tuple` exact-length at the top end even though
the schema is open.

The *bottom* end is rendered: a trailing position whose set admits
`undefined` prints optional, so `Ts<[number, option(string)]>` is
`readonly[number, (string|undefined)?]` and an array may stop at the last
required position, exactly as both readers accept. 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, which narrows the
spelling and not the set.

`parse/proof.f.mjs` and `validate/proof.f.mjs` pin openness on both kinds.

Expand Down
168 changes: 168 additions & 0 deletions fjs/types/rtti/todo/parse-omits-undefined-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# `parse` builds the members it should omit

**Priority:** P2 — the array kind's JSON round-trip is a data defect, not just
a canonicality gap
**Status:** open — both halves are now unblocked; what remains is the change
itself (see [The type-level obstacle is gone](#the-type-level-obstacle-is-gone))

## Problem

RTTI has one rule for absence, stated in [`../README.md`](../README.md) for
both container kinds: an absent member reads as `undefined`, so **a member is
required exactly when its set excludes `undefined`**. Absence *is* `undefined`
— the two are one thing, which is why `[number, option(string)]` accepts
`[42]` and `{ a: number, b: option(string) }` accepts `{ a: 1 }`.

`parse` reads that rule on the way in and then contradicts it on the way out.
It materializes the member it just decided was absent (verified at `d24983a`):

| schema | value | `parse` builds |
| --- | --- | --- |
| `[number, option(string)]` | `[42]` | `[42, undefined]` |
| `{ a: number, b: option(string) }` | `{ a: 1 }` | `{ a: 1, b: undefined }` |
| `close([number, option(string)])` | `[1]` | `[1, undefined]` |

Both spellings denote the same RTTI value, and `parse` picks the one that
spells absence as a present member. `validate` has nothing to pick — it returns
what it was handed — so the disagreement is `parse`'s alone.

### It breaks a JSON round-trip on the array kind

JSON has no `undefined`, and an array element that holds one serializes as
`null`. So the value `parse` builds does not survive the format it was most
likely read from:

```js
const s = [number, option(string)]
parse(s)([42]) // ['ok', [42, undefined]]
JSON.stringify([42, undefined]) // '[42,null]'
parse(s)([42, null]) // ['error', { path: ['1'], message: 'no match' }]
```

The omitted spelling round-trips: `'[42]'` re-parses to `['ok', [42, undefined]]`.

The struct kind happens to work, because `JSON.stringify` already drops a key
whose value is `undefined` — it applies the very rule this issue asks `parse`
to apply. So today the two kinds disagree about their own output in a way
nothing in the module states, and the kind that disagrees loses data.

The same follows for any format without `undefined` (CBOR, and the canonical
byte-level forms `../../../cas` hashes): two values that are equal under RTTI
serialize differently, so they address differently.

## Proposal

**Omit, don't materialize.** A parsed member whose value is `undefined` is not
written into the result.

- **Struct kind** — drop the key. `parse({ a: number, b: option(string) })({ a: 1 })`
builds `{ a: 1 }`, and `'b' in result` is false.
- **Tuple kind** — drop the **trailing run** only. An interior position cannot
be dropped without shifting the positions after it, so an interior
`undefined` stays an explicit element: `[number, option(string), number]`
against `[1, undefined, 3]` builds `[1, undefined, 3]` unchanged, while
`[number, bigint, option(string), option(null)]` against `[2, 4n]` builds
`[2, 4n]`.
- The closed forms follow, building their declared members exactly as the open
ones do.

Where it lands: `arrayRebuild` and `recordRebuild` in
[`../parse/module.f.mjs`](../parse/module.f.mjs) are the two rebuild functions
all the container factories share, so each kind changes in one place.

Two cases the implementation must answer rather than discover, both settled by
the same rule — `undefined` is absence, whatever put it there:

- A member whose *input* is explicitly `undefined` (`{ a: 1, b: undefined }`)
and a member declared with a set that is only `undefined` (`{ a: undefined }`,
or a `close({ a: unknown })` whose `a` is `undefined`) are dropped too.
- `array`/`record` share those rebuilds, so the rule reaches them unless it is
gated per kind. Uniform is the position this issue takes — `ArrayTs` is an
unbounded `ReadonlyArray` and `RecordTs`'s keys are already optional, so
neither costs anything at the type level — but it is a decision, not a
side effect to leave unstated.

### The type-level obstacle is gone

Both halves are now free at the type level.

The struct half always was. `StructTs` renders an admits-`undefined` key as
optional (`OptionalFields` in [`../ts/types.ts`](../ts/types.ts)) and keeps
`undefined` in the value type, so `{ a: 1 }` and `{ a: 1, b: undefined }` are
both assignable under this repo's `exactOptionalPropertyTypes: true`.

The tuple half was the blocker: `TupleTs` mapped a schema tuple to a
**required-length** tuple, so a dropped result would not have inhabited its own
declared type —

```
error TS2322: Type '[number]' is not assignable to type 'readonly [number, string | undefined]'.
Source has 1 element(s) but target requires 2.
```

`TupleTs` now renders the trailing admits-`undefined` positions optional, so
`Ts<[number, bigint, option(boolean), option(string)]>` is
`readonly[number, bigint, (boolean|undefined)?, (string|undefined)?]` and both
spellings — `[1, 2n]` and `[1, 2n, undefined, undefined]` — inhabit it. The
derivation and the three errors it had to defeat are in `TupleTs`'s doc
comment; `_tupleOption` and `_tupleInteriorOption` pin the rendering.

That was the one thing this issue needed decided before it could proceed. What
is left is the change itself, plus one question it does not settle:
`array`/`record` share `parse`'s rebuilds, so the rule reaches them unless it
is gated per kind (this issue says uniform — `ArrayTs` is an unbounded
`ReadonlyArray` and `RecordTs`'s keys are already optional, so neither costs
anything at the type level).

Only the *trailing* run renders optional, because TypeScript forbids a required
element after an optional one. That is a spelling limit, not a narrower set: an
interior position admitting `undefined` may still be absent at runtime, which
`../validate/proof.f.mjs`'s `interiorOptionBeforeRequired` pins —
`[option(string), number]` accepts `[, 5]`, the required position after the
hole being present. `optionalPositions` cannot say it: its hole falls *inside*
the trailing omittable run, so no position the renderer marks required follows
it. (Not that truncation explains that row — truncation would predict its
rejection, and all three readers accept it, which is what that proof's own
comment records.)

## Tasks

- [x] Decide the tuple half — `TupleTs` renders trailing omittable positions
optional, so neither kind is blocked at the type level any more.
- [ ] Omit in `arrayRebuild`/`recordRebuild`: drop the key on the struct kind,
the trailing `undefined` run on the array kind; open and closed alike.
- [ ] Settle whether `array`/`record` follow (this issue says yes).
- [ ] `../README.md`: the two-readers table row "absent optional member"
(`parse`: "present as `undefined`") and the openness row
`[number, option(string)] | [42] | [42, undefined]`.
- [ ] The proofs that pin the current spelling:
`../parse/proof.f.mjs`'s `shortArrayFillsAnOptionalPosition` and the
closed `shortArray`, and `../validate/proof.f.mjs`'s
`absentOptionalStaysAbsent`, whose contrast assertion is
`'b' in unwrap(parse(schema)(input))`.
- [ ] Add the JSON round-trip above as a proof case, so the defect cannot
return unnoticed.
- [ ] Changelog: **BREAKING** — `parse` no longer materializes an absent
optional member.

## Related

- [`../parse/module.f.mjs`](../parse/module.f.mjs) — `arrayRebuild` /
`recordRebuild`, the two rebuild points.
- [`../README.md`](../README.md) — "Structs and tuples are open" states the
absence rule this issue applies to construction, and "The two schema-form
readers" tabulates the row that changes.
- [`../ts/types.ts`](../ts/types.ts) — `TupleTs` (the optional-position
derivation, and the errors it defeats) and `OptionalFields` (the struct
half's).
- The same "a hole and a declared `undefined` are one thing" question from the
*schema* side, which this issue asks from the *value* side. It shipped as
[#1712](https://github.com/functionalscript/functionalscript/pull/1712) —
`parse` and `validate` read a tuple schema by length, so a hole in one is a
declared position whose schema is `undefined`. That settles the schema side
in favour of the reading this issue assumes, and leaves
[schema-walk-own-indices](./schema-walk-own-indices.md) as what remains of
it: whether that walk goes by own indices or by iteration.
- [PR #1708](https://github.com/functionalscript/functionalscript/pull/1708) —
added the acceptance rows for several trailing optional positions, which is
where the construction side came up.
72 changes: 71 additions & 1 deletion fjs/types/rtti/ts/proof.f.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,84 @@
/**
* @import { Type } from '../types.ts'
* @import { Or, Type } from '../types.ts'
* @import { Data } from '../data/types.ts'
* @import { Ts, TupleTs } from './types.ts'
* @import { Assert } from '../../../asserts/types.ts'
* @import { Equal } from '../../ts/types.ts'
*/

import { assertEq } from '../../../asserts/module.f.mjs'
import { toData, unitBit } from '../data/module.f.mjs'
import { boolean, number, string, bigint, unknown, array, close, record, or, option, never } from '../module.f.mjs'
import { dataToTs, printer } from './module.f.mjs'

// ── `Ts<T>` over a tuple schema ─────────────────────────────────────────────
//
// Spelled as schema *types* rather than `typeof` a value: these are type-level
// facts, and a value existing only to be pointed at is an unused one.
//
// `TupleTs` splits off the trailing run of positions admitting `undefined` and
// renders it optional, which needs a known length. A schema array of non-fixed
// length — what `.map()` produces — has no trailing position to split off, so
// it keeps its element type instead, the homomorphic mapping's answer. Pinned
// because a split that falls back to the empty tuple silently renders such a
// schema `readonly []`, and nothing else here would have caught it.
/** @typedef {Assert<Equal<Ts<readonly (typeof number | typeof bigint)[]>, readonly (number | bigint)[]>>} _NonFixedLength */

// `option(t)` is `or(t, undefined)`; these are the schema types it produces.
/** @typedef {Or<readonly [typeof boolean, undefined]>} _OptionBoolean */
/** @typedef {Or<readonly [typeof string, undefined]>} _OptionString */

// A variadic tuple is the shape the `length` guard exists for, and the only
// one: its peel *succeeds*, binding the unknown-length prefix to `I`, so
// without the guard the reconstruction flattens it. The others below reach the
// fallback because their peel fails, and are held by that alone.
//
// Asserted as assignability rather than with `Equal<>`. `Equal<>` reports this
// shape as unchanged whether or not the guard is in place — it cannot see the
// difference — so an `Equal<>` pin here passes over the bug it is meant to
// catch. What the flattening actually costs is a string admitted in the number
// prefix, so that is what these state.
/** @typedef {readonly [...(typeof number)[], _OptionString]} _VariadicSchema */
/** @typedef {Assert<readonly [1, 'x', 2] extends Ts<_VariadicSchema> ? false : true>} _VariadicPrefixRejectsMixedPrefix */
/** @typedef {Assert<readonly [1, 2, 'x'] extends Ts<_VariadicSchema> ? true : false>} _VariadicPrefixAdmitsItsOwnShape */

// A rest element after a fixed prefix is the same shape from the other side,
// and is held for the same reason: `length` is `number`, so the mapping stands.
//
// This row and `_NonFixedLength` document intent rather than discriminate a
// mechanism. The guard and the fallback both answer `M` for these two shapes,
// so neither single mutation moves them — only removing both at once does.
// The rows that pin one mechanism each are `_VariadicPrefixRejectsMixedPrefix`
// (the guard), `_OptionalMember` (the fallback) and
// `_UnionKeepsBranchCorrelation` (the distribution).
/** @typedef {Assert<Equal<Ts<readonly [typeof number, ...(typeof string)[]]>, readonly [number, ...string[]]>>} _RestTuple */

// A schema whose own tuple type already marks a member optional is held by the
// *fallback* rather than the length guard: its length is `1 | 2`, not `number`,
// so it reaches the split, where the peel needs a required last element and
// finds none. An optional position is what this transform produces, so one the
// caller wrote is already in the target form and the mapping stands.
/** @typedef {Assert<Equal<Ts<readonly [typeof number, (typeof string)?]>, readonly [number, string?]>>} _OptionalMember */

// A union of tuple schemas is split per member, not once across the union.
// Splitting the union lets the two halves distribute independently and the
// spread then pairs every prefix with every suffix, so `[number, boolean]` —
// A's prefix with B's suffix — would pass. Assignability again: this is a
// statement about which values the union admits.
/** @typedef {readonly [typeof number, _OptionString]} _BranchA */
/** @typedef {readonly [typeof string, _OptionBoolean, _OptionNumber]} _BranchB */
/** @typedef {Or<readonly [typeof number, undefined]>} _OptionNumber */
/** @typedef {Assert<readonly [1, true] extends TupleTs<_BranchA | _BranchB> ? false : true>} _UnionKeepsBranchCorrelation */
/** @typedef {Assert<readonly [1, 'x'] extends TupleTs<_BranchA | _BranchB> ? true : false>} _UnionAdmitsItsOwnBranches */

/** @typedef {Assert<Equal<Ts<readonly [typeof number, typeof bigint, _OptionBoolean, _OptionString]>, readonly [number, bigint, (boolean | undefined)?, (string | undefined)?]>>} _OptionalTail */

// Only the *trailing* run: TypeScript forbids a required element after an
// optional one, so an interior position that admits `undefined` stays required.
/** @typedef {Assert<Equal<Ts<readonly [_OptionString, typeof number]>, readonly [string | undefined, number]>>} _InteriorStaysRequired */

const toTs = printer()

const toTsMut = printer(true)

/** @type {(rtti: Type, expected: string) => void} */
Expand Down
Loading
Loading