Skip to content

Split JSON rtti schemas into .f.mjs and JSON types into types.ts - #1498

Merged
sergey-shandar merged 19 commits into
mainfrom
exp
Aug 12, 2026
Merged

Split JSON rtti schemas into .f.mjs and JSON types into types.ts#1498
sergey-shandar merged 19 commits into
mainfrom
exp

Conversation

@sergey-shandar

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

Copy link
Copy Markdown
Contributor

Extracts the JSON rtti schemas out of fjs/media/json/module.f.ts into a
new JSDoc-typed fjs/media/json/rtti/module.f.mjs, and the JSON types into
a sibling fjs/media/json/types.ts.

This is the proof that a .f.mjs module can carry mutually recursive
constants
— the shape every non-trivial rtti schema group has, so the
pattern below is expected to recur as rtti spreads.

The pattern

unknown, object, and array form a cycle in the value graph: unknown
names object and array, both of which are built from unknown. As
TypeScript the cycle was closed by inference plus as const:

export const unknown = () => ['or', primitive, object, array] as const

as const has no declaration-level JSDoc equivalent. The replacement is an
explicit @type whose element types are typeof references to the other
constants:

/** @type {() => readonly['or', typeof primitive, typeof object, typeof array]} */
export const unknown = () => ['or', primitive, object, array]

Forward references are fine — unknown is annotated in terms of object and
array, which are declared below it.

Why not @type {const}

Three spellings were measured, and they fail in different places:

form npx tsc emitted .d.mts
no annotation TS2345 — literal widens to (string | …)[], not a Type
/** @type {const} */(…) inline cast clean 4 any + 2 /*elided*/
@type {() => readonly[…typeof…]} clean 0 any, 0 elided

The bare form is just the "pin literal consts" rule and fails loudly. The
trap is the middle one: it type-checks, fjs t is green, and the damage
appears only in the published declaration. @type {const} pins the tuple but
gives the emitter no name for the recursive positions, so it inlines the
structure, gives up at depth, and writes /*elided*/ any. typeof primitive /
typeof object / typeof array are names the emitter can print, so each node
of the cycle refers to its neighbours by name and the declaration stays finite
and exact — and smaller, 1392 vs 2248 characters.

Same invisible-in-repository failure mode as the curried-generic @returns
finding from #1478.

The annotation stays verified rather than asserted: types.ts keeps
Assert<Equal<Unknown, Ts<typeof unknown>>>, so the hand-written Unknown and
the schema-derived type are still checked against each other.

Both rules are now recorded in AGENTS.md §6.2 and in
todo/migrate-typescript-to-mjs.md (proposal section, task, and acceptance
criterion), since this will come up in every future rtti migration group.

Breaking change

fjs/media/json/module.f.ts no longer exports the schemas or the types:

moved from to
primitive, unknown, object, array fjs/media/json/module.f.ts fjs/media/json/rtti/module.f.mjs
Primitive, Unknown, Object, Array fjs/media/json/module.f.ts fjs/media/json/types.ts

fjs/media/json/module.f.ts itself stays TypeScript — only the schemas moved
to .f.mjs.

All 21 dependents are updated (2 module files, 1 schema importer, and the
proof files across fjs/types/*, fjs/protocol/*, fjs/media/*,
fjs/mcp, and fjs/text/utf16).

tsc --noEmit is clean, and the emitted module.f.d.mts has no any or
/*elided*/.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript cad89b0 Commit Preview URL

Branch Preview URL
Aug 12 2026, 07:22 AM

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at d30ee39aa612436caabb7d79ae9b88e4c381a412. This is clearly mid-experiment, so
not a review — just the datapoint that seems most useful given where #1497 ended up:
this approach fixes the prepack failure.

main                exit 0, 0 errors
#1497 f10c9f7f      exit 2, 5 errors
#1497 e4e8209       exit 2, 4 errors
this branch         exit 0, 0 errors

And the reason is visible in the emitted declaration, which is what makes it look like the
right fix rather than a coincidence. Naming the thunk type and annotating with it:

/** @typedef {() => ['or', typeof primitive, typeof object, typeof array]} Unknown */
/** @type {Unknown} */
export const unknown = () => ['or', primitive, object, array]

makes declaration emit write a reference instead of trying to expand a self-referential
structure:

export type Unknown = () => ['or', typeof primitive, typeof object, typeof array];
export declare const unknown: Unknown;
export declare const object: Type1<"record", Unknown>;
export declare const array: Type1<"array", Unknown>;

grep -c elided on that file is 0, where both #1497 heads emitted
Type1<"record", /*elided*/ any>. That elision was the root of all five errors, so removing
it at the source removes the whole class — a consumer importing through the declaration now
gets the same type the authored source has. npx tsc --noEmit exits 0 and npm test gives
2357/0, matching main.

Two things I noticed that I assume are just "not there yet", flagged only so they don't get
lost: types.ts is down to Primitive alone, so Unknown, Object, Array, Entry and
MapEntries have no home yet, and its Assert/Equal imports are now unused. Also
fjs/media/json/module.f.ts is still .f.ts on this branch, so the migration itself hasn't
happened — which is presumably the point of testing the rtti split on its own first.

One small thing worth deciding deliberately when you do fill it back in: the typedef is
() => ['or', ...] where main had as const, i.e. readonly ['or', ...]. It type-checks
because a mutable tuple is assignable where a readonly one is expected, but it does widen
the schema type slightly compared to main.

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 334cd919457bb3570d916597e4f644fb8c93e033. Short follow-up, since the piece that
was still open last time is now settled.

The pin is back in types.tsAssert<Equal<Unknown, Ts<typeof unknown>>>, importing
unknown across the file boundary from ./module.f.mjs — and npm run prepack is still
clean from a fresh tree: exit 0, 0 errors. That's the combination #1497 never reached. There
the pin either failed cross-file or had to be moved out of the way to stop failing, and the
four consumer errors survived either way. Here the tripwire is in place, cross-file, and
passes, and grep -c elided on the emitted fjs/media/json/module.f.d.mts is 0.

npx tsc --noEmit exits 0 and npm test gives 2357/0, matching main. Branch is level with
origin/main.

Also worth noting since I raised it on #1497: Entry and MapEntries landed in
fjs/media/json/common/module.f.ts, with MapEntries left non-exported. That is cleaner
than what #1497 had to do — it stays genuinely private rather than becoming a public name
just so a JSDoc @import could reach it.

Still a draft, so nothing to approve. No concerns from me at this head.

Records the breaking move of the JSON rtti schemas to
fjs/media/json/rtti/module.f.mjs and the JSON types to
fjs/media/json/types.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sergey-shandar sergey-shandar changed the title Exp Split JSON rtti schemas into .f.mjs and JSON types into types.ts Aug 12, 2026

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at a681da6d530636db4f5f7773e444c742931818ea. Now that this has a real title and a
CHANGELOG entry, here is a full pass. One convention gap worth fixing, one softer note, and
otherwise it looks good — including one thing it improves over main.

Fix before merge: no @module header

fjs/media/json/rtti/module.f.mjs has no @module header — the file opens with a blank
line and then the import. §4 says every module should start with one, and the comparable
migrated modules all do (fjs/media/json/tokenizer, fjs/cas, fjs/dev,
fjs/effects/node/virtual).

Worth placing it carefully rather than mechanically: earlier in this series a header written
immediately above an import got folded into that import's leading trivia and vanished from
the emitted declaration along with the import. Here the import is a value import so it would
not be elided, but leaving a blank line between the header and the first import is the safe
habit.

Softer: no co-located proof for the new module

§3.2 says a new implementation module ships with a co-located proof, and
fjs/media/json/rtti/ contains only module.f.mjs. In practice the four exports are
exercised transitively — unknown is imported at runtime by fjs/protocol/json_rpc,
fjs/protocol/mcp, and fjs/media/json/schema — and this is moved code rather than newly
written code, so I would not call it a coverage regression. I also can't measure it: npm run cov runs 0 tests and prints a vacuous 100.00 in my environment (Node v23.11.0), and it
does the same on main, so that's my setup rather than anything about this branch. Flagging
it as a structural point for you to judge, not as a demonstrated gap.

What checks out

The core question from #1497 is settled here. From a clean tree, npm run prepack exits 0
with 0 errors, with the pin living in types.ts and importing unknown across the file
boundary — the exact arrangement that failed on both #1497 heads. npx tsc --noEmit exits 0
and npm test gives 2357/0, matching main. Branch is level with origin/main.

The readonly is back (() => readonly['or', ...]), so the schema type no longer widens
against main.

Public surface: 0 type-alias differences against main — Primitive, Unknown, Object
and Array keep identical shapes across the move to types.ts. The 8 const differences are
the four schemas relocating from fjs/media/json to fjs/media/json/rtti under the same
names.

And an improvement worth calling out: main's emitted fjs/media/json/module.f.d.ts contains
a /*elided*/ any, because declaration emit could not express the self-referential thunk.
The annotation on this branch removes it — grep -c elided on the emitted
rtti/module.f.d.mts is 0. So consumers reading the declaration now get the same type the
authored source has, which was not true before this PR.

CHANGELOG: one entry, correctly scoped, links only the PR, in the usual length band for this
series. It adds without deleting — the fjs/fsc/json.f.ts / bnf.f.ts entry that #1497
dropped is untouched here.

Broken relative markdown links: 144 on both sides, none added.

Still a draft, so I'm not approving. Happy to once it's marked ready, assuming the @module
header goes in.

Mutually recursive exported constants -- the shape every non-trivial rtti
schema group has -- need an explicit @type that names its neighbours via
typeof, not /** @type {const} */.

Measured on fjs/media/json/rtti/module.f.mjs: no annotation fails TS2345
(literal widens to a mutable array); the const cast type-checks but emits
4 `any` and 2 /*elided*/ into the .d.mts because the emitter has no name
for the recursive positions and inlines the structure instead; the typeof
form emits neither and is 40% smaller. The const-cast failure is invisible
in the repository and visible only to a consumer of the published package,
so it needs a rule rather than review.

Adds AGENTS.md 6.2 subsection next to the existing @type {const} guidance,
plus a proposal section, task, and acceptance criterion in the migration
todo. Expect this to recur as rtti use grows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at cd5a10f583033b70f0327e8472fa0876e857a4bc. Docs-only since a681da6 — no code
files changed — so this is about the new rule rather than the migration.

Turning this into a written convention is the right call, and I went and reproduced the
measurements rather than taking them on faith, since they're now permanent claims in
AGENTS.md. All three rows of the table hold exactly, on
fjs/media/json/rtti/module.f.mjs:

form npx tsc emitted .d.mts documented
no annotation TS2345
/** @type {const} */(…) clean 4 any, 2 /*elided*/, 2248 chars
@type {() => readonly[…typeof…]} clean 0 any, 0 elided, 1392 chars

Including the character counts. One thing worth knowing if anyone re-runs this: a naive
grep -c any on the typeof declaration returns 2, but both are the English word "any" in
the JSDoc prose ("matching any JSON primitive"), not the type — I briefly thought the claim
was overstated before checking. The unannotated form also trips TS2344 on the round-trip
assert alongside the four TS2345s, which if anything reinforces the last paragraph's point
about keeping the assert beside the schema.

The framing that this belongs in a rule rather than in review is the part I'd most agree
with. A failure visible only to a consumer of the published .d.mts, with npx tsc and
fjs t both green, is exactly what a reviewer misses — this one only surfaced because
npm pack runs prepack's second pass against the emitted declarations.

Still outstanding from my last pass: fjs/media/json/rtti/module.f.mjs still has no
@module header (§4). Small, and the only thing I'd want before approving.

Addresses review feedback on #1498.

The @module header was missing (AGENTS.md 4). It is separated from the
first import by a blank line, per the declaration-emit hazard documented
in the migration todo; verified it survives into module.f.d.mts.

Adds the co-located proof.f.mjs that AGENTS.md 3.2 asks of a new
implementation module. Previously the four schemas were only exercised
transitively through json_rpc, mcp, and json/schema. Covers each schema's
accepted and rejected values, the nested/recursive paths through the
thunk, and that JSON `unknown` rejects `undefined` where rtti core's does
not. Suite goes 2357 -> 2368, 0 failures.

The shared tag helper takes the erased ValidateE rather than a generic
Type; instantiating validate's generic result per schema hits TS2589 on
these mutually recursive schemas.

Also avoids the literal token "elided" in the module doc comment, so
`grep -c elided` on the emitted declaration stays a usable check for the
condition this module is the test case for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sergey-shandar

Copy link
Copy Markdown
Contributor Author

@codex review

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 75bdaa25541b69ce535f6d33535217ba27e14978. Both items from my earlier passes are
resolved, and nothing new turned up. No blocking concerns left.

@module header. Present, and placed with a blank line before the first import — which
matters, because that's the arrangement that survives declaration emit. grep -c '@module'
on the emitted rtti/module.f.d.mts is 1, so it didn't get folded into the import's
leading trivia the way it did earlier in this migration series.

Co-located proof. fjs/media/json/rtti/proof.f.mjs covers all four exports. Tests go
2357 → 2368, and all 11 new cases are discovered and run. Its three runtime imports are
.f.mjs (asserts, types/rtti/validate, and the sibling module), so proof.f.mjs rather
than proof.f.ts is the right extension under §2.

I checked the proof isn't vacuous rather than assuming it. Flipping one expectation —
assertEq(unknownAccepts(undefined), 'error')'ok' — produces exactly one failure,
correctly attributed:

proof.unknown.rejectsUndefined(): error
Number of tests: pass: 2367, fail: 1, total: 2368

Something I hadn't expected and think is worth recording: the schemas now have three
independent guards, and they catch different things. I tried mutating unknown to admit
rtti core's unknown (the exact undefined-shaped drift this whole series has been about)
and it never reached the proof — the explicit @type rejected it at typecheck on arity,
Source has 5 element(s) but target allows only 4. So the annotation isn't just a
declaration-emit fix, it's load-bearing for correctness; the Assert<Equal<…>> pin catches
type-versus-schema divergence; and the proof catches expectation drift at runtime. That's a
good place for this module to end up.

Everything else. From a clean tree npm run prepack exits 0 with 0 errors, npx tsc --noEmit exits 0, the emitted declaration has 0 elided and 0 type-level any, the public
type surface is unchanged against main (0 alias differences), the new proof declaration adds
only proof itself, broken markdown links are 144 on both sides, and the branch is level
with origin/main. The See AGENTS.md §6.2 pointer in the new header resolves — the rule
really is in that section.

Still marked draft, so I'll leave the approval for when you flip it to ready; from my side
there's nothing outstanding.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 75bdaa2554

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread fjs/media/json/types.ts
@@ -0,0 +1,14 @@
import type { Assert } from "../../asserts/types.ts"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Add the required module documentation header

Add the repository-required JSDoc @module header before these imports; as written, the new public type-only module has no module-level API documentation for readers or generated documentation.

AGENTS.md reference: AGENTS.md:L254-L265

Useful? React with 👍 / 👎.

Codex flagged the new public type-only module as missing the §4 header.
Placed with a blank line before the first import, so it survives declaration
emit rather than folding into the import's leading trivia.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sergey-shandar

Copy link
Copy Markdown
Contributor Author

@codex review

@sergey-shandar
sergey-shandar marked this pull request as ready for review August 12, 2026 07:08

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Reviewed at 6dbe384f68798eab4d7299d79a89797fda1a7b0a. Approving — everything I raised across
the earlier passes is resolved, and the final commit's header placement holds up under the
one check that mattered for it.

types.ts is the interesting case for a @module header, because every import in that file
is import type. That's the exact shape that bit this migration series before: a header
written flush against a type-only import gets absorbed into the import's leading trivia and
disappears from the emitted declaration along with it. The blank line here prevents that, and
it's confirmed rather than assumed — @module survives emit in all three:

fjs/media/json/types.d.ts             1
fjs/media/json/rtti/module.f.d.mts    1
fjs/media/json/rtti/proof.f.d.mts     1

Full state at this head, from a clean tree:

  • npm run prepack exits 0 with 0 errors — the original blocker, and the reason #1497 was
    closed. npx tsc --noEmit exits 0.
  • npm test: 2368 pass / 0 fail, up from main's 2357 by exactly the 11 new proof cases.
  • Emitted rtti/module.f.d.mts has 0 /*elided*/ and 0 type-level any, where main's
    fjs/media/json/module.f.d.ts still carries an elision. This PR leaves the published
    declaration strictly more faithful than it found it.
  • Public type surface: 0 alias differences. 9 const differences, all accounted for — the four
    schemas moving from fjs/media/json to fjs/media/json/rtti under the same names, plus the
    new proof export.
  • Broken relative markdown links: 144, identical to main. Level with origin/main.
  • CHANGELOG: one entry, correctly scoped, links only the PR, and adds without deleting.

The part I'd single out is that the typeof cross-reference turned out to be load-bearing
for more than declaration emit. When I tried to mutate unknown into admitting rtti core's
unknown — the undefined-shaped drift this whole series has circled — it never reached the
proof, because the annotation rejected it at typecheck on arity. Three independent guards now
sit on this module and each catches something the others don't: the annotation on schema
drift, the Assert<Equal<…>> pin on type-versus-schema divergence, and the proof on
expectation drift, which I verified is live by flipping one expectation and getting exactly
one correctly-named failure.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6dbe384f68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

export type Array = readonly Unknown[]

type _Unknown = Assert<Equal<Unknown, Ts<typeof unknown>>>
import type { Object, Unknown } from './types.ts'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Update docs for the split JSON API

After moving the types and schemas out of this module, its header still advertises “JSON value types, rtti schemas” and says those schemas are defined here. The active design in fjs/djs/todo/663-json-djs-tree-type.md:45-60,74-75 likewise imports Primitive from the removed location and requires the aliases to remain there, while fjs/media/html/todo/665-json-html.md:90 still says Unknown lives there. These instructions now direct future work to a nonexistent API, so update the module JSDoc and dependent todo designs to use types.ts/rtti/module.f.mjs.

AGENTS.md reference: AGENTS.md:L337-L339

Useful? React with 👍 / 👎.

Codex flagged that the split left instructions pointing at a surface that no
longer exists. `fjs/media/json/module.f.ts`'s header still advertised the types
and schemas as living there, and three todo designs would have directed future
work to import them from the old location.

- module.f.ts header: describe the utilities it still owns, and point at
  `./types.ts` and `./rtti/module.f.mjs` for the rest.
- djs/todo/663: the alias block and the `Primitive` import now name `types.ts`,
  plus a compatibility bullet — re-expressing the aliases over the shared
  generic tree has to keep the `Assert<Equal<...>>` pin satisfied.
- media/html/todo/665 and media/json/todo/standard-parse-serialize: split the
  "value types" half of the reference off to `types.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sergey-shandar

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: cad89b029d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 2209a39 Aug 12, 2026
19 checks passed
sergey-shandar added a commit that referenced this pull request Aug 12, 2026
Every import in this file is import type, so a header without a
blank line before the imports gets folded into the first import's
leading trivia and dropped from the emitted declaration (per review
on #1499, following the same fix applied in #1498).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@sergey-shandar
sergey-shandar deleted the exp branch August 12, 2026 18:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants