diff --git a/CHANGELOG.md b/CHANGELOG.md index aab05191e2..adfbeaad01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,22 @@ 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 is the data form's + 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). +- **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 [#1546](https://github.com/functionalscript/functionalscript/pull/1546) 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 a76df02a56..1e1fa1e3ee 100644 --- a/fjs/types/rtti/ts/module.f.mjs +++ b/fjs/types/rtti/ts/module.f.mjs @@ -2,24 +2,268 @@ * 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 { Const, Type } from '../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 { 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' + +const nullBit = unitBit(null) +const undefinedBit = unitBit(undefined) +const falseBit = unitBit(false) +const trueBit = unitBit(true) +const booleanBits = falseBit | trueBit + +/** + * Names that cannot name a TypeScript type alias: the predefined type + * 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 + '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', + // 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} */ +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)) + && !reserved.some(p => p === s) /** - * Creates a printer that converts an RTTI schema `Type` to its TypeScript type expression as a string. + * 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. * - * Mirrors the compile-time `Ts` mapped type at runtime. - * Pass `true` to emit mutable (non-`readonly`) types. + * @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])[] + * readonly rules: RuleSet + * }} _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. * - * **Note:** recursive schemas (e.g. `const list = () => ['array', list] as const`) - * will cause infinite recursion. Only acyclic schemas are supported. + * @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. * - * **Note:** the `unknown` schema produces the string `'unknown'` (TypeScript's built-in), - * whereas `Ts<>` maps it to `DjsUnknown` from `djs/module.f.ts`. + * @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)] + : []), +] + +/** + * 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)[]]`. + * + * @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}`]) +} + +/** + * 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 => { + /** @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(union(dedup([...fields.map(([, v]) => v), 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. + * + * 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 + * 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), 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 @@ -27,12 +271,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}' @@ -41,26 +288,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 60f9e648e0..7e26d98afe 100644 --- a/fjs/types/rtti/ts/proof.f.mjs +++ b/fjs/types/rtti/ts/proof.f.mjs @@ -1,23 +1,71 @@ /** * @import { Type } from '../types.ts' + * @import { Data } from '../data/types.ts' */ -import { printer } from './module.f.mjs' +import { assertEq } from '../../../asserts/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' 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 + +/** 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'), @@ -50,7 +98,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}', @@ -80,7 +129,99 @@ 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))) + }, + // 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: () => { + 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']) + }, + // 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']) + }, + // 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])), [ + [['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: () => { + // 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]?: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 }], + [[], 'readonly(unknown)[]|{readonly[k in string]?:unknown}']) + }, + }, mut: { array: () => eqMut(array(number), '(number)[]'), nestedArray: () => eqMut(array(array(boolean)), '((boolean)[])[]'), @@ -88,4 +229,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). 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 }