Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,41 @@ inline-cast position gives it the special const-assertion meaning. This is
unlike every other `@type` cast, which works in both positions — don't
"clean up" a `@type {const}` inline cast into the declaration form.

#### Mutually recursive constants: cross-reference with `typeof`

When exported constants refer to each other in a cycle — the usual shape for a
recursive rtti schema, where `unknown` names `object` and `array` and both are
built from `unknown` — pin them with an explicit `@type` whose element types are
`typeof` references to the other constants, **not** with `@type {const}`:

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

export const object = record(unknown)
export const array = rttiArray(unknown)
```

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

`@type {const}` is wrong here even though it compiles. It pins the tuple, so
`npx tsc` and `fjs t` both pass — but it gives declaration emit no *name* for
the recursive positions, so the emitter inlines the structure, gives up at
depth, and writes `/*elided*/ any`. On `fjs/media/json/rtti/module.f.mjs` the
const cast emitted 4 `any` and 2 `/*elided*/`; the `typeof` form emitted
neither. Only a consumer type-checking against the published `.d.mts` sees the
difference, which is why this needs to be a rule rather than something review
catches. Omitting the annotation entirely is a third, louder failure: the array
literal widens to `(string | …)[]` and fails `TS2345` outright (see "Pin literal
`const`s" above).

Pair the annotation with a round-trip assert so it stays checked rather than
merely claimed — `fjs/media/json/types.ts` holds
`Assert<Equal<Unknown, Ts<typeof unknown>>>`. An explicit `@type` on a constant
whose type the compiler would otherwise infer is only as trustworthy as what
verifies it.

#### Avoid type predicates

Avoid TypeScript type predicates (`(x: T): x is U`). They are error-prone: the
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ history.

## Unreleased

- **BREAKING CHANGES:** the JSON rtti schemas `primitive`, `unknown`,
`object`, and `array` move from `fjs/media/json/module.f.ts` to a new
JSDoc-typed `fjs/media/json/rtti/module.f.mjs`, and the `Primitive`,
`Unknown`, `Object`, and `Array` types to a sibling
`fjs/media/json/types.ts` — importers must use the `rtti/module.f.mjs`
specifier for the schemas and the `types.ts` specifier for the types.
`fjs/media/json/module.f.ts` stays TypeScript and no longer re-exports
either
[#1498](https://github.com/functionalscript/functionalscript/pull/1498)
- **BREAKING CHANGES:** `fjs/fsc/json.f.ts` and `fjs/fsc/bnf.f.ts` migrate
from authored TypeScript to JSDoc-typed JavaScript (`.f.mjs`) — no
local types to split, both use only `Rule`/`TerminalRange` from
Expand Down
2 changes: 1 addition & 1 deletion fjs/djs/module.f.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* @module
*/
import type { Primitive as JsonPrimitive } from '../media/json/module.f.ts'
import type { Primitive as JsonPrimitive } from '../media/json/types.ts'
import { transpile } from './transpiler/module.f.ts'
import { stringify, stringifyAsTree } from './serializer/module.f.ts'
import { sort } from '../types/object/module.f.mjs'
Expand Down
9 changes: 6 additions & 3 deletions fjs/djs/todo/663-json-djs-tree-type.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Keep the names the modules already use and import the shared definitions through
a namespace where useful:

```ts
// fjs/media/json/module.f.ts
// fjs/media/json/types.ts
import type * as Tree from './common/module.f.ts'
export type Primitive = boolean | string | number | null
export type Unknown = Tree.Unknown<Primitive>
Expand All @@ -53,7 +53,7 @@ export type Array = Tree.Array<Primitive>
```ts
// fjs/djs/module.f.ts
import type * as Tree from '../media/json/common/module.f.ts'
import type { Primitive as JsonPrimitive } from '../media/json/module.f.ts'
import type { Primitive as JsonPrimitive } from '../media/json/types.ts'
export type Primitive = JsonPrimitive | bigint | undefined
export type Unknown = Tree.Unknown<Primitive>
export type Object = Tree.Object<Primitive>
Expand All @@ -80,6 +80,9 @@ serialization behavior.
JSON tree, `undefined` is not a primitive leaf, but `object[key]` can still be
`undefined` because the property is absent.
- Preserve `readonly` recursive containers.
- Keep `fjs/media/json/types.ts`'s
`Assert<Equal<Unknown, Ts<typeof unknown>>>` pin against the rtti schema in
`fjs/media/json/rtti/module.f.mjs` satisfied by the re-expressed aliases.
- Keep the shared module in the JSON/DJS family rather than promoting it to a
broader generic types package without another real consumer.
- Confirm recursive generic aliases work with `tsc` and the repository's Deno
Expand Down Expand Up @@ -109,7 +112,7 @@ serialization behavior.
- [157](./157.md) — shares JSON/DJS parser value machinery; complementary to
sharing the recursive value type.
- [197](./197.md) — extracts traversal over the same `Unknown` shape.
- `fjs/media/json/module.f.ts` — current JSON recursive type aliases.
- `fjs/media/json/types.ts` — current JSON recursive type aliases.
- `fjs/djs/module.f.ts` — current DJS recursive type aliases.
- `fjs/media/json/serializer/module.f.mjs` — currently has no separate recursive
generic value aliases and therefore is not part of this migration.
4 changes: 3 additions & 1 deletion fjs/mcp/proof.f.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Unknown } from '../media/json/types.ts'

import { assert, assertEq } from '../asserts/module.f.mjs'
import { pure, step } from '../effects/module.f.mjs'
import type { Effect, Operation } from '../effects/types.ts'
import { create } from '../effects/memory/module.f.mjs'
import { parse as parseJson, type Unknown } from '../media/json/module.f.ts'
import { parse as parseJson } from '../media/json/module.f.ts'
import { number as rttiNumber, option, string as rttiString } from '../types/rtti/module.f.mjs'
import { parse as rttiParse } from '../types/rtti/parse/module.f.mjs'
import type { Response } from '../protocol/json_rpc/module.f.ts'
Expand Down
3 changes: 2 additions & 1 deletion fjs/media/html/todo/665-json-html.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,6 @@ Which serialises to:

### Related

- `fjs/media/json/module.f.ts` — the `Unknown` type and `serialize`
- `fjs/media/json/types.ts` — the `Unknown` type
- `fjs/media/json/module.f.ts` — `serialize`
- `fjs/media/json/schema/module.f.ts` — sibling JSON-dialect module
51 changes: 6 additions & 45 deletions fjs/media/json/module.f.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
/**
* JSON value types, rtti schemas, and utilities: `serialize`, `stringify`,
* `parse`, and `setProperty` for immutable nested updates.
* JSON utilities: `serialize`, `stringify`, `parse`, and `setProperty` for
* immutable nested updates.
*
* `parse` is the total, `Result`-returning `text → Unknown` entry point built
* on this module's own tokenizer and parser.
*
* The JSON value types (`Unknown`, `Primitive`) are derived from the rtti
* schemas defined here, so the schema is the single source of truth — no
* hand-written types to keep in sync.
* The JSON value types (`Unknown`, `Primitive`, `Object`, `Array`) live in
* [`./types.ts`](./types.ts), and the rtti schemas they are pinned against in
* [`./rtti/module.f.mjs`](./rtti/module.f.mjs).
*
* @module
*/
Expand All @@ -22,46 +22,7 @@ import { at, definedEntries } from '../../types/object/module.f.mjs'
import type { Entry as ObjectEntry } from '../../types/object/types.ts'
import { compose, fn } from '../../types/function/module.f.mjs'
import { objectWrap, arrayWrap, stringSerialize, numberSerialize, nullSerialize, boolSerialize } from './serializer/module.f.mjs'
import { boolean as rttiBoolean, number as rttiNumber, string as rttiString, or, record, array as rttiArray } from '../../types/rtti/module.f.mjs'
import type { Ts } from '../../types/rtti/ts/types.ts'
import type { Assert } from '../../asserts/types.ts'
import type { Equal } from '../../types/ts/types.ts'

// ── rtti schemas ──────────────────────────────────────────────────────────────

/** rtti schema matching any JSON primitive: `null`, `boolean`, `number`, or `string`. */
export const primitive = or(null, rttiBoolean, rttiNumber, rttiString)

/**
* rtti schema matching any JSON value: a primitive, an array of JSON values,
* or an object whose values are JSON values. Self-referential via a thunk;
* rtti instantiates array/record item validators lazily so recursion terminates
* on acyclic input.
*
* A struct field typed `unknown` is **required when present** — unlike rtti
* core's `unknown`, the JSON `unknown` excludes `undefined`.
*/
export const unknown = () => ['or', primitive, object, array] as const

/**
* rtti schema matching a JSON object: `{ readonly [k: string]?: Unknown }`.
*/
export const object = record(unknown)

/** rtti schema matching a JSON array: `readonly Unknown[]`. */
export const array = rttiArray(unknown)

// ── TypeScript types (derived from schemas — single source of truth) ──────────

export type Primitive = Ts<typeof primitive>

export type Unknown = Object | Array | Primitive

export type Object = { readonly[k in string]?: Unknown }

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


// ── JSON utilities ────────────────────────────────────────────────────────────

Expand Down
4 changes: 3 additions & 1 deletion fjs/media/json/parser/module.f.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
*
* @module
*/

import type { Unknown } from '../types.ts'

import type { Result } from '../../../types/result/types.ts'
import { error, ok } from '../../../types/result/module.f.mjs'
import type { List } from '../../../types/list/types.ts'
Expand All @@ -11,7 +14,6 @@ import type { Fold } from '../../../types/function/operator/types.ts'
import type { JsonToken } from '../tokenizer/types.ts'
import { setReplace } from '../../../types/ordered_map/module.f.mjs'
import type { OrderedMap } from '../../../types/ordered_map/types.ts'
import { type Unknown } from '../module.f.ts'
import { fromMap } from '../../../types/object/module.f.mjs'
import { assertEq } from '../../../asserts/module.f.mjs'

Expand Down
51 changes: 51 additions & 0 deletions fjs/media/json/rtti/module.f.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* rtti schemas describing the JSON data model: `primitive`, `unknown`,
* `object`, and `array`.
*
* The three composite schemas are mutually recursive — `unknown` names
* `object` and `array`, both of which are built from `unknown` — so `unknown`
* carries an explicit `@type` that cross-references its neighbours through
* `typeof`. That spelling is deliberate: `@type {const}` also type-checks here,
* but leaves declaration emit no name for the recursive positions, so it
* inlines the structure, gives up at depth, and degrades the emitted `.d.mts`
* to `any`. See AGENTS.md §6.2.
*
* The TypeScript counterparts live in the sibling
* [`../types.ts`](../types.ts), which pins them against these schemas with
* `Assert<Equal<Unknown, Ts<typeof unknown>>>`.
*
* @module
*/

import {
boolean as rttiBoolean,
number as rttiNumber,
string as rttiString,
or,
record,
array as rttiArray
} from '../../../types/rtti/module.f.mjs'

/** rtti schema matching any JSON primitive: `null`, `boolean`, `number`, or `string`. */
export const primitive = or(null, rttiBoolean, rttiNumber, rttiString)

/**
* rtti schema matching any JSON value: a primitive, an array of JSON values,
* or an object whose values are JSON values. Self-referential via a thunk;
* rtti instantiates array/record item validators lazily so recursion terminates
* on acyclic input.
*
* A struct field typed `unknown` is **required when present** — unlike rtti
* core's `unknown`, the JSON `unknown` excludes `undefined`.
*
* @type {() => readonly['or', typeof primitive, typeof object, typeof array]}
*/
export const unknown = () => ['or', primitive, object, array]

/**
* rtti schema matching a JSON object: `{ readonly [k: string]?: Unknown }`.
*/
export const object = record(unknown)

/** rtti schema matching a JSON array: `readonly Unknown[]`. */
export const array = rttiArray(unknown)
86 changes: 86 additions & 0 deletions fjs/media/json/rtti/proof.f.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Proof for the JSON rtti schemas.
*
* @module
*/

import { assertEq } from '../../../asserts/module.f.mjs'
import { validate } from '../../../types/rtti/validate/module.f.mjs'
import { primitive, unknown, object, array } from './module.f.mjs'

/** @import { ValidateE } from '../../../types/rtti/common/types.ts' */
/** @import { Unknown } from '../../../types/rtti/ts/types.ts' */

// Reduces a validation to its `ok`/`error` tag: these schemas are about what is
// accepted, not about the payload, which validation returns unchanged. The
// erased `ValidateE` keeps the shared helper from re-instantiating each
// schema's deep recursive result type (TS2589).
/** @type {(v: ValidateE) => (value: Unknown) => string} */
const tag = v => value => v(value)[0]

const primitiveAccepts = tag(validate(primitive))
const unknownAccepts = tag(validate(unknown))
const objectAccepts = tag(validate(object))
const arrayAccepts = tag(validate(array))

export const proof = {
primitive: {
accepts: () => {
assertEq(primitiveAccepts(null), 'ok')
assertEq(primitiveAccepts(true), 'ok')
assertEq(primitiveAccepts(0), 'ok')
assertEq(primitiveAccepts(''), 'ok')
},
rejectsComposite: () => {
assertEq(primitiveAccepts([]), 'error')
assertEq(primitiveAccepts({}), 'error')
},
},
object: {
accepts: () => {
assertEq(objectAccepts({}), 'ok')
assertEq(objectAccepts({ a: 1, b: 'two', c: null }), 'ok')
},
// A record schema descends into its values, so a bad leaf anywhere in
// the tree fails the whole object.
nested: () => {
assertEq(objectAccepts({ a: { b: [1, { c: null }] } }), 'ok')
},
rejectsNonObject: () => {
assertEq(objectAccepts(1), 'error')
assertEq(objectAccepts([]), 'error')
},
},
array: {
accepts: () => {
assertEq(arrayAccepts([]), 'ok')
assertEq(arrayAccepts([null, true, 2, 'three']), 'ok')
},
nested: () => {
assertEq(arrayAccepts([[{ a: [] }]]), 'ok')
},
rejectsNonArray: () => {
assertEq(arrayAccepts(1), 'error')
assertEq(arrayAccepts({}), 'error')
},
},
unknown: {
// `unknown` is the union of the three above, so it accepts every arm.
acceptsEveryArm: () => {
assertEq(unknownAccepts(null), 'ok')
assertEq(unknownAccepts(false), 'ok')
assertEq(unknownAccepts(1), 'ok')
assertEq(unknownAccepts('s'), 'ok')
assertEq(unknownAccepts([]), 'ok')
assertEq(unknownAccepts({}), 'ok')
},
// The thunk closes the recursion: an arbitrarily deep acyclic value
// terminates because array/record item validators instantiate lazily.
recurses: () => {
assertEq(unknownAccepts({ a: [{ b: [[{ c: 'd' }]] }] }), 'ok')
},
rejectsUndefined: () => {
assertEq(unknownAccepts(undefined), 'error')
},
},
}
2 changes: 1 addition & 1 deletion fjs/media/json/schema/module.f.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { Visitor } from '../../../types/rtti/common/types.ts'
import type { Primitive } from '../../../djs/module.f.ts'
import type { Ts } from '../../../types/rtti/ts/types.ts'
import type { Phantom } from '../../../types/phantom/types.ts'
import { unknown as jsonUnknown } from '../module.f.ts'
import { unknown as jsonUnknown } from '../rtti/module.f.mjs'

const unknownThunk = () => ['const', unknownConst] as const

Expand Down
4 changes: 3 additions & 1 deletion fjs/media/json/schema/proof.f.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type Unknown as JsonValue } from '../types.ts'

import { boolean, number, string, bigint, unknown, array, record, or, option } from '../../../types/rtti/module.f.mjs'
import { stringify, type Unknown as JsonValue } from '../module.f.ts'
import { stringify } from '../module.f.ts'
import { toJsonSchema, type Unknown, unknown as schemaUnknown } from './module.f.ts'
import { assert, assertEq } from '../../../asserts/module.f.mjs'

Expand Down
5 changes: 3 additions & 2 deletions fjs/media/json/todo/standard-parse-serialize.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,6 @@ This keeps parser/serializer policy separate from generic runtime conversion.
- [RTTI-aware extended JSON parser](./rtti-parse.md) — another materializer over
the same lossless number-token tree.
- [Remove native JSON](./remove-native-json.md) — self-hosts serialization.
- [`fjs/media/json/module.f.ts`](../module.f.ts) — current ordinary JSON value
types and `parse` / `stringify` surface.
- [`fjs/media/json/module.f.ts`](../module.f.ts) — current ordinary JSON
`parse` / `stringify` surface.
- [`fjs/media/json/types.ts`](../types.ts) — current ordinary JSON value types.
Loading
Loading