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
7 changes: 7 additions & 0 deletions changelog/unreleased/1712.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **BREAKING CHANGES:** `types/rtti`: `parse` and `validate` read a tuple schema
by length, so a hole in one is a declared position whose schema is
`undefined` — `validate([, number])([9, 5])` is now an error, agreeing with
the data form. Dense schemas are unaffected
- **BREAKING CHANGES:** `types/rtti`: a tuple schema's non-index enumerable own
property is no longer a declared member — a tuple is read by index, so such a
key was matched against `value[NaN]`. The data form always ignored it
38 changes: 38 additions & 0 deletions fjs/types/rtti/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,44 @@ undeclared keys are unconstrained, a tuple's `rest` is `unknown` — so
[Closed containers](#closed-containers) below. Closedness is stated, never
inferred.

#### A hole is a declared position

A `Tuple` schema is read by **length**, so a sparse one declares as many
positions as it is long and a hole is a position whose schema is `undefined`:
Comment thread
sergey-shandar marked this conversation as resolved.

| schema | value | all three readers |
| --- | --- | --- |
| `new Array(1)` | `[undefined]` | ok |
| `new Array(1)` | `[1, 2, 3]` | error |
| `[, number]` | `[undefined, 5]` | ok |
| `[, number]` | `[9, 5]` | error |

Reading index `0` of `new Array(1)` yields `undefined`, and `undefined` is a
`Const` schema in its own right, so this is what follows from `Tuple` being
`readonly Type[]`. `Object.entries` — which skips holes — was the schema-form
readers' entry list until it disagreed with the data form's `for…of` on exactly
these rows; `tupleSchemaEntries` in `common/module.f.mjs` is now the one place
that says how a tuple schema is read, and `structSchemaEntries` is its struct
counterpart. The alternative reading would make `new Array(1)` and `[]` the
same schema while `[undefined]` stayed different from both.

The same rule settles a tuple schema's **non-index** enumerable own properties,
which are no positions either. `Object.assign([number], { foo: string })`
declares one position and nothing named `foo`: a tuple is read by index, so the
entry reading declared `foo` and then matched it against `value[NaN]` — the
property literally named `NaN`, which no ordinary value carries. The data form
ignored it all along; now so do the schema-form readers.

"By length" is how every schema anyone can write is read; the mechanism is the
iterator, the same one `containerUnion` walks, so the two agree by construction
rather than by two rules that happen to coincide. That matters only for a schema
carrying an overridden `Symbol.iterator` — which FunctionalScript cannot build,
having neither symbols nor mutation — where reading indices here would put the
schema-form readers back at odds with the data form.

Nothing about a dense schema changes: on an array with neither holes nor extra
own properties the two entry lists are identical.

#### This is deliberate; please do not "fix" it

The tempting mistake is to read `Ts<T>` and conclude tuples must be exact:
Expand Down
47 changes: 45 additions & 2 deletions fjs/types/rtti/common/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* - `eachEntry`: the container entry loop (array/record/tuple/struct). Callers
* choose what (if anything) to accumulate, so a caller that only needs
* pass/fail pays no allocation per entry.
* - `tupleSchemaEntries`/`structSchemaEntries`: what a container schema
* declares, per kind — the entry list its readers walk.
* - `undeclaredEntries`: the other half of a closed container's loop — the
* entries a `Tuple`/`Struct` schema does not name.
* - `orVisit`: the shared `or` handler — try each variant's recursive walker,
Expand All @@ -29,10 +31,10 @@
* @module
*
* @import { Primitive, Unknown } from '../ts/types.ts'
* @import { Const, Info0, Primitive0, Tag1, Tuple, Type } from '../types.ts'
* @import { Const, Info0, Primitive0, Struct, Tag1, Tuple, Type } from '../types.ts'
* @import { Error, Result as CommonResult } from '../../result/types.ts'
* @import { StringMap } from '../../object/types.ts'
* @import { Validate, Visitor, IsContainer, Container, ResultE, ValidateE, ValidationError } from './types.ts'
* @import { Validate, Visitor, IsContainer, Container, ResultE, SchemaEntries, ValidateE, ValidationError } from './types.ts'
*/

import { assert } from '../../../asserts/module.f.mjs'
Expand Down Expand Up @@ -130,6 +132,47 @@ export const eachEntry =
return ok(acc)
}

/**
* What a `Tuple` schema declares, read by **length**.
*
* `Array.from` yields `undefined` for a hole and preserves the schema's
* length, so a hole is a declared position whose schema is `undefined` — which
* is a `Const` schema in its own right, and exactly what reading index `0` of
* `new Array(1)` gives. That is the reading `../data/module.f.mjs`'s
* `containerUnion` has always had, so the canonical data form stays fixed.
*
* `Array.from` walks the iterator, which is the *same* walk `containerUnion`
* makes, and that is the point rather than an accident: the two agree by
* construction. It holds even for a schema carrying an overridden
* `Symbol.iterator`, where reading indices here would disagree with the data
* form all over again — verified: such a schema is read as `number` by the
* entry reading and as `string` by `containerUnion`. Reading *both* by index
* is defensible, but it changes the canonical, content-addressed data form and
* belongs with that decision, not here. FunctionalScript cannot build such a
* schema in the first place: it has no symbols and no mutation, so the case is
* reachable only from plain JavaScript, which is also why no proof can pin it.
*
* `Object.entries` skips holes, which is why it is not used here: it would
* make `new Array(1)` and `[]` the same schema while `[undefined]` stayed
* different from both. It also yields a non-index own property, which is no
* position either — `getItem` reads a tuple by index, so such a key was
* declared and then matched against `value[NaN]`. `Array.from` answers
* positions only. On a plain dense array the two agree exactly.
*
* @type {SchemaEntries<Tuple>}
*/
export const tupleSchemaEntries = rtti =>
Array.from(rtti, (t, i) => [String(i), t])
Comment thread
sergey-shandar marked this conversation as resolved.
Comment thread
sergey-shandar marked this conversation as resolved.

/**
* What a `Struct` schema declares: its enumerable own keys. A struct has no
* holes, so there is nothing for this to disagree with.
*
* @type {SchemaEntries<Struct>}
*/
export const structSchemaEntries = rtti =>
Object.entries(rtti)

/**
* The entries of `value` that `declared` does not name.
*
Expand Down
30 changes: 29 additions & 1 deletion fjs/types/rtti/common/proof.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @import { ValidationError } from './types.ts'
*/

import { eachEntry, undeclaredEntries } from './module.f.mjs'
import { eachEntry, structSchemaEntries, tupleSchemaEntries, undeclaredEntries } from './module.f.mjs'
import { error, ok } from '../../result/module.f.mjs'
import { assert, assertEq, assertStructurallySame } from '../../../asserts/module.f.mjs'

Expand Down Expand Up @@ -72,6 +72,34 @@ export const proof = {
// length — see `fits` in `../parse/module.f.mjs`.
holeIsNotAnEntry: () => assertEq(undeclaredEntries(['0'], [1, , 3]).length, 1),
},
// What a container schema declares, per kind. A tuple is read by length,
// so a hole is a declared position whose schema is `undefined` — the same
// reading `../data/module.f.mjs` has, and the reason the two kinds need
// different entry readers at all.
schemaEntries: {
tuple: () => assertStructurallySame(
tupleSchemaEntries([1, 'a']),
[['0', 1], ['1', 'a']],
),
// `Object.entries` would answer `[]` here, and `[['0', undefined]]` for
// `[undefined]` — two schemas that denote the same set, read as two.
tupleHole: () => assertStructurallySame(
tupleSchemaEntries(new Array(1)),
[['0', undefined]],
),
tupleHoleIsTheDenseReading: () => assertStructurallySame(
tupleSchemaEntries(new Array(1)),
tupleSchemaEntries([undefined]),
),
struct: () => assertStructurallySame(
structSchemaEntries({ a: 1, b: 'x' }),
[['a', 1], ['b', 'x']],
),
empty: () => {
assertEq(tupleSchemaEntries([]).length, 0)
assertEq(structSchemaEntries({}).length, 0)
},
},
pathPrefixed: () => {
/** @type {(k: string, v: number) => Result<number, ValidationError>} */
const nested = (k, v) =>
Expand Down
9 changes: 9 additions & 0 deletions fjs/types/rtti/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export type Visitor<R> = {
readonly unknown: () => R
}

/**
* Reads what a container schema declares, as `[key, Type]` pairs — one per
* container kind, since a `Tuple` is read by length and a `Struct` by
* enumerable key. See `tupleSchemaEntries` in `./module.f.mjs` for why the two
* readings are not interchangeable on a sparse array.
*/
export type SchemaEntries<S extends ConstObject> =
(rtti: S) => ReadonlyArray<readonly [string, Type]>

/** Type guard narrowing `Unknown` to a specific container type `C`. */
export type IsContainer<C extends Unknown> = (value: Unknown) => value is C

Expand Down
42 changes: 29 additions & 13 deletions fjs/types/rtti/parse/module.f.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
* member reads as `undefined`, on both kinds — so a shorter array whose
* trailing position admits `undefined` is accepted and the gap is filled.
*
* A tuple schema declares by length, so a hole in one is a declared position
* whose schema is `undefined` — see "A hole is a declared position" in
* `../README.md`.
*
* Openness is what makes this forward-compatible with extended serialization
* formats: a schema-based parser keeps working when newer versions of the
* format add extra fields or tuple elements.
Expand All @@ -45,11 +49,11 @@
*
* @module
*
* @import { ConstObject, Info1, Struct, Tag1, Tuple, Type } from '../types.ts'
* @import { ConstObject, Info1, Tag1, Type } from '../types.ts'
* @import { Result as CommonResult } from '../../result/types.ts'
* @import { StringMap } from '../../object/types.ts'
* @import { List } from '../../list/types.ts'
* @import { Container, IsContainer, ValidateE, ValidationError, Visitor } from '../common/types.ts'
* @import { Container, IsContainer, SchemaEntries, ValidateE, ValidationError, Visitor } from '../common/types.ts'
* @import { Unknown } from '../ts/types.ts'
* @import { Parse } from './types.ts'
*/
Expand All @@ -63,6 +67,8 @@ import {
isObject,
orVisit,
primitive0Validate,
structSchemaEntries,
tupleSchemaEntries,
undeclaredEntries,
verror,
visit,
Expand Down Expand Up @@ -127,26 +133,28 @@ const arrayParse = containerParse(isArray, arrayRebuild)
const recordParse = containerParse(isObject, recordRebuild)

/**
* Builds a parser for `Tuple` or `Struct` const schemas. It iterates the
* *schema's* entries, which is what makes both kinds open: a longer array or
* an undeclared key is never visited, so it is accepted and left out of the
* rebuilt result.
* Builds a parser for `Tuple` or `Struct` const schemas. It iterates what the
* *schema* declares — `schemaEntries`, per kind — which is what makes both
* kinds open: a longer array or an undeclared key is never visited, so it is
* accepted and left out of the rebuilt result.
*/
const constContainerParse =
/**
* @template {Unknown} C
* @template {ConstObject} S
* @param {IsContainer<C>} isContainer
* @param {SchemaEntries<S>} schemaEntries
* @param {(value: C, k: string) => Unknown} getItem
* @param {_Rebuild} rebuild
* @returns {<T extends Tuple | Struct>(rtti: T) => Parse<T>}
* @returns {<T extends S>(rtti: T) => Parse<T>}
*/
(isContainer, getItem, rebuild) =>
(isContainer, schemaEntries, getItem, rebuild) =>
rtti => value => {
if (!isContainer(value)) {
return verror('unexpected value')
}
const r = eachEntry(
entries(rtti),
schemaEntries(rtti),
(k, t) => (/** @type {any} */ (parse(t))(getItem(value, k))),
emptyEntries,
consEntry,
Expand All @@ -156,12 +164,14 @@ const constContainerParse =

const tupleParse = constContainerParse(
isArray,
tupleSchemaEntries,
(value, k) => value[Number(k)],
arrayRebuild,
)

const structParse = constContainerParse(
isObject,
structSchemaEntries,
(value, k) => value[k],
recordRebuild,
)
Expand All @@ -182,16 +192,18 @@ const noAccumulate = () => undefined
const closeContainerParse =
/**
* @template {ReadonlyArray<Unknown> | StringMap<Unknown>} C
* @template {ConstObject} S
* @param {IsContainer<C>} isContainer
* @param {SchemaEntries<S>} schemaEntries
* @param {(value: C, k: string) => Unknown} getItem
* @param {_Rebuild} rebuild
* @param {(value: C, declared: number) => boolean} fits
* @returns {(rtti: ConstObject, rest: Type | undefined) => ValidateE}
* @returns {(rtti: S, rest: Type | undefined) => ValidateE}
*/
(isContainer, getItem, rebuild, fits) =>
(isContainer, schemaEntries, getItem, rebuild, fits) =>
(rtti, rest) => {
// Depend on the schema alone, so they are computed once per schema.
const rttiEntries = entries(rtti)
const rttiEntries = schemaEntries(rtti)
const declared = rttiEntries.map(([k]) => k)
return value => {
if (!isContainer(value)) {
Expand All @@ -218,21 +230,25 @@ const closeContainerParse =

const closeTupleParse = closeContainerParse(
isArray,
tupleSchemaEntries,
(value, k) => value[Number(k)],
arrayRebuild,
(value, declared) => value.length <= declared,
)

const closeStructParse = closeContainerParse(
isObject,
structSchemaEntries,
(value, k) => value[k],
recordRebuild,
() => true,
)

/** @type {(rtti: ConstObject, rest: Type | undefined) => ValidateE} */
const closeParse = (rtti, rest) =>
(rtti instanceof Array ? closeTupleParse : closeStructParse)(rtti, rest)
rtti instanceof Array
? closeTupleParse(rtti, rest)
: closeStructParse(rtti, rest)

const orParse =
/**
Expand Down
Loading
Loading