From 7438e44ddb6baac4858694610de40c7d0882b8fe Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 07:56:47 +0000 Subject: [PATCH 1/5] RTTI TS printer: recursive schemas via the data form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime printer in fjs/types/rtti/ts walked the thunk graph, so a recursive schema recursed forever ("Only acyclic schemas are supported"). It now converts through the serializable data form — thunk RTTI -> toData -> dataToTs — where recursion is a finite graph of named rules: - New dataToTs(mut?)(data) mirrors Data's shape: sorted [identifier, expression] definition pairs (render each as `type = `) plus the entry expression referencing them. Rule names that cannot name a TypeScript type alias (not an identifier, or a predefined type name such as `string`) get deterministic generated identifiers (T0, T1, ...) that skip kept names. A dangling reference panics. - printer(mut?) keeps its expression contract, now via dataToTs: a recursive schema prints as its definition's identifier instead of overflowing the stack. Output is canonical — union members follow the data form's kind order (option(number) prints 'undefined|number'), or(true, false) prints 'boolean', and the empty struct prints the whole object kind — and the tuple-with-rest / props-with-rest data patterns print naturally (readonly[A,...readonly(R)[]], struct&record intersection). Deletes todo/662.md: it proposed routing the printer's thunk walk through the shared `visit` recognizer, and the printer no longer walks the thunk ADT at all. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T8BR3aPUJDe5zZMDJKme7j --- CHANGELOG.md | 6 + fjs/types/rtti/todo/662.md | 221 ---------------- fjs/types/rtti/ts/module.f.mjs | 247 +++++++++++++++--- fjs/types/rtti/ts/proof.f.mjs | 120 ++++++++- .../66d-ts-printer-tuple-readonly-fold.md | 5 +- 5 files changed, 342 insertions(+), 257 deletions(-) delete mode 100644 fjs/types/rtti/todo/662.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d18faec5bb..c837a98df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,12 @@ history. ## Unreleased +- `types/rtti/ts`: the runtime printer supports recursive schemas — it + converts through `fjs/types/rtti/data` (new `dataToTs`), so a recursive + schema prints as `type = ` definitions plus an entry + expression instead of overflowing the stack; output follows the data form's + canonical order + [#1542](https://github.com/functionalscript/functionalscript/pull/1542). - `types/uint8array`: `toVec` attempts the conversion instead of precomputing a byte-count bound; behavior and error message unchanged [#1543](https://github.com/functionalscript/functionalscript/pull/1543) diff --git a/fjs/types/rtti/todo/662.md b/fjs/types/rtti/todo/662.md deleted file mode 100644 index 6d98471aaf..0000000000 --- a/fjs/types/rtti/todo/662.md +++ /dev/null @@ -1,221 +0,0 @@ -## 662. RTTI `ts` printer: walk the `Type` ADT through the shared `visit` - -**Priority:** P4 -**Status:** open - -### Problem - -`fjs/types/rtti/common/module.f.mjs` exports `visit` — a visitor over the -`Type` ADT — and its module header states its purpose plainly: - -> Shared kernel for RTTI consumers (`validate`, `parse`). -> … -> `visit`: a visitor over the `Type` ADT. Callers supply a `Visitor` -> with one handler per variant; `visit(v)(rtti)` recognizes `rtti` and -> calls the matching handler. Both consumers compose their top-level -> function from a visitor. - -`visit` does the whole job of recognizing a schema: evaluate a `Thunk` -once, switch on its tag (`const` / `array` / `record` / `unknown` / `or` / -a `Tag0` primitive), and route a bare `Const` through `visitConst` -(tuple / struct / `constPrimitive`). - -```ts -// fjs/types/rtti/common/module.f.mjs:164 -export const visit = (v: Visitor) => (rtti: Type): R => { - if (typeof rtti === 'function') { - const [tag, ...value] = rtti() - switch (tag) { - case 'const': return visitConst(v)(value[0] as Const) - case 'array': return v.array(value[0]) - case 'record': return v.record(value[0]) - case 'unknown': return v.unknown() - case 'or': return v.or(value) - } - return v.primitive0(tag as Primitive0) - } - return visitConst(v)(rtti) -} -``` - -```ts -// fjs/types/rtti/common/module.f.mjs:79 -const visitConst = (v: Visitor) => (c: Const): R => - typeof c === 'object' && c !== null - ? (commonIsArray(c) ? v.tuple(c) : v.struct(c as Struct)) - : v.constPrimitive(c as Primitive) -``` - -`validate` and `parse` both compose their top-level function from a -`Visitor` and call `visit(...)`. But there is a **third** walker over -the same `Type` ADT in the same `rtti/` tree — the runtime `printer` in -`fjs/types/rtti/ts/module.f.mjs` — and it never adopted `visit`. It -re-hand-rolls the identical recognition logic: - -```ts -// fjs/types/rtti/ts/module.f.mjs:41 -export const printer = (mut?: true): (rtti: Type) => string => { - const { tuple, struct, array, record } = tsPrinter(mut) - - const constToTs = (rtti: Const): string => - typeof rtti !== 'object' || rtti === null ? primitive(rtti) : - rtti instanceof Array ? tuple(rtti.map(toTs)) : - struct(Object.entries(rtti).map(([k, v]) => [k, toTs(v)])) - - const toTs = (rtti: Type): string => { - if (typeof rtti !== 'function') { return constToTs(rtti) } - const [tag, ...rest] = rtti() - switch (tag) { - case 'const': return constToTs(rest[0] as Const) - case 'array': return array(toTs(rest[0])) - case 'record': return record(toTs(rest[0])) - case 'or': return union(rest.map(toTs)) - default: return tag // tag0: 'boolean' | 'number' | 'string' | 'bigint' | 'unknown' - } - } - - return toTs -} -``` - -`constToTs` is `visitConst` with the leaves swapped (`primitive` / `tuple` / -`struct` instead of `constPrimitive` / `tuple` / `struct`). The `toTs` -`switch` is the `visit` `switch` with `array` / `record` / `or` swapped -for printer leaves and the `default` arm returning the tag string. The -schema-recognition skeleton — the part `visit` exists to own — is copied -verbatim, including the `typeof rtti === 'function'` thunk gate, the -`rtti()` evaluation, the array-vs-object Const split, and the -`null`-as-primitive handling. - -### Proposal - -Express the printer as a `Visitor` and delegate recognition to -`visit`. Every printer leaf maps one-to-one onto a visitor handler, so the -hand-rolled `constToTs` and the `toTs` switch both disappear: - -```ts -import { visit } from '../common/module.f.mjs' -import type { Visitor } from '../common/types.ts' - -export const printer = (mut?: true): (rtti: Type) => string => { - const { tuple, struct, array, record } = tsPrinter(mut) - const visitor: Visitor = { - tuple: t => tuple(t.map(toTs)), - struct: s => struct(Object.entries(s).map(([k, v]) => [k, toTs(v)])), - array: e => array(toTs(e)), - record: e => record(toTs(e)), - or: vs => union(vs.map(toTs)), - constPrimitive: c => primitive(c), - primitive0: t => t, // 'boolean' | 'number' | 'string' | 'bigint' - unknown: () => 'unknown', - } - const toTs = visit(visitor) - return toTs -} -``` - -The handlers reference `toTs` (the recursive call), which is `visit(visitor)`; -the arrow functions capture the `toTs` binding, and no handler runs during -construction, so the const-before-use is safe — the same lazy-recursion -shape `validate`/`parse` already rely on. - -This is **separation of concerns**, not just DRY: schema recognition -(thunk gate, tag switch, Const split, null handling) belongs in the one -place that owns the `Type` ADT shape — `common.visit` — and the printer -should own only the *rendering* of each variant. Today the printer owns -both, so a future `Type` variant (a new `Tag1`, say) has to be added to -`visit`'s switch **and** to `toTs`'s switch independently; with this -change the printer gets a compile error from `Visitor` until it -supplies the new leaf, which is exactly the safety `validate`/`parse` -already enjoy. - -### Why the mapping is exact - -| `visit` routes to | current printer code | visitor handler | -|---|---|---| -| `v.tuple(c)` (Const array) / `'const'`→array | `tuple(rtti.map(toTs))` | `t => tuple(t.map(toTs))` | -| `v.struct(c)` (Const object) | `struct(Object.entries(rtti)…)` | `s => struct(Object.entries(s)…)` | -| `v.constPrimitive(c)` | `primitive(rtti)` | `c => primitive(c)` | -| `v.array(e)` | `array(toTs(rest[0]))` | `e => array(toTs(e))` | -| `v.record(e)` | `record(toTs(rest[0]))` | `e => record(toTs(e))` | -| `v.or(value)` | `union(rest.map(toTs))` | `vs => union(vs.map(toTs))` | -| `v.primitive0(tag)` | `default: return tag` | `t => t` | -| `v.unknown()` | `default: return tag` (`'unknown'`) | `() => 'unknown'` | - -The two behaviours the printer documents in its JSDoc are preserved: - -- **`unknown` prints the literal `'unknown'`** (TS built-in), not - `DjsUnknown`. In `visit`, `'unknown'` is a *separate* arm - (`case 'unknown': return v.unknown()`), distinct from the `Tag0` - primitives, so `unknown: () => 'unknown'` keeps the documented string - while `primitive0: t => t` covers `'boolean' | 'number' | 'string' | - 'bigint'`. The current code lumps both into the `default` arm; the split - is harmless because both arms produce the tag string anyway. -- **`null` Const prints as a primitive.** `visitConst` routes - `typeof c === 'object' && c !== null` away from `constPrimitive`, so - `null` falls to `v.constPrimitive(null)` → `primitive(null)`, matching - `constToTs`'s `rtti === null ? primitive(rtti)` guard. - -`tsPrinter`'s leaf signatures already line up with the visitor: -`tuple: (string[]) => string`, `struct: ([string,string][]) => string`, -`array: (string) => string`, `record: (string) => string` -(`fjs/types/ts/module.f.mjs:23-29`), with `union` and `primitive` from the -same module (`:44`, `:55`). - -### Why this qualifies - -- **Separation of concerns** (always appropriate per `AGENTS.md`, no - second-consumer bar required): the `Type`-ADT recognition skeleton has a - natural home — `common.visit` — and the printer is currently the only - RTTI walker that keeps a private copy of it instead of importing it. -- **DRY at the right altitude**: `visit` already serves `validate` and - `parse`; the printer is the third real consumer of the same walk. The - skeleton (`typeof === 'function'` gate, `rtti()` eval, tag switch, - array/object Const split, null handling) is currently maintained twice. -- **Removes `as` casts.** `toTs` carries `rest[0] as Const`; `visit`/`visitConst` - already localise the unavoidable casts (`value[0] as Const`, - `c as Primitive`) inside `common`, so the printer's visitor handlers - need none — aligning with the `AGENTS.md` push to avoid `as`. - -### Caveats / why this is an idea, not a mechanical edit - -- **Import direction / cycle.** `common` already does - `/** @import { Ts } from '../ts/types.ts' */` (type-only). Adding - `import { visit } from '../common/module.f.mjs'` to `ts` makes the two - modules co-dependent at compile time. At **runtime** the edge is - one-directional (`ts → common`; the `common → ts` import is type-only - and erased), so there is no initialization-order hazard — but confirm - `npx tsc` and `npm test` stay green, since circular type graphs - occasionally surface `slow types` complaints under JSR - (i147). -- **Generic inference.** Unlike `validate`/`parse`, whose visitors return a - generic `Validate` and lean on a top-level `as any` - (historically tracked in i146), the printer's visitor is - monomorphic (`Visitor`), so it should type cleanly with **no** - cast. If TS nonetheless balks at one of the handler parameter types - (`Struct`, `Primitive`), prefer fixing the handler signature over - reintroducing `as`. -- **Scope.** This is independent of [i172](todo.md), - which proposes merging the `validate`/`parse` **container factories**. - That is about the value-walk; this is about the schema-walk in the - printer. Either can land without the other. - -### Related - -- [i172](todo.md) — unify `validate`/`parse` - container walks; complementary RTTI consolidation along the same kernel. -- i146 — the `Ts` inference / `as any` problem in the generic visitors; - the printer's visitor avoids it by being monomorphic. -- [i197](../djs/todo.md) — a sibling "adopt a shared visitor" - proposal, but over runtime `Unknown` *values* rather than the `Type` - schema ADT; same spirit, different walk. -- [`../data`](../data/README.md) — the fourth `Type`-ADT consumer - (the serializable data form); a printer already on `visit` is one fewer - fork it has to reconcile with. - -- `fjs/types/rtti/common/module.f.mjs:79,164` — `visitConst` / `visit`, the - shared schema walker. -- `fjs/types/rtti/ts/module.f.mjs:41-64` — the printer's duplicated - `constToTs` + `toTs` switch. -- `fjs/types/ts/module.f.mjs:23-29,44,55` — the printer leaves the visitor - handlers delegate to. diff --git a/fjs/types/rtti/ts/module.f.mjs b/fjs/types/rtti/ts/module.f.mjs index 4674bf0023..db0871316a 100644 --- a/fjs/types/rtti/ts/module.f.mjs +++ b/fjs/types/rtti/ts/module.f.mjs @@ -2,23 +2,224 @@ * Runtime printer mirroring the `Ts` type transformer for RTTI schemas. * See `./types.ts` for `Ts` and the `*Ts` transformer types. * + * The printer routes through the serializable RTTI data form + * (`fjs/types/rtti/data`): `thunk RTTI → toData → dataToTs`. The data form + * is a finite graph, so recursive schemas — which the thunk graph represents + * as self-referencing functions with no leaves — terminate: every named rule + * becomes a TypeScript type-alias definition and every graph edge prints as + * that alias's identifier. Output is canonical: union members follow the + * data form's kind order and object keys its sorted order, so structurally + * different but equivalent schemas print identically. + * * @module + * + * @import { Printer } from '../../ts/types.ts' + * @import { Type } from '../types.ts' + * @import { ArraySet, Data, KindSet, Node, ObjectSet, RuleSet, UnionSet } from '../data/types.ts' */ +import { assertNotNullish } from '../../../asserts/module.f.mjs' +import { definedEntries } from '../../object/module.f.mjs' import { primitive, union, printer as tsPrinter } from '../../ts/module.f.mjs' -/** @import { Const, Type } from '../types.ts' */ +import { cmp, toData, unitBit, unknown as top } from '../data/module.f.mjs' + +const nullBit = unitBit(null) +const undefinedBit = unitBit(undefined) +const falseBit = unitBit(false) +const trueBit = unitBit(true) +const booleanBits = falseBit | trueBit + +/** Predefined type names that cannot name a TypeScript type alias. */ +const predefined = /** @type {const} */ ([ + 'any', 'bigint', 'boolean', 'false', 'never', 'null', 'number', 'object', + 'string', 'symbol', 'true', 'undefined', 'unknown', 'void', +]) + +/** @type {(c: string) => boolean} */ +const isIdStart = c => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_' || c === '$' + +/** @type {(c: string) => boolean} */ +const isIdPart = c => isIdStart(c) || (c >= '0' && c <= '9') + +/** Whether `s` can name a TypeScript type alias. */ +/** @type {(s: string) => boolean} */ +const isTypeName = s => + s !== '' + && [...s].every((c, i) => i === 0 ? isIdStart(c) : isIdPart(c)) + && !predefined.some(p => p === s) + +/** + * Maps every rule name to a TypeScript type-alias identifier: the name + * itself when it can be one, else a deterministic generated `T` that + * collides with no kept name. + * + * @type {(rules: RuleSet) => readonly (readonly [string, string])[]} + */ +const identifiers = rules => { + const names = definedEntries(rules).map(([n]) => n) + const kept = names.filter(isTypeName) + /** @type {readonly (readonly [string, string])[]} */ + let result = [] + let i = 0 + for (const name of names) { + if (isTypeName(name)) { + result = [...result, [name, name]] + continue + } + let id = `T${i}` + while (kept.some(k => k === id)) { + ++i + id = `T${i}` + } + ++i + result = [...result, [name, id]] + } + return result +} + +/** @typedef {{ readonly ts: Printer, readonly ids: readonly (readonly [string, string])[] }} _Ctx */ + +/** @type {(ids: readonly (readonly [string, string])[], name: string) => string | undefined} */ +const idOf = (ids, name) => { + for (const [k, v] of ids) { + if (k === name) { return v } + } + return undefined +} + +/** + * A reference prints as its definition's identifier; a reference naming a + * missing definition is malformed data and panics. + * + * @type {(ctx: _Ctx) => (n: Node) => string} + */ +const nodeToTs = ctx => n => + typeof n === 'string' + ? assertNotNullish(idOf(ctx.ids, n), `missing definition: ${n}`) + : unionToTs(ctx)(n) + +/** + * The member expressions of one kind component: nothing when absent, the + * whole kind when `true`, one expression per member otherwise. + * + * @template T + * @param {KindSet | undefined} k + * @param {string} whole + * @param {(v: T) => string} item + * @returns {readonly string[]} + */ +const kindToTs = (k, whole, item) => + k === undefined ? [] : + k === true ? [whole] : + k.map(item) + +/** @type {(bits: number) => readonly string[]} */ +const unitToTs = bits => [ + ...((bits & nullBit) === 0 ? [] : [primitive(null)]), + ...((bits & undefinedBit) === 0 ? [] : [primitive(undefined)]), + ...((bits & booleanBits) === booleanBits ? ['boolean'] + : (bits & falseBit) !== 0 ? [primitive(false)] + : (bits & trueBit) !== 0 ? [primitive(true)] + : []), +] /** - * Creates a printer that converts an RTTI schema `Type` to its TypeScript type expression as a string. + * A tuple prints its prefix, an array its element type, and a + * tuple-with-rest combines them with a rest element: + * `readonly[A,...readonly(R)[]]`. * - * Mirrors the compile-time `Ts` mapped type at runtime. - * Pass `true` to emit mutable (non-`readonly`) types. + * @type {(ctx: _Ctx) => (p: ArraySet) => string} + */ +const arraySetToTs = ctx => p => { + const items = p.prefix.map(nodeToTs(ctx)) + const { rest } = p + if (rest === undefined) { return ctx.ts.tuple(items) } + const restTs = ctx.ts.array(nodeToTs(ctx)(rest)) + return items.length === 0 ? restTs : ctx.ts.tuple([...items, `...${restTs}`]) +} + +/** + * A struct prints its fields, a record its value type, and a + * props-with-rest combines them with an intersection. * - * **Note:** recursive schemas (e.g. `const list = () => ['array', list] as const`) - * will cause infinite recursion. Only acyclic schemas are supported. + * @type {(ctx: _Ctx) => (p: ObjectSet) => string} + */ +const objectSetToTs = ctx => p => { + const fields = definedEntries(p.props).map( + ([k, v]) => /** @type {const} */ ([k, nodeToTs(ctx)(v)])) + const { rest } = p + if (rest === undefined) { return ctx.ts.struct(fields) } + const restTs = ctx.ts.record(nodeToTs(ctx)(rest)) + return fields.length === 0 ? restTs : `${ctx.ts.struct(fields)}&${restTs}` +} + +/** @type {(u: UnionSet) => boolean} */ +const isTop = u => cmp([{}, u])([{}, top]) === 0 + +/** @type {(ctx: _Ctx) => (u: UnionSet) => string} */ +const unionToTs = ctx => u => { + if (isTop(u)) { return 'unknown' } + return union([ + ...unitToTs(u.unit ?? 0), + ...kindToTs(u.number, 'number', primitive), + ...kindToTs(u.string, 'string', primitive), + ...kindToTs(u.bigint, 'bigint', primitive), + ...kindToTs(u.array, ctx.ts.array('unknown'), arraySetToTs(ctx)), + ...kindToTs(u.object, ctx.ts.record('unknown'), objectSetToTs(ctx)), + ]) +} + +/** + * Renders a serializable RTTI {@link Data} (from `toData`) as TypeScript: + * the rule definitions as sorted `[identifier, expression]` pairs — render + * each as `type = ` — plus the entry expression, + * which references those identifiers. A schema with no reference cycles has + * no definitions and the entry expression stands alone. * - * **Note:** the `unknown` schema produces the string `'unknown'` (TypeScript's built-in), - * whereas `Ts<>` maps it to `DjsUnknown` from `djs/module.f.ts`. + * Rule names come from the data form; one that cannot name a type alias (not + * an identifier, or a predefined type name) gets a deterministic generated + * identifier (`T0`, `T1`, …). A reference naming a missing definition + * panics. + * + * @example + * ```js + * const list = () => ['array', list] + * dataToTs()(toData(list)) + * // [[['list', 'readonly(list)[]']], 'list'] + * // i.e. `type list = readonly(list)[]` and the entry expression `list` + * ``` + * + * @type {(mut?: true) => (data: Data) => readonly [readonly (readonly [string, string])[], string]} + */ +export const dataToTs = mut => ([rules, entry]) => { + /** @type {_Ctx} */ + const ctx = { ts: tsPrinter(mut), ids: identifiers(rules) } + return [ + definedEntries(rules).map(([n, u]) => + /** @type {const} */ ([nodeToTs(ctx)(n), unionToTs(ctx)(u)])), + nodeToTs(ctx)(entry), + ] +} + +/** + * Creates a printer that converts an RTTI schema `Type` to its TypeScript + * type expression as a string, through the canonical data form: `toData` + * first, then {@link dataToTs}. + * + * Mirrors the compile-time `Ts` mapped type at runtime, in the data + * form's canonical order — union members follow its kind order (e.g. + * `option(number)` prints `'undefined|number'`) and structurally different + * but equivalent schemas print identically (`or(true, false)` prints + * `'boolean'`). Pass `true` to emit mutable (non-`readonly`) types. + * + * A recursive schema prints as the identifier of its definition — use + * {@link dataToTs} to also obtain the `type = ` + * definitions the expression references; a schema with no reference cycles + * needs none. + * + * **Note:** the `unknown` schema produces the string `'unknown'` + * (TypeScript's built-in), whereas `Ts<>` maps it to `DjsUnknown` from + * `djs/module.f.ts`. * * @example * ```js @@ -26,12 +227,15 @@ import { primitive, union, printer as tsPrinter } from '../../ts/module.f.mjs' * toTs(boolean) // 'boolean' * toTs(array(number)) // 'readonly(number)[]' * toTs(record(string)) // '{readonly[k in string]?:string}' - * toTs(or(string, number)) // 'string|number' + * toTs(or(string, number)) // 'number|string' * toTs(42) // '42' * toTs('hello') // '"hello"' * toTs([boolean, number]) // 'readonly[boolean,number]' * toTs({ x: string }) // '{readonly"x":string}' * + * const list = () => ['array', list] + * toTs(list) // 'list' — see `dataToTs` for the definition + * * const toTsMut = printer(true) * toTsMut(array(number)) // '(number)[]' * toTsMut(record(string)) // '{[k in string]?:string}' @@ -40,26 +244,9 @@ import { primitive, union, printer as tsPrinter } from '../../ts/module.f.mjs' * @type {(mut?: true) => (rtti: Type) => string} */ export const printer = mut => { - const { tuple, struct, array, record } = tsPrinter(mut) - - /** @type {(rtti: Const) => string} */ - const constToTs = rtti => - typeof rtti !== 'object' || rtti === null ? primitive(rtti) : - rtti instanceof Array ? tuple(rtti.map(toTs)) : - struct(Object.entries(rtti).map(([k, v]) => [k, toTs(v)])) - - /** @type {(rtti: Type) => string} */ - const toTs = rtti => { - if (typeof rtti !== 'function') { return constToTs(rtti) } - const [tag, ...rest] = rtti() - switch (tag) { - case 'const': return constToTs(/** @type {Const} */ (rest[0])) - case 'array': return array(toTs(rest[0])) - case 'record': return record(toTs(rest[0])) - case 'or': return union(rest.map(toTs)) - default: return tag // tag0: 'boolean' | 'number' | 'string' | 'bigint' | 'unknown' - } + const toTs = dataToTs(mut) + return rtti => { + const [, entry] = toTs(toData(rtti)) + return entry } - - return toTs } diff --git a/fjs/types/rtti/ts/proof.f.mjs b/fjs/types/rtti/ts/proof.f.mjs index 0f18b4cc8c..31da8a89cc 100644 --- a/fjs/types/rtti/ts/proof.f.mjs +++ b/fjs/types/rtti/ts/proof.f.mjs @@ -1,20 +1,65 @@ -import { printer } from './module.f.mjs' +/** + * @import { Type } from '../types.ts' + * @import { Data } from '../data/types.ts' + */ + +import { assertEq } from '../../../asserts/module.f.mjs' +import { toData } from '../data/module.f.mjs' import { boolean, number, string, bigint, unknown, array, record, or, option, never } from '../module.f.mjs' -/** @import { Type } from '../types.ts' */ +import { dataToTs, printer } from './module.f.mjs' const toTs = printer() const toTsMut = printer(true) + /** @type {(rtti: Type, expected: string) => void} */ const eqMut = (rtti, expected) => { const result = toTsMut(rtti) if (result !== expected) { throw `expected ${JSON.stringify(expected)}, got ${JSON.stringify(result)}` } } + /** @type {(rtti: Type, expected: string) => void} */ const eq = (rtti, expected) => { const result = toTs(rtti) if (result !== expected) { throw `expected ${JSON.stringify(expected)}, got ${JSON.stringify(result)}` } } +/** @type {(data: Data, expected: unknown) => void} */ +const eqData = (data, expected) => { + const result = JSON.stringify(dataToTs()(data)) + const exp = JSON.stringify(expected) + assertEq(result, exp, [result, exp]) +} + +/** A recursive list: `type list = readonly list[]`. */ +/** @typedef {() => readonly ['array', _List]} _List */ +/** @type {_List} */ +const list = () => ['array', list] + +/** Mutual recursion through a container. */ +/** @typedef {() => readonly ['or', typeof number, _Forest]} _Tree */ +/** @typedef {() => readonly ['array', _Tree]} _Forest */ +/** @type {_Tree} */ +const tree = () => ['or', number, forest] +/** @type {_Forest} */ +const forest = () => ['array', tree] + +/** A cycle closing through an anonymous `or` thunk — an empty rule name. */ +/** @typedef {() => readonly ['record', () => readonly ['or', typeof string, _Lock]]} _Lock */ +/** @type {_Lock} */ +const lock = () => ['record', or(string, lock)] + +/** A recursive rule whose function name is the predefined type name `string`. */ +/** @typedef {() => readonly ['array', _StringNamed]} _StringNamed */ +/** @type {{ readonly string: _StringNamed }} */ +const stringNamedHolder = { string: () => ['array', stringNamedHolder.string] } +const stringNamed = stringNamedHolder.string + +/** A recursive rule whose function name is `T0` — the first generated identifier. */ +/** @typedef {() => readonly ['array', _T0Named]} _T0Named */ +/** @type {{ readonly T0: _T0Named }} */ +const t0NamedHolder = { T0: () => ['array', t0NamedHolder.T0] } +const t0Named = t0NamedHolder.T0 + export const proof = { tag0: { boolean: () => eq(boolean, 'boolean'), @@ -47,7 +92,8 @@ export const proof = { bigint: () => eq(7n, '7n'), emptyTuple: () => eq([], 'readonly[]'), tuple: () => eq([12, true], 'readonly[12,true]'), - emptyStruct: () => eq({}, '{}'), + // an unconstrained struct is the whole object kind + emptyStruct: () => eq({}, '{readonly[k in string]?:unknown}'), struct: () => eq( { a: number, b: string }, '{readonly"a":number,readonly"b":string}', @@ -77,7 +123,69 @@ export const proof = { mixed: () => eq(or(42, string), '42|string'), }, never: () => eq(never, 'never'), - option: () => eq(option(number), 'number|undefined'), + // union members follow the canonical kind order, `undefined` first + option: () => eq(option(number), 'undefined|number'), + normalization: { + booleanFromConsts: () => eq(or(true, false), 'boolean'), + literalAbsorbed: () => eq(or(42, number), 'number'), + sortedLiterals: () => eq(or(2, 1), '1|2'), + sortedBigints: () => eq(or(2n, 1n), '1n|2n'), + canonicalIdentity: () => { + assertEq(toTs(or(string, number)), toTs(or(number, string))) + }, + optionalProp: () => eq({ x: option(string) }, '{readonly"x":undefined|string}'), + }, + recursion: { + selfList: () => { + eq(list, 'list') + eqData(toData(list), [[['list', 'readonly(list)[]']], 'list']) + }, + mutual: () => { + eqData(toData(tree), [[['tree', 'number|readonly(tree)[]']], 'tree']) + eqData(toData(forest), [[['tree', 'number|readonly(tree)[]']], 'readonly(tree)[]']) + }, + recursiveUnion: () => { + eqData(toData(or(number, list)), [[['list', 'readonly(list)[]']], 'number|readonly(list)[]']) + }, + mutable: () => { + const [defs, entry] = dataToTs(true)(toData(list)) + assertEq(JSON.stringify([defs, entry]), JSON.stringify([[['list', '(list)[]']], 'list'])) + }, + }, + identifiers: { + // the empty rule name is not an identifier — generated `T0` + emptyName: () => { + eqData(toData(lock), [ + [['T0', 'string|{readonly[k in string]?:T0}']], + '{readonly[k in string]?:T0}', + ]) + }, + // a predefined type name cannot name an alias — generated `T0` + predefinedName: () => { + eqData(toData(stringNamed), [[['T0', 'readonly(T0)[]']], 'T0']) + }, + // a generated identifier skips names already kept + generatedCollision: () => { + eqData(toData(/** @type {const} */ ([t0Named, lock])), [ + [['T1', 'string|{readonly[k in string]?:T1}'], ['T0', 'readonly(T0)[]']], + 'readonly[T0,{readonly[k in string]?:T1}]', + ]) + }, + }, + data: { + tupleWithRest: () => { + eqData([{}, { array: [{ prefix: [{ number: true }], rest: { string: true } }] }], + [[], 'readonly[number,...readonly(string)[]]']) + }, + structWithRest: () => { + eqData([{}, { object: [{ props: { a: { number: true } }, rest: { string: true } }] }], + [[], '{readonly"a":number}&{readonly[k in string]?:string}']) + }, + wholeKinds: () => { + eqData([{}, { array: true, object: true }], + [[], 'readonly(unknown)[]|{readonly[k in string]?:unknown}']) + }, + }, mut: { array: () => eqMut(array(number), '(number)[]'), nestedArray: () => eqMut(array(array(boolean)), '((boolean)[])[]'), @@ -85,4 +193,8 @@ export const proof = { tuple: () => eqMut([12, true], '[12,true]'), struct: () => eqMut({ a: number, b: string }, '{"a":number,"b":string}'), }, + throw: { + // a dangling reference is malformed data + missingDefinition: () => dataToTs()([{}, 'nope']), + }, } diff --git a/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md b/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md index cb3fa87018..5f9bc58042 100644 --- a/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md +++ b/fjs/types/todo/66d-ts-printer-tuple-readonly-fold.md @@ -56,5 +56,6 @@ touched anyway, not on its own. ### Related -- [i662-rtti-ts-printer-visit](todo.md) — adjacent - `ts` printer work. +- [`fjs/types/rtti/ts`](../rtti/ts/module.f.mjs) — the rtti printer consuming + this `Printer` (data-driven; the former i662 proposal to route it through + `visit` was superseded when it stopped walking the thunk ADT at all). From b1c971a578416428ba3448b652782adc313b687a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 07:59:12 +0000 Subject: [PATCH 2/5] CHANGELOG: the recursive TS printer ships in #1547 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T8BR3aPUJDe5zZMDJKme7j --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c837a98df6..25854adc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ history. schema prints as `type = ` definitions plus an entry expression instead of overflowing the stack; output follows the data form's canonical order - [#1542](https://github.com/functionalscript/functionalscript/pull/1542). + [#1547](https://github.com/functionalscript/functionalscript/pull/1547). - `types/uint8array`: `toVec` attempts the conversion instead of precomputing a byte-count bound; behavior and error message unchanged [#1543](https://github.com/functionalscript/functionalscript/pull/1543) From 22d18830673a45d4e0b6764d7cb7deae50a4e42c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 13:40:54 +0000 Subject: [PATCH 3/5] TS printer: screen reserved words from type-alias names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isTypeName rejected only the 14 predefined type names, so a rule named after an ECMAScript reserved word or a type-operator keyword was kept as its own alias name and the printer emitted invalid TypeScript — `type if = readonly(if)[]` (TS2457). The screen now covers everything that cannot name a type alias: predefined type names, ECMAScript reserved words, and the type-operator keywords (infer, keyof, readonly, unique), all routed to the generated-identifier path the module's contract already promised. Proofs cover a reserved-word-named recursive thunk and a data-level type-operator name. Also rewords the CHANGELOG entry: the printer's output is the data form's canonical *form*, not merely a canonical order. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T8BR3aPUJDe5zZMDJKme7j --- CHANGELOG.md | 5 +++-- fjs/types/rtti/ts/module.f.mjs | 27 ++++++++++++++++++++------- fjs/types/rtti/ts/proof.f.mjs | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb8fd1b1e..896aedc421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,8 +23,9 @@ history. - `types/rtti/ts`: the runtime printer supports recursive schemas — it converts through `fjs/types/rtti/data` (new `dataToTs`), so a recursive schema prints as `type = ` definitions plus an entry - expression instead of overflowing the stack; output follows the data form's - canonical order + expression instead of overflowing the stack; output is the data form's + canonical form, and a rule name that cannot name a type alias (reserved + word, predefined type name, non-identifier) gets a generated identifier [#1547](https://github.com/functionalscript/functionalscript/pull/1547). - `types/bit_vec`: `tryListToVec`/`tryU8ListToVec` reuse the shared balanced fold, at the same cost as the accumulator they replace diff --git a/fjs/types/rtti/ts/module.f.mjs b/fjs/types/rtti/ts/module.f.mjs index db0871316a..06b13a2125 100644 --- a/fjs/types/rtti/ts/module.f.mjs +++ b/fjs/types/rtti/ts/module.f.mjs @@ -29,10 +29,23 @@ const falseBit = unitBit(false) const trueBit = unitBit(true) const booleanBits = falseBit | trueBit -/** Predefined type names that cannot name a TypeScript type alias. */ -const predefined = /** @type {const} */ ([ +/** + * Names that cannot name a TypeScript type alias: the predefined type + * names (`TS2457`), the ECMAScript reserved words, and the type-operator + * keywords that fail to parse in the alias-name position. + */ +const reserved = /** @type {const} */ ([ + // predefined type names 'any', 'bigint', 'boolean', 'false', 'never', 'null', 'number', 'object', 'string', 'symbol', 'true', 'undefined', 'unknown', 'void', + // ECMAScript reserved words + 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', + 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', + 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', + 'instanceof', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', + 'typeof', 'var', 'while', 'with', + // type-operator keywords + 'infer', 'keyof', 'readonly', 'unique', ]) /** @type {(c: string) => boolean} */ @@ -46,7 +59,7 @@ const isIdPart = c => isIdStart(c) || (c >= '0' && c <= '9') const isTypeName = s => s !== '' && [...s].every((c, i) => i === 0 ? isIdStart(c) : isIdPart(c)) - && !predefined.some(p => p === s) + && !reserved.some(p => p === s) /** * Maps every rule name to a TypeScript type-alias identifier: the name @@ -176,10 +189,10 @@ const unionToTs = ctx => u => { * which references those identifiers. A schema with no reference cycles has * no definitions and the entry expression stands alone. * - * Rule names come from the data form; one that cannot name a type alias (not - * an identifier, or a predefined type name) gets a deterministic generated - * identifier (`T0`, `T1`, …). A reference naming a missing definition - * panics. + * Rule names come from the data form; one that cannot name a type alias — + * not an identifier, a predefined type name, an ECMAScript reserved word, + * or a type-operator keyword — gets a deterministic generated identifier + * (`T0`, `T1`, …). A reference naming a missing definition panics. * * @example * ```js diff --git a/fjs/types/rtti/ts/proof.f.mjs b/fjs/types/rtti/ts/proof.f.mjs index 31da8a89cc..96e47a46f8 100644 --- a/fjs/types/rtti/ts/proof.f.mjs +++ b/fjs/types/rtti/ts/proof.f.mjs @@ -60,6 +60,12 @@ const stringNamed = stringNamedHolder.string const t0NamedHolder = { T0: () => ['array', t0NamedHolder.T0] } const t0Named = t0NamedHolder.T0 +/** A recursive rule whose function name is the reserved word `if`. */ +/** @typedef {() => readonly ['array', _IfNamed]} _IfNamed */ +/** @type {{ readonly if: _IfNamed }} */ +const ifNamedHolder = { if: () => ['array', ifNamedHolder.if] } +const ifNamed = ifNamedHolder.if + export const proof = { tag0: { boolean: () => eq(boolean, 'boolean'), @@ -164,6 +170,14 @@ export const proof = { predefinedName: () => { eqData(toData(stringNamed), [[['T0', 'readonly(T0)[]']], 'T0']) }, + // reserved words cannot name an alias either — generated `T0` + reservedName: () => { + eqData(toData(ifNamed), [[['T0', 'readonly(T0)[]']], 'T0']) + }, + typeOperatorName: () => { + eqData([{ infer: { array: [{ prefix: [], rest: 'infer' }] } }, 'infer'], + [[['T0', 'readonly(T0)[]']], 'T0']) + }, // a generated identifier skips names already kept generatedCollision: () => { eqData(toData(/** @type {const} */ ([t0Named, lock])), [ From 6304513a25c65e62f7f462bc139920b921631027 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 14:05:01 +0000 Subject: [PATCH 4/5] TS printer: strict-mode reserved names, optional keys, honest rest index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Screen the nine strict-mode reserved words (let, yield, implements, interface, package, private, protected, public, static) and `intrinsic` (TS2795) from type-alias names — every module is strict-mode code, so `type let = ...` fails TS1214. This finishes the reserved-word routing the previous commit started; the JSDoc's "cannot name a type alias" clause now matches the screen. - A struct key whose value set admits `undefined` may also be absent, so it prints as an optional key, mirroring Ts<>: `{x: option(string)}` prints `{readonly"x"?:undefined|string}` and TypeScript accepts `{}` like the validators do. `Printer.struct` fields (`StructField` in fjs/types/ts) take an optional third element marking the key optional. - A props-with-rest object set printed `struct&record`, applying the index signature to the declared keys and making valid values unrepresentable (`a` had to be number and string at once). TypeScript requires an index signature to cover declared keys, so the index type now widens to the union of the rest and the declared value types — the closest expressible supertype. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T8BR3aPUJDe5zZMDJKme7j --- CHANGELOG.md | 9 ++++-- fjs/types/rtti/ts/module.f.mjs | 52 +++++++++++++++++++++++++++------- fjs/types/rtti/ts/proof.f.mjs | 28 ++++++++++++++++-- fjs/types/ts/module.f.mjs | 2 +- fjs/types/ts/proof.f.mjs | 5 ++++ fjs/types/ts/types.ts | 8 +++++- 6 files changed, 86 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d3a116a4..bb288e6d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,8 +24,13 @@ history. converts through `fjs/types/rtti/data` (new `dataToTs`), so a recursive schema prints as `type = ` definitions plus an entry expression instead of overflowing the stack; output is the data form's - canonical form, and a rule name that cannot name a type alias (reserved - word, predefined type name, non-identifier) gets a generated identifier + canonical form, a struct key admitting `undefined` prints optional + (mirroring `Ts<>`), and a rule name that cannot name a type alias (reserved + word — strict-mode ones included, predefined type name, non-identifier) + gets a generated identifier + [#1547](https://github.com/functionalscript/functionalscript/pull/1547). +- `types/ts`: a `Printer.struct` field (`StructField`) takes an optional + third element marking the key optional (`"key"?: type`) [#1547](https://github.com/functionalscript/functionalscript/pull/1547). - `types/sorted_list`: the two merge tail policies are now named `keepTail` and `dropTail` instead of sharing one shadowed name; internal only diff --git a/fjs/types/rtti/ts/module.f.mjs b/fjs/types/rtti/ts/module.f.mjs index 06b13a2125..935ee9cf17 100644 --- a/fjs/types/rtti/ts/module.f.mjs +++ b/fjs/types/rtti/ts/module.f.mjs @@ -13,13 +13,13 @@ * * @module * - * @import { Printer } from '../../ts/types.ts' + * @import { Printer, StructField } from '../../ts/types.ts' * @import { Type } from '../types.ts' * @import { ArraySet, Data, KindSet, Node, ObjectSet, RuleSet, UnionSet } from '../data/types.ts' */ import { assertNotNullish } from '../../../asserts/module.f.mjs' -import { definedEntries } from '../../object/module.f.mjs' +import { at, definedEntries } from '../../object/module.f.mjs' import { primitive, union, printer as tsPrinter } from '../../ts/module.f.mjs' import { cmp, toData, unitBit, unknown as top } from '../data/module.f.mjs' @@ -44,8 +44,11 @@ const reserved = /** @type {const} */ ([ 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'while', 'with', - // type-operator keywords - 'infer', 'keyof', 'readonly', 'unique', + // reserved in strict-mode code — and every module is strict-mode code + 'implements', 'interface', 'let', 'package', 'private', 'protected', + 'public', 'static', 'yield', + // type-operator keywords, and `intrinsic` (TS2795 outside lib.d.ts) + 'infer', 'intrinsic', 'keyof', 'readonly', 'unique', ]) /** @type {(c: string) => boolean} */ @@ -90,7 +93,13 @@ const identifiers = rules => { return result } -/** @typedef {{ readonly ts: Printer, readonly ids: readonly (readonly [string, string])[] }} _Ctx */ +/** + * @typedef {{ + * readonly ts: Printer + * readonly ids: readonly (readonly [string, string])[] + * readonly rules: RuleSet + * }} _Ctx + */ /** @type {(ids: readonly (readonly [string, string])[], name: string) => string | undefined} */ const idOf = (ids, name) => { @@ -152,17 +161,38 @@ const arraySetToTs = ctx => p => { } /** - * A struct prints its fields, a record its value type, and a - * props-with-rest combines them with an intersection. + * Whether the node's value set admits `undefined` — its unit bit, read + * through a reference (own-property only) if needed. + * + * @type {(ctx: _Ctx) => (n: Node) => boolean} + */ +const admitsUndefined = ctx => n => { + const u = typeof n === 'string' ? assertNotNullish(at(n)(ctx.rules)) : n + return ((u.unit ?? 0) & undefinedBit) !== 0 +} + +/** @type {(list: readonly string[]) => readonly string[]} */ +const dedup = list => list.filter((s, i) => list.indexOf(s) === i) + +/** + * A struct prints its fields — a key whose value set admits `undefined` may + * also be absent, so it prints optional, mirroring `Ts<>` — and a record + * prints its value type. A props-with-rest set combines them with an + * intersection; TypeScript requires an index signature to cover the + * declared keys too, so the index type widens to the union of the rest and + * the declared value types — the closest expressible supertype. * * @type {(ctx: _Ctx) => (p: ObjectSet) => string} */ const objectSetToTs = ctx => p => { - const fields = definedEntries(p.props).map( - ([k, v]) => /** @type {const} */ ([k, nodeToTs(ctx)(v)])) + /** @type {readonly StructField[]} */ + const fields = definedEntries(p.props).map(([k, v]) => { + const ts = nodeToTs(ctx)(v) + return admitsUndefined(ctx)(v) ? [k, ts, true] : [k, ts] + }) const { rest } = p if (rest === undefined) { return ctx.ts.struct(fields) } - const restTs = ctx.ts.record(nodeToTs(ctx)(rest)) + const restTs = ctx.ts.record(union(dedup([...fields.map(([, v]) => v), nodeToTs(ctx)(rest)]))) return fields.length === 0 ? restTs : `${ctx.ts.struct(fields)}&${restTs}` } @@ -206,7 +236,7 @@ const unionToTs = ctx => u => { */ export const dataToTs = mut => ([rules, entry]) => { /** @type {_Ctx} */ - const ctx = { ts: tsPrinter(mut), ids: identifiers(rules) } + const ctx = { ts: tsPrinter(mut), ids: identifiers(rules), rules } return [ definedEntries(rules).map(([n, u]) => /** @type {const} */ ([nodeToTs(ctx)(n), unionToTs(ctx)(u)])), diff --git a/fjs/types/rtti/ts/proof.f.mjs b/fjs/types/rtti/ts/proof.f.mjs index 96e47a46f8..7e26d98afe 100644 --- a/fjs/types/rtti/ts/proof.f.mjs +++ b/fjs/types/rtti/ts/proof.f.mjs @@ -4,7 +4,7 @@ */ import { assertEq } from '../../../asserts/module.f.mjs' -import { toData } from '../data/module.f.mjs' +import { toData, unitBit } from '../data/module.f.mjs' import { boolean, number, string, bigint, unknown, array, record, or, option, never } from '../module.f.mjs' import { dataToTs, printer } from './module.f.mjs' @@ -139,7 +139,11 @@ export const proof = { canonicalIdentity: () => { assertEq(toTs(or(string, number)), toTs(or(number, string))) }, - optionalProp: () => eq({ x: option(string) }, '{readonly"x":undefined|string}'), + // a key admitting `undefined` may be absent — it prints optional + optionalProp: () => eq({ x: option(string) }, '{readonly"x"?:undefined|string}'), + mixedProps: () => eq( + { a: number, b: option(number) }, + '{readonly"a":number,readonly"b"?:undefined|number}'), }, recursion: { selfList: () => { @@ -178,6 +182,13 @@ export const proof = { eqData([{ infer: { array: [{ prefix: [], rest: 'infer' }] } }, 'infer'], [[['T0', 'readonly(T0)[]']], 'T0']) }, + // reserved only in strict-mode code — but every module is strict + strictModeReservedName: () => { + eqData([{ let: { array: [{ prefix: [], rest: 'let' }] } }, 'let'], + [[['T0', 'readonly(T0)[]']], 'T0']) + eqData([{ intrinsic: { array: [{ prefix: [], rest: 'intrinsic' }] } }, 'intrinsic'], + [[['T0', 'readonly(T0)[]']], 'T0']) + }, // a generated identifier skips names already kept generatedCollision: () => { eqData(toData(/** @type {const} */ ([t0Named, lock])), [ @@ -192,8 +203,19 @@ export const proof = { [[], 'readonly[number,...readonly(string)[]]']) }, structWithRest: () => { + // the index signature must cover the declared keys too, so the + // rest type widens to include the declared value types eqData([{}, { object: [{ props: { a: { number: true } }, rest: { string: true } }] }], - [[], '{readonly"a":number}&{readonly[k in string]?:string}']) + [[], '{readonly"a":number}&{readonly[k in string]?:number|string}']) + eqData([{}, { object: [{ props: { a: { string: true } }, rest: { string: true } }] }], + [[], '{readonly"a":string}&{readonly[k in string]?:string}']) + }, + optionalByReference: () => { + eqData([{ r: { unit: unitBit(null) | unitBit(undefined), number: true } }, + { object: [{ props: { p: 'r' } }] }], + [[['r', 'null|undefined|number']], '{readonly"p"?:r}']) + eqData([{ r: { number: true } }, { object: [{ props: { p: 'r' } }] }], + [[['r', 'number']], '{readonly"p":r}']) }, wholeKinds: () => { eqData([{}, { array: true, object: true }], diff --git a/fjs/types/ts/module.f.mjs b/fjs/types/ts/module.f.mjs index a6a9cd202e..5b1b8faeff 100644 --- a/fjs/types/ts/module.f.mjs +++ b/fjs/types/ts/module.f.mjs @@ -24,7 +24,7 @@ export const printer = (mut = undefined) => { return { tuple: (mut ? complex('[', ']') : complex('readonly[', ']')), struct: fields => - structX(fields.map(([k, v]) => `${ro}${JSON.stringify(k)}:${v}`)), + structX(fields.map(([k, v, opt]) => `${ro}${JSON.stringify(k)}${opt === true ? '?' : ''}:${v}`)), array: type => `${ro}(${type})[]`, // `[k:string]?:` is invalid TypeScript — optional keys on an infinite // key set require mapped-type syntax. diff --git a/fjs/types/ts/proof.f.mjs b/fjs/types/ts/proof.f.mjs index 6203784a19..692bb60b98 100644 --- a/fjs/types/ts/proof.f.mjs +++ b/fjs/types/ts/proof.f.mjs @@ -64,6 +64,11 @@ export const printerReadonlyStruct = () => { if (r !== '{readonly"x":number,readonly"y":string}') { throw r } } +export const printerOptionalField = () => { + const r = ro.struct([['x', 'number', true], ['y', 'string']]) + if (r !== '{readonly"x"?:number,readonly"y":string}') { throw r } +} + export const printerReadonlyArray = () => { const r = ro.array('string') assertEq(r, 'readonly(string)[]') diff --git a/fjs/types/ts/types.ts b/fjs/types/ts/types.ts index cb94c611dc..2fae2d5ec5 100644 --- a/fjs/types/ts/types.ts +++ b/fjs/types/ts/types.ts @@ -12,12 +12,18 @@ export type Equal = ? true : false +/** + * A `struct` field: the key, its type expression, and — when the third + * element is `true` — an optional-key marker (`"key"?: type`). + */ +export type StructField = readonly [string, string, true?] + /** * Functions for emitting TypeScript type expression strings. */ export type Printer = { readonly tuple: (types: readonly string[]) => string - readonly struct: (fields: readonly (readonly [string, string])[]) => string + readonly struct: (fields: readonly StructField[]) => string readonly array: (type: string) => string readonly record: (type: string) => string } From 5ea971930a75718cd95d53a62097597296ed41c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 14:29:21 +0000 Subject: [PATCH 5/5] Mark the Printer.struct field widening as a breaking change The StructField parameter narrowing is source-breaking for an external Printer implementation that annotates the parameter explicitly, so the CHANGELOG entry carries the BREAKING CHANGES prefix with the one-line migration. Also qualifies the reserved-list summary: the ECMAScript reserved words include the strict-mode-only ones, since every module is strict-mode code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01T8BR3aPUJDe5zZMDJKme7j --- CHANGELOG.md | 8 ++++++-- fjs/types/rtti/ts/module.f.mjs | 5 +++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb288e6d79..adfbeaad01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,12 @@ history. word — strict-mode ones included, predefined type name, non-identifier) gets a generated identifier [#1547](https://github.com/functionalscript/functionalscript/pull/1547). -- `types/ts`: a `Printer.struct` field (`StructField`) takes an optional - third element marking the key optional (`"key"?: type`) +- **BREAKING CHANGES:** `types/ts`: a `Printer.struct` field (`StructField`) + takes an optional third element marking the key optional (`"key"?: type`). + Callers and contextually-typed implementations are unaffected; an external + `Printer` implementation annotating the parameter as + `readonly (readonly [string, string])[]` must widen it to + `readonly StructField[]` [#1547](https://github.com/functionalscript/functionalscript/pull/1547). - `types/sorted_list`: the two merge tail policies are now named `keepTail` and `dropTail` instead of sharing one shadowed name; internal only diff --git a/fjs/types/rtti/ts/module.f.mjs b/fjs/types/rtti/ts/module.f.mjs index 935ee9cf17..1e1fa1e3ee 100644 --- a/fjs/types/rtti/ts/module.f.mjs +++ b/fjs/types/rtti/ts/module.f.mjs @@ -31,8 +31,9 @@ const booleanBits = falseBit | trueBit /** * Names that cannot name a TypeScript type alias: the predefined type - * names (`TS2457`), the ECMAScript reserved words, and the type-operator - * keywords that fail to parse in the alias-name position. + * names (`TS2457`), the ECMAScript reserved words — those reserved only in + * strict-mode code included, since every module is strict-mode code + * (`TS1214`) — and the type keywords that fail in the alias-name position. */ const reserved = /** @type {const} */ ([ // predefined type names