From faccd05ca2b52a51fc71dfcd5da1103864ac7494 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 22 May 2026 11:16:32 -0400 Subject: [PATCH 01/27] Refactor types to prototype-chain inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types are now PropType instances rather than method-pack registrations. Built-ins are registered singletons; derivatives are `Object.create(parent)`, so option lookup walks the JS prototype chain — no spec merging or copying. - `PropType.for(input, { fallback })` is the public resolver; returns the registered singleton, a fresh derivative for specs with extras, or the fallback. `PropType.normalizeIs` is the single source of string→ctor resolution. - `PropType.prototype.equals/parse/stringify` are dispatchers that handle null/identity short-circuits and walk the instance chain for an override on `obj.spec`. The passed spec object is stored verbatim as `instance.spec` — no allocation per construction. - `ListType` / `DictionaryType` are abstract bases under `util/`, with `static nestedSpecKeys` declaring which option keys hold nested type specs (only those are resolved + stored as own properties on the instance; everything else stays on `this.spec`). - New `/plugins/types` endpoint exposes built-in singletons by name. - Drop the auto-spacing heuristic in `joinItems`; custom separators are now used verbatim. Default separator is `", "` so the common case is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 +- src/plugins/props/README.md | 53 +++ src/plugins/props/index.js | 1 + src/plugins/props/types/basic.js | 81 ++-- src/plugins/props/types/dictionaries.js | 168 +++----- src/plugins/props/types/index.js | 11 +- src/plugins/props/types/lists.js | 91 ++--- src/plugins/props/util/DictionaryType.js | 98 +++++ src/plugins/props/util/ListType.js | 68 ++++ src/plugins/props/util/Prop.js | 10 +- src/plugins/props/util/PropType.js | 230 +++++++++++ src/plugins/props/util/types.js | 101 ----- src/plugins/types.js | 9 + test/PropType.js | 479 +++++++++++++++++++++++ test/index.js | 3 +- 15 files changed, 1056 insertions(+), 350 deletions(-) create mode 100644 src/plugins/props/util/DictionaryType.js create mode 100644 src/plugins/props/util/ListType.js create mode 100644 src/plugins/props/util/PropType.js delete mode 100644 src/plugins/props/util/types.js create mode 100644 src/plugins/types.js create mode 100644 test/PropType.js diff --git a/package.json b/package.json index 34d3afc3..21f07da2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ ".": "./src/index.js", "./fn": "./src/index-fn.js", "./plugins": "./src/plugins/index.js", - "./plugins/fn": "./src/plugins/index-fn.js" + "./plugins/fn": "./src/plugins/index-fn.js", + "./plugins/types": "./src/plugins/types.js" }, "repository": { "type": "git", diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index d539e664..bdf77106 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -153,6 +153,59 @@ While `defaultKey` _can_ be a non-function, this is almost never what you want, If `defaultValue` is provided, singular entries are considered keys, and `defaultValue` is used to generate the values. It can be either a constant (e.g. `true`) or a function, in which case it’s passed the key and the index as arguments. +#### Custom types + +Types are *instances* of `PropType` (or one of its subclasses, `ListType` / `DictionaryType`). Each instance carries the spec it was constructed with — its constructor (`is`) and any type options — and supplies `equals` / `parse` / `stringify` either as methods on a subclass prototype, or as overrides passed via the constructor spec. A type is an abstract, prop-agnostic definition; the same instance is shared across every prop that references it. + +For most custom types, the simplest path is to register a spec object directly. Methods read options from `this.spec`: + +```js +import { PropType } from "nude-element/plugins"; + +PropType.register({ + is: Color, + parse: value => (value instanceof Color ? value : new Color(value)), + equals: (a, b) => a === b || a?.equals?.(b), + stringify: value => value?.toString(), +}); +``` + +For types that need richer shared behavior (e.g. resolving nested type specs, or reusing logic across several constructors), subclass `PropType` — or one of the built-in bases `ListType` / `DictionaryType`: + +```js +import { PropType, ListType } from "nude-element/plugins"; + +class TupleType extends ListType { + // override / extend as needed; `this.spec` holds the merged spec, + // `this.values` is the resolved nested type +} + +PropType.register(new TupleType({ is: Tuple, /* ...spec */ })); +``` + +**Derivative types.** A type spec with options beyond `is` produces a *derivative* type — a new `PropType` instance whose prototype is the registered singleton for that `is`. Lookups for unspecified options fall through to the parent via the JS prototype chain. + +```js +import { PropType } from "nude-element/plugins"; + +const NumberArray = PropType.for({ is: Array, values: Number }); + +static props = { + points: { type: NumberArray }, +}; +``` + +Inline specs in prop definitions work the same way — each occurrence produces its own derivative. Hoist a derivative into a `const` (as above) if you want every prop using it to share the same instance. + +The built-in type instances are also available by their native names from a separate endpoint: + +```js +import * as Types from "nude-element/plugins/types"; + +Types.Array; // the ArrayType singleton +Types.Map; // the MapType singleton +``` + ### Attribute-property reflection The `reflect` property takes the following values: diff --git a/src/plugins/props/index.js b/src/plugins/props/index.js index 47a193c7..b5667735 100644 --- a/src/plugins/props/index.js +++ b/src/plugins/props/index.js @@ -5,6 +5,7 @@ import { defineOwnProperty, getSuperMethod } from "xtensible/util"; import { defineLazyProperty } from "../../util/lazy.js"; export const { props } = symbols.known; +export * from "./types/index.js"; const hooks = { setup () { diff --git a/src/plugins/props/types/basic.js b/src/plugins/props/types/basic.js index f9c937c6..c43589c0 100644 --- a/src/plugins/props/types/basic.js +++ b/src/plugins/props/types/basic.js @@ -1,67 +1,36 @@ -import { resolve } from "../util/types.js"; +import PropType from "../util/PropType.js"; -const callableBuiltins = new Set([ - String, - Number, - Boolean, - Array, - Object, - Function, - Symbol, - BigInt, -]); - -export const generic = { - equals (a, b) { - let simpleEquals = a === b; - if (simpleEquals || a === null || a === undefined || b === null || b === undefined) { - return simpleEquals; - } - - if (typeof a.equals === "function") { - return a.equals(b); - } - - // Roundtrip - return simpleEquals; - }, - parse (value, type) { - let { is: Type, ...typeOptions } = resolve(type); - if (!Type || value instanceof Type) { - return value; - } - - return callableBuiltins.has(Type) ? Type(value) : new Type(value); +PropType.register({ + is: Boolean, + parse (value) { + return value !== null; }, - stringify (value) { - return String(value); + return value ? "" : null; }, -}; - -export const boolean = { - type: Boolean, - parse: value => value !== null, - stringify: value => (value ? "" : null), -}; +}); -export const number = { - type: Number, - equals: (a, b) => a === b || (Number.isNaN(a) && Number.isNaN(b)), -}; +PropType.register({ + is: Number, + equals (a, b) { + return Number.isNaN(a) && Number.isNaN(b); + }, +}); -export const fn = { - type: Function, - equals: (a, b) => a === b || a.toString() === b.toString(), - parse (value, options = {}) { +PropType.register({ + is: Function, + equals (a, b) { + return a.toString() === b.toString(); + }, + parse (value) { if (typeof value === "function") { return value; } - value = String(value); - - return Function(...(options.arguments ?? []), value); + return Function(...(this.spec.arguments ?? []), String(value)); + }, + stringify () { + // Stringification is explicitly forbidden for functions. + throw new TypeError("Cannot stringify Function"); }, - // Just don’t do that - stringify: false, -}; +}); diff --git a/src/plugins/props/types/dictionaries.js b/src/plugins/props/types/dictionaries.js index 9fa16832..b5a5ffe3 100644 --- a/src/plugins/props/types/dictionaries.js +++ b/src/plugins/props/types/dictionaries.js @@ -1,63 +1,8 @@ -import { resolveValue, split } from "./util.js"; -import { parse, stringify, equals } from "../util/types.js"; +import DictionaryType from "../util/DictionaryType.js"; -function parseEntries ( - value, - { values, keys, separator = ", ", defaultValue = true, defaultKey, pairs } = {}, -) { - let entries = value; - - if (typeof value === "string") { - entries = split(value, { separator, pairs }); - - entries = entries.map((entry, index) => { - let parts = entry.split(/(?= 2) { - // Value contains colons - key = parts.shift(); - value = parts.join(":"); - } - else if (parts.length === 1) { - if (defaultKey) { - value = parts[0]; - key = resolveValue(defaultKey, [null, value, index]); - } - else { - key = parts[0]; - value = resolveValue(defaultValue, [null, key, index]); - } - } - - [key, value] = [key, value].map(v => v?.trim?.() ?? v); - - if (value === "false") { - value = false; - } - - return [key, value]; - }); - } - - entries = entries.map(([key, value]) => { - if (keys) { - key = parse(key, keys); - } - - if (values) { - value = parse(value, values); - } - - return [key, value]; - }); - - return entries; -} - -export const object = { - type: Object, - equals (a, b, { values } = {}) { +export const ObjectType = DictionaryType.register({ + is: Object, + equals (a, b) { let aKeys = Object.keys(a); let bKeys = Object.keys(b); @@ -65,81 +10,67 @@ export const object = { return false; } - return aKeys.every(key => equals(a[key], b[key], values)); + let { values } = this; + return aKeys.every(key => (values ? values.equals(a[key], b[key]) : a[key] === b[key])); }, - - /** - * Parses a simple microsyntax for declaring key-value options: - * If no value is provided, it becomes `true`. The string "false" is parsed as `false`. - * Escapes for separators are supported, via backslash. - * @param {string} value - * @param {Object} [options] - * @param {Function} [options.values] The type to parse the values as - * @param {string} [options.separator=","] The separator between entries. - */ - parse (value, options = {}) { - let entries; + parse (value) { if (value instanceof Map) { value = value.entries(); } else if (typeof value === "object") { - if (options.values) { + let { values } = this; + if (values) { for (let key in value) { - value[key] = parse(value[key], options.values); + value[key] = values.parse(value[key]); } } return value; } - entries = parseEntries(value, options); - return Object.fromEntries(entries); + return Object.fromEntries(this.parseEntries(value)); }, - - stringify (value, { values, separator = ", " } = {}) { + stringify (value) { + let { values } = this; + let { separator = ", " } = this.spec; let entries = Object.entries(value); if (values) { - entries = entries.map(([key, value]) => [key, stringify(value, values)]); + entries = entries.map(([key, val]) => [key, values.stringify(val)]); } - return entries.map(([key, value]) => `${key}: ${value}`).join(separator); + return entries.map(([key, val]) => `${key}: ${val}`).join(separator); }, -}; +}); -export const map = { - type: Map, - equals (a, b, { values } = {}) { - let aKeys = a.keys(); - let bKeys = b.keys(); - - if (aKeys.length !== bKeys.length) { +export const MapType = DictionaryType.register({ + is: Map, + equals (a, b) { + if (a.size !== b.size) { return false; } - return aKeys.every(key => equals(a.get(key), b.get(key), values)); - }, + let { values } = this; + for (let [key, val] of a) { + if (!b.has(key)) { + return false; + } - /** - * Parses a simple microsyntax for declaring key-value options: - * If no value is provided, it becomes `true`. The string "false" is parsed as `false`. - * Escapes for separators are supported, via backslash. - * @param {string} value - * @param {Object} [options] - * @param {Function} [options.keys] The type to parse the keys as - * @param {Function} [options.values] The type to parse the values as - * @param {string} [options.separator=","] The separator between entries. - */ - parse (value, options) { - let entries; + let bVal = b.get(key); + if (values ? !values.equals(val, bVal) : val !== bVal) { + return false; + } + } + + return true; + }, + parse (value) { if (value instanceof Map) { - if (options) { - let { keys, values } = options; - if (keys || values) { - for (let [key, value] of value) { - value.delete(key); - value.set(parse(key, keys), parse(value, values)); - } + let { keys, values } = this; + if (keys || values) { + for (let [key, val] of value) { + value.delete(key); + value.set(keys?.parse(key) ?? key, values?.parse(val) ?? val); } } @@ -149,20 +80,21 @@ export const map = { value = Object.entries(value); } - entries = parseEntries(value, options); + let entries = this.parseEntries(value); return Array.isArray(entries) ? new Map(entries) : entries; }, - - stringify (value, { keys, values, separator = ", " } = {}) { - let entries = value.entries(); + stringify (value) { + let { keys, values } = this; + let { separator = ", " } = this.spec; + let entries = [...value.entries()]; if (keys || values) { - entries = entries.map(([key, value]) => [ - stringify(key, keys), - stringify(value, values), + entries = entries.map(([key, val]) => [ + keys ? keys.stringify(key) : key, + values ? values.stringify(val) : val, ]); } - return entries.map(([key, value]) => `${key}: ${value}`).join(separator); + return entries.map(([key, val]) => `${key}: ${val}`).join(separator); }, -}; +}); diff --git a/src/plugins/props/types/index.js b/src/plugins/props/types/index.js index e38ace51..2451d899 100644 --- a/src/plugins/props/types/index.js +++ b/src/plugins/props/types/index.js @@ -1,3 +1,8 @@ -export * from "./basic.js"; -export * from "./lists.js"; -export * from "./dictionaries.js"; +// Side-effect imports register the built-in types' singletons. +import "./basic.js"; +import "./lists.js"; +import "./dictionaries.js"; + +export { default as PropType } from "../util/PropType.js"; +export { default as ListType } from "../util/ListType.js"; +export { default as DictionaryType } from "../util/DictionaryType.js"; diff --git a/src/plugins/props/types/lists.js b/src/plugins/props/types/lists.js index b32c4e1d..6d6ad70a 100644 --- a/src/plugins/props/types/lists.js +++ b/src/plugins/props/types/lists.js @@ -1,48 +1,26 @@ -import { parse, stringify, equals } from "../util/types.js"; -import { split } from "./util.js"; +import ListType from "../util/ListType.js"; -function parseList (value, { values, ...options } = {}) { - if (typeof value === "string") { - value = split(value, options); - } - else { - value = Array.isArray(value) ? value : [value]; - } - - if (values) { - value = value.map(item => parse(item, values)); - } - - return value; -} - -export const array = { - type: Array, - equals (a, b, { values } = {}) { +export const ArrayType = ListType.register({ + is: Array, + equals (a, b) { if (a.length !== b.length) { return false; } - return a.every((item, i) => equals(item, b[i], values)); + let { values } = this; + return a.every((item, i) => (values ? values.equals(item, b[i]) : item === b[i])); }, - parse: parseList, - stringify: (value, { values, separator = ",", joiner } = {}) => { - if (values) { - value = value.map(item => stringify(item, values)); - } - - if (!joiner) { - let trimmedSeparator = separator.trim(); - joiner = (!trimmedSeparator || trimmedSeparator === "," ? "" : " ") + separator + " "; - } - - return value.join(joiner); + parse (value) { + return this.parseItems(value); }, -}; + stringify (value) { + return this.joinItems(value); + }, +}); -export const set = { - type: Set, - equals (a, b, { values } = {}) { +export const SetType = ListType.register({ + is: Set, + equals (a, b) { if (a.size !== b.size) { return false; } @@ -55,19 +33,16 @@ export const set = { return true; }, - parse (value, options) { + parse (value) { if (value instanceof Set) { - if (options) { - let { values } = options; - - if (values) { - // Parse values in place - for (let item of value) { - let parsed = parse(item, values); - if (parsed !== item) { - value.delete(item); - value.add(parsed); - } + let { values } = this; + if (values) { + // Parse values in place + for (let item of value) { + let parsed = values.parse(item); + if (parsed !== item) { + value.delete(item); + value.add(parsed); } } } @@ -75,19 +50,9 @@ export const set = { return value; } - let items = parseList(value, options); - return new Set(items); + return new Set(this.parseItems(value)); }, - stringify: (value, { values, separator = ",", joiner } = {}) => { - if (values) { - value = value.map(item => stringify(item, values)); - } - - if (!joiner) { - let trimmedSeparator = separator.trim(); - joiner = (!trimmedSeparator || trimmedSeparator === "," ? "" : " ") + separator + " "; - } - - return value.join(joiner); + stringify (value) { + return this.joinItems([...value]); }, -}; +}); diff --git a/src/plugins/props/util/DictionaryType.js b/src/plugins/props/util/DictionaryType.js new file mode 100644 index 00000000..fe9e61ca --- /dev/null +++ b/src/plugins/props/util/DictionaryType.js @@ -0,0 +1,98 @@ +import PropType from "./PropType.js"; +import { resolveValue, split } from "../types/util.js"; + +/** + * Shared behavior for dictionary-like types (Object, Map): entry parsing for + * the shared microsyntax (`key: value, key: value`), delegating to + * `this.keys` / `this.values` (the resolved nested types) and reading + * formatting options (`separator`, `defaultKey`, `defaultValue`, `pairs`) + * off `this`. + * + * @extends {PropType} + */ +export default class DictionaryType extends PropType { + /** + * Parse a string (or pre-built entries array) into `[key, value]` tuples, + * applying `this.keys` / `this.values` parsing if configured. + * + * Microsyntax: entries split by `this.separator` (default `","`); + * within each entry, `:` splits key from value. Lone tokens use + * `this.defaultKey` / `this.defaultValue` (the latter defaults to `true`). + * The literal string `"false"` becomes `false`. Backslash escapes + * are honored for both separators. + * + * @param {string | [unknown, unknown][]} value + * @returns {[unknown, unknown][]} + */ + parseEntries (value) { + let { separator = ", ", defaultValue = true, defaultKey, pairs } = this.spec; + let entries = value; + + if (typeof value === "string") { + entries = split(value, { separator, pairs }); + + entries = entries.map((entry, index) => { + let parts = entry.split(/(?= 2) { + // Value contains colons + key = parts.shift(); + val = parts.join(":"); + } + else if (parts.length === 1) { + if (defaultKey) { + val = parts[0]; + key = resolveValue(defaultKey, [null, val, index]); + } + else { + key = parts[0]; + val = resolveValue(defaultValue, [null, key, index]); + } + } + + [key, val] = [key, val].map(v => v?.trim?.() ?? v); + + if (val === "false") { + val = false; + } + + return [key, val]; + }); + } + + let { keys, values } = this; + entries = entries.map(([key, val]) => { + if (keys) { + key = keys.parse(key); + } + + if (values) { + val = values.parse(val); + } + + return [key, val]; + }); + + return entries; + } + + /** @type {string[]} */ + static nestedSpecKeys = ["keys", "values"]; +} + +/** + * @typedef {import("./PropType.js").SpecifiedType} SpecifiedType + * @typedef {import("./PropType.js").PropTypeSpec} PropTypeSpec + */ + +/** + * @typedef {PropTypeSpec & { + * keys?: SpecifiedType, + * values?: SpecifiedType, + * separator?: string, + * defaultKey?: ((key: unknown, value: unknown, index: number) => unknown) | unknown, + * defaultValue?: ((key: unknown, value: unknown, index: number) => unknown) | unknown, + * pairs?: object, + * }} DictionaryTypeSpec + */ diff --git a/src/plugins/props/util/ListType.js b/src/plugins/props/util/ListType.js new file mode 100644 index 00000000..01291ba3 --- /dev/null +++ b/src/plugins/props/util/ListType.js @@ -0,0 +1,68 @@ +import PropType from "./PropType.js"; +import { split } from "../types/util.js"; + +/** + * Shared behavior for list-like types (Array, Set): provides splitting and + * joining helpers that delegate to `this.values` (the resolved nested type) + * and read formatting options (`separator`, `joiner`, `pairs`) off `this`. + * + * @extends {PropType} + */ +export default class ListType extends PropType { + /** + * Split a raw input into items, parsing each through {@link values}. + * @param {string | unknown[] | unknown} value + * @returns {unknown[]} + */ + parseItems (value) { + if (typeof value === "string") { + value = split(value, this.spec); + } + else { + value = Array.isArray(value) ? value : [value]; + } + + let { values } = this; + if (values) { + value = value.map(item => values.parse(item)); + } + + return value; + } + + /** + * Stringify list items via {@link values}, joined by `spec.joiner` or + * `spec.separator` if no joiner is supplied. Whitespace is *not* added + * automatically — consumers who want spaces include them in the joiner + * (or separator) themselves. + * @param {unknown[]} items + * @returns {string} + */ + joinItems (items) { + let { values } = this; + let { separator = ", ", joiner = separator } = this.spec; + + if (values) { + items = items.map(item => values.stringify(item)); + } + + return items.join(joiner); + } + + /** @type {string[]} */ + static nestedSpecKeys = ["values"]; +} + +/** + * @typedef {import("./PropType.js").SpecifiedType} SpecifiedType + * @typedef {import("./PropType.js").PropTypeSpec} PropTypeSpec + */ + +/** + * @typedef {PropTypeSpec & { + * values?: SpecifiedType, + * separator?: string, + * joiner?: string, + * pairs?: object, + * }} ListTypeSpec + */ diff --git a/src/plugins/props/util/Prop.js b/src/plugins/props/util/Prop.js index 425a8aa4..83d6db48 100644 --- a/src/plugins/props/util/Prop.js +++ b/src/plugins/props/util/Prop.js @@ -1,5 +1,5 @@ import { inferDependencies } from "../util.js"; -import * as types from "./types.js"; +import { PropType } from "../types/index.js"; /** * Class-level metadata for a single prop. @@ -57,14 +57,10 @@ let Self = class Prop { to: reflect.to === true ? name : reflect.to || undefined, }; - this.type = types.resolve(spec.type); + this.type = PropType.for(spec.type); for (let fnName of ["equals", "stringify", "parse"]) { - this[fnName] = - spec[fnName] ?? - function (...args) { - return types[fnName](...args, this.type); - }; + this[fnName] = spec[fnName] ?? this.type[fnName].bind(this.type); } } diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js new file mode 100644 index 00000000..73a61b84 --- /dev/null +++ b/src/plugins/props/util/PropType.js @@ -0,0 +1,230 @@ +const callableBuiltins = new Set([ + String, + Number, + Boolean, + Array, + Object, + Function, + Symbol, + BigInt, +]); + +/** + * Type adapter for prop values: defines equality, parsing from raw input + * (typically attribute strings), and stringification back to attributes. + * + * A `PropType` instance is an *abstract*, prop-agnostic type definition. + * Built-in types are registered as singletons keyed on their JS constructor + * in {@link PropType.registry}. {@link PropType.for} dispatches lookups and + * delegates derivative construction to the constructor. + * + * Derivatives are created via `Object.create(parent)` so every property + * lookup (options + the {@link spec} method-override slot) walks the JS + * prototype chain naturally — no merging, no copies. + * + * Type-specific `equals` / `parse` / `stringify` supplied at registration + * are stored under `instance.spec`. The prototype methods on this class + * handle the shared null/identity short-circuits and delegate to that spec + * when present, so type definitions stay free of boilerplate. + * + * @template {PropTypeSpec} [TSpec=PropTypeSpec] + */ +export default class PropType { + /** + * Slot for type-specific method overrides supplied at registration: + * `{equals?, parse?, stringify?}`. May be inherited via the instance + * prototype chain; for derivatives that introduce their own override, + * the constructor sets a new `Object.create(parent.spec)` so unspecified + * slots still fall through. + * @type {{equals?: Function, parse?: Function, stringify?: Function} | undefined} + */ + spec; + + /** @param {TSpec} [spec] */ + constructor (spec = {}) { + let { is, ...extras } = spec; + is = PropType.normalizeIs(is); + let parent = PropType.registry.get(is); + + // Pure lookup: `{is: Array}` with no extras returns the registered singleton. + if (parent && Object.keys(extras).length === 0) { + return parent; + } + + let instance = parent ? Object.create(parent) : this; + // Only set `is` on the instance when there's no parent (root path). + // On the derivative path, `is` is inherited via the prototype chain + // (parent was looked up by this `is`, so they match). + if (is !== undefined && !parent) { + instance.is = is; + } + + instance.spec = spec; + + // Resolve nested type specs and store them as own properties so + // type-specific code reads them as `this.values` / `this.keys` (the + // resolved PropType instances). Every other spec field — methods, + // separators, defaults, etc. — stays in `spec` and is read via + // `this.spec.X` by type-specific code and the dispatchers. + for (let key of instance.constructor.nestedSpecKeys ?? []) { + if (spec[key] !== undefined) { + instance[key] = PropType.for(spec[key]); + } + } + + return instance; + } + + /** + * @param {unknown} a + * @param {unknown} b + * @returns {boolean} + */ + equals (a, b) { + if (a == null || b == null) { + return a === b; + } + + if (a === b) { + return true; + } + + for (let obj = this; obj; obj = Object.getPrototypeOf(obj)) { + if (obj.spec?.equals) { + return obj.spec.equals.call(this, a, b); + } + } + + return typeof a.equals === "function" ? a.equals(b) : false; + } + + /** + * @param {unknown} value + * @returns {unknown} + */ + parse (value) { + if (value == null) { + return value; + } + + for (let obj = this; obj; obj = Object.getPrototypeOf(obj)) { + if (obj.spec?.parse) { + return obj.spec.parse.call(this, value); + } + } + + let Type = this.is; + if (!Type || value instanceof Type) { + return value; + } + + return callableBuiltins.has(Type) ? Type(value) : new Type(value); + } + + /** + * Null/undefined produce `null` (signaling attribute removal). + * @param {unknown} value + * @returns {string | null} + */ + stringify (value) { + if (value == null) { + return null; + } + + for (let obj = this; obj; obj = Object.getPrototypeOf(obj)) { + if (obj.spec?.stringify) { + return obj.spec.stringify.call(this, value); + } + } + + return String(value); + } + + /** @type {Map} */ + static registry = new Map(); + + /** + * Option keys whose values are themselves type specs and should be + * recursively resolved via {@link PropType.for}. Subclasses override + * (e.g. `ListType` → `["values"]`). + * @type {string[]} + */ + static nestedSpecKeys = []; + + /** + * Shared fallback returned by {@link PropType.for} when no type is + * specified or matched. Subclasses inherit via the class chain. + * @type {PropType} + */ + static any = new PropType(); + + /** + * Register a type: constructs an instance (via `new this(spec)`) and + * stores it in {@link PropType.registry} keyed on `spec.is`. Returns + * the registered instance. + * @param {PropTypeSpec} spec + * @returns {PropType} + */ + static register (spec) { + let instance = new this(spec); + this.registry.set(instance.is, instance); + return instance; + } + + /** + * Resolve any user-facing type identifier into a {@link PropType}. + * + * - PropType instance → returned as-is. + * - constructor / string → registry lookup (string resolved via `globalThis`). + * - object spec → constructs a derivative if it carries extras beyond `is`, + * otherwise returns the registered singleton. + * - null / undefined → `options.fallback` (default: the generic instance). + * + * @param {SpecifiedType} input + * @param {{ fallback?: PropType }} [options] + * @returns {PropType} + */ + static for (input, { fallback = this.any } = {}) { + if (input instanceof PropType) { + return input; + } + + if (!input) { + return fallback; + } + + if (typeof input === "object") { + let { is, ...extras } = input; + if (Object.keys(extras).length > 0) { + return new this(input); + } + input = is; + } + + return this.registry.get(PropType.normalizeIs(input)) ?? fallback; + } + + /** + * Resolve an `is` identifier to its JS constructor. Strings are looked + * up in `globalThis`; everything else passes through. The single source + * of string-to-constructor resolution in the class. + * @param {Function | string | undefined} is + * @returns {Function | undefined} + */ + static normalizeIs (is) { + return typeof is === "string" ? (globalThis[is] ?? is) : is; + } +} + +/** + * @typedef {object} PropTypeSpec + * @property {Function | string} [is] JS constructor (or its global name). + * @property {(this: PropType, a: unknown, b: unknown) => boolean} [equals] + * @property {(this: PropType, value: unknown) => unknown} [parse] + * @property {(this: PropType, value: unknown) => (string | null)} [stringify] + */ + +/** + * Anything users can pass as `type` in a prop spec. + * @typedef {PropTypeSpec | Function | string | PropType} SpecifiedType + */ diff --git a/src/plugins/props/util/types.js b/src/plugins/props/util/types.js deleted file mode 100644 index e6ed123e..00000000 --- a/src/plugins/props/util/types.js +++ /dev/null @@ -1,101 +0,0 @@ -import * as allTypes from "../types/index.js"; - -const defaultType = allTypes.generic; - -export const types = new Map(); -for (let name in allTypes) { - if (name === "generic") { - continue; - } - - let spec = allTypes[name]; - types.set(spec.type, spec); -} - -export function equals (a, b, type) { - if (a === null || b === null || a === undefined || b === undefined) { - return a === b; - } - - if (type) { - let { is: Type, ...typeOptions } = resolve(type); - let equals = types.get(Type)?.equals; - - if (equals) { - return equals(a, b, type); - } - } - - return defaultType.equals(a, b, type); -} - -// Cast a value to the desired type -export function parse (value, type) { - if (!type || value === undefined || value === null) { - return value; - } - - if (type) { - type = resolve(type); - let { is: Type, ...typeOptions } = type; - let parse = types.get(Type)?.parse; - - if (parse) { - return parse(value, type); - } - } - - return defaultType.parse(value, type); -} - -export function stringify (value, type) { - if (value === undefined || value === null) { - return null; - } - - if (!type) { - return String(value); - } - - let { is: Type, ...typeOptions } = resolve(type); - - let stringify = types.get(Type)?.stringify; - - if (stringify === false) { - // stringify is *explicitly* forbidden - throw new TypeError(`Cannot stringify ${type}`); - } - - return stringify ? stringify(value) : defaultType.stringify(value, type); -} - -/** - * A resolved type spec - * @typedef {Object} TypeSpec - * @property {Function} is - * @property {object} [...typeOptions] Options specific to the defined type - */ - -/** - * A type, as can be specified by users of the library - * @typedef {TypeSpec | Function | string} SpecifiedType - */ - -/** - * Resolve a type value into a spec - * @param {SpecifiedType} type - * @returns {TypeSpec} - */ -export function resolve (type) { - if (type) { - if (typeof type === "function" || typeof type === "string") { - type = { is: type }; - } - - if (typeof type.is === "string") { - type.is = globalThis[type.is]; - } - } - - return type; -} diff --git a/src/plugins/types.js b/src/plugins/types.js new file mode 100644 index 00000000..3c153bcd --- /dev/null +++ b/src/plugins/types.js @@ -0,0 +1,9 @@ +/** + * Public, named singletons for the built-in {@link PropType} instances. + * + * Exported under the corresponding JS constructor names so consumers can write: + * `import * as Types from "nude-element/plugins/types"; Types.Array, Types.Map, ...` + */ + +export { ArrayType as Array, SetType as Set } from "./props/types/lists.js"; +export { ObjectType as Object, MapType as Map } from "./props/types/dictionaries.js"; diff --git a/test/PropType.js b/test/PropType.js new file mode 100644 index 00000000..1a34766c --- /dev/null +++ b/test/PropType.js @@ -0,0 +1,479 @@ +import PropType from "../src/plugins/props/util/PropType.js"; +import ListType from "../src/plugins/props/util/ListType.js"; +import DictionaryType from "../src/plugins/props/util/DictionaryType.js"; +// Side-effect imports register the built-in types. +import "../src/plugins/props/types/index.js"; +import { ArrayType, SetType } from "../src/plugins/props/types/lists.js"; +import { ObjectType, MapType } from "../src/plugins/props/types/dictionaries.js"; + +const NumberType = PropType.for(Number); +const StringType = PropType.for(String); + +export default { + name: "PropType", + tests: [ + { + name: "for() — pure lookup returns the registered singleton", + tests: [ + { + name: "By constructor", + run: () => PropType.for(Array) === ArrayType, + expect: true, + }, + { + name: "By global name string", + run: () => PropType.for("Array") === ArrayType, + expect: true, + }, + { + name: "By bare spec {is: ctor}", + run: () => PropType.for({ is: Array }) === ArrayType, + expect: true, + }, + { + name: "By bare spec {is: 'name'}", + run: () => PropType.for({ is: "Array" }) === ArrayType, + expect: true, + }, + { + name: "PropType instance passes through", + run: () => PropType.for(ArrayType) === ArrayType, + expect: true, + }, + { + name: "Lookup is idempotent across calls", + run: () => PropType.for(Array) === PropType.for(Array), + expect: true, + }, + { + name: "undefined yields a fallback that is a PropType", + run: () => PropType.for(undefined) instanceof PropType, + expect: true, + }, + { + name: "null yields the same fallback as undefined", + run: () => PropType.for(undefined) === PropType.for(null), + expect: true, + }, + { + name: "Custom fallback honored", + run: () => PropType.for(undefined, { fallback: ArrayType }) === ArrayType, + expect: true, + }, + { + name: "Unregistered constructor yields the default fallback", + run () { + class Unknown {} + return PropType.for(Unknown) === PropType.for(undefined); + }, + expect: true, + }, + { + name: "Built-in singletons match their named exports", + run: () => + PropType.for(Array) === ArrayType + && PropType.for(Set) === SetType + && PropType.for(Object) === ObjectType + && PropType.for(Map) === MapType, + expect: true, + }, + ], + }, + { + name: "Derivation", + tests: [ + { + name: "Specs with options produce a fresh instance, not the singleton", + run: () => PropType.for({ is: Array, values: Number }) !== ArrayType, + expect: true, + }, + { + name: "Derivative is an instance of its abstract base class", + run: () => PropType.for({ is: Array, values: Number }) instanceof ListType, + expect: true, + }, + { + name: "Derivative reports the correct is", + run: () => PropType.for({ is: Array, values: Number }).is === Array, + expect: true, + }, + { + name: "Nested option specs resolve to PropType instances", + run () { + let t = PropType.for({ is: Array, values: Number }); + return t.values === NumberType; + }, + expect: true, + }, + { + name: "No caching: repeated calls yield distinct derivatives", + run () { + let t1 = PropType.for({ is: Array, values: Number }); + let t2 = PropType.for({ is: Array, values: Number }); + return t1 !== t2; + }, + expect: true, + }, + { + name: "But nested singletons are shared across calls", + run () { + let t1 = PropType.for({ is: Array, values: Number }); + let t2 = PropType.for({ is: Array, values: Number }); + return t1.values === t2.values; + }, + expect: true, + }, + { + name: "Mutating a derivative does not affect the singleton", + run () { + let t = PropType.for({ is: Array, values: Number }); + t.separator = ";"; + return ArrayType.separator; + }, + expect: undefined, + }, + { + name: "Method override is used in dispatch", + run () { + let t = PropType.for({ is: Array, equals: () => "called" }); + return t.equals([1], [1, 2]); + }, + expect: "called", + }, + { + name: "Non-overridden methods still come from the parent", + run () { + let t = PropType.for({ is: Array, equals: () => false }); + return t.parse("a, b, c"); + }, + expect: ["a", "b", "c"], + }, + { + name: "Non-overridden stringify still comes from the parent", + run () { + let t = PropType.for({ is: Array, equals: () => false }); + return t.stringify(["a", "b", "c"]); + }, + expect: "a, b, c", + }, + ], + }, + { + name: "Default method behavior", + tests: [ + { + name: "parse passes null through", + run: () => PropType.for(Array).parse(null), + expect: null, + }, + { + name: "parse passes undefined through", + run: () => PropType.for(Array).parse(undefined), + expect: undefined, + }, + { + name: "stringify returns null for null", + run: () => PropType.for(Array).stringify(null), + expect: null, + }, + { + name: "stringify returns null for undefined", + run: () => PropType.for(Array).stringify(undefined), + expect: null, + }, + { + name: "equals: null vs null is true", + run: () => PropType.for(Array).equals(null, null), + expect: true, + }, + { + name: "equals: null vs undefined is false", + run: () => PropType.for(Array).equals(null, undefined), + expect: false, + }, + { + name: "equals: identity short-circuit", + run () { + let a = [1, 2]; + return PropType.for(Array).equals(a, a); + }, + expect: true, + }, + ], + }, + { + name: "List behavior", + tests: [ + { + name: "Array parses comma-separated string", + run () { + return PropType.for({ is: Array, values: Number }).parse("1, 2, 3"); + }, + expect: [1, 2, 3], + }, + { + name: "Array stringifies", + run () { + return PropType.for({ is: Array, values: Number }).stringify([1, 2, 3]); + }, + expect: "1, 2, 3", + }, + { + name: "Array equality with matching contents", + run () { + let t = PropType.for({ is: Array, values: Number }); + return t.equals([1, 2, 3], [1, 2, 3]); + }, + expect: true, + }, + { + name: "Array equality with different length", + run () { + let t = PropType.for({ is: Array, values: Number }); + return t.equals([1, 2], [1, 2, 3]); + }, + expect: false, + }, + { + name: "Set parses to a Set instance", + run () { + let result = PropType.for({ is: Set, values: Number }).parse("1, 2, 3"); + return result instanceof Set && [...result].join(","); + }, + expect: "1,2,3", + }, + { + name: "Derivative with custom separator parses with it", + run () { + return PropType.for({ is: Array, values: Number, separator: ";" }).parse("1; 2; 3"); + }, + expect: [1, 2, 3], + }, + { + name: "Derivative with custom separator stringifies with it (no auto-spacing)", + run () { + return PropType.for({ is: Array, values: Number, separator: ";" }).stringify([1, 2, 3]); + }, + expect: "1;2;3", + }, + { + name: "Derivative with custom joiner stringifies with it", + run () { + return PropType.for({ is: Array, values: Number, joiner: " " }).stringify([1, 2, 3]); + }, + expect: "1 2 3", + }, + { + name: "Derivative without `values` still respects separator", + run () { + return PropType.for({ is: Array, separator: ";" }).parse("a; b; c"); + }, + expect: ["a", "b", "c"], + }, + ], + }, + { + name: "Dictionary behavior", + tests: [ + { + name: "Object parses microsyntax", + run () { + return PropType.for({ is: Object, keys: String, values: Number }).parse("a: 1, b: 2"); + }, + expect: { a: 1, b: 2 }, + }, + { + name: "Map parses to a Map with correct entries", + run () { + let m = PropType.for({ is: Map, keys: String, values: Number }).parse("a: 1, b: 2"); + return [m instanceof Map, m.get("a"), m.get("b")]; + }, + expect: [true, 1, 2], + }, + { + name: "Nested key and value types are resolved", + run () { + let t = PropType.for({ is: Map, keys: String, values: Number }); + return t.keys === StringType && t.values === NumberType; + }, + expect: true, + }, + { + name: "Derivative with custom separator parses with it", + run () { + return PropType.for({ is: Object, keys: String, values: Number, separator: ";" }).parse("a: 1; b: 2"); + }, + expect: { a: 1, b: 2 }, + }, + { + name: "Derivative with custom separator stringifies with it", + run () { + return PropType.for({ is: Object, keys: String, values: Number, separator: " | " }).stringify({ a: 1, b: 2 }); + }, + expect: "a: 1 | b: 2", + }, + { + name: "Derivative with defaultValue uses it for valueless entries", + run () { + return PropType.for({ is: Object, defaultValue: 7 }).parse("a, b, c"); + }, + expect: { a: 7, b: 7, c: 7 }, + }, + { + name: "Derivative with defaultKey uses it for keyless entries", + run () { + return PropType.for({ is: Object, defaultKey: (v, i) => i }).parse("a, b, c"); + }, + expect: { 0: "a", 1: "b", 2: "c" }, + }, + ], + }, + { + name: "Nested type composition", + tests: [ + { + name: "Array> — inner type is resolved correctly", + run () { + let t = PropType.for({ + is: Array, + values: { is: Array, values: Number }, + }); + return t.values instanceof ListType + && t.values.is === Array + && t.values.values === NumberType; + }, + expect: true, + }, + { + name: "Array> — inner type is the SetType derivative", + run () { + let t = PropType.for({ + is: Array, + values: { is: Set, values: Number }, + }); + return t.values instanceof ListType + && t.values.is === Set; + }, + expect: true, + }, + ], + }, + { + name: "Built-in primitives", + tests: [ + { + name: "Boolean.parse(null) → null", + run: () => PropType.for(Boolean).parse(null), + expect: null, + }, + { + name: "Boolean.parse(any non-null) → true", + run: () => PropType.for(Boolean).parse("anything"), + expect: true, + }, + { + name: "Boolean.stringify(true) → empty string", + run: () => PropType.for(Boolean).stringify(true), + expect: "", + }, + { + name: "Boolean.stringify(false) → null", + run: () => PropType.for(Boolean).stringify(false), + expect: null, + }, + { + name: "Number.parse coerces string", + run: () => PropType.for(Number).parse("42"), + expect: 42, + }, + { + name: "Number.equals: NaN === NaN", + run: () => PropType.for(Number).equals(NaN, NaN), + expect: true, + }, + { + name: "Number.equals: 1 vs 2 false", + run: () => PropType.for(Number).equals(1, 2), + expect: false, + }, + { + name: "Function singleton parses zero-arg body", + run () { + return PropType.for(Function).parse("return 7")(); + }, + expect: 7, + }, + { + name: "Function derivative with arguments parses correctly", + run () { + let t = PropType.for({ is: Function, arguments: ["x"] }); + return t.parse("return x * 2")(5); + }, + expect: 10, + }, + { + name: "Function stringify throws", + run () { + try { + PropType.for(Function).stringify(() => {}); + return "no throw"; + } + catch (e) { + return e instanceof TypeError; + } + }, + expect: true, + }, + ], + }, + { + name: "register()", + tests: [ + { + name: "After register, for() returns the registered instance", + run () { + class Foo {} + let registered = PropType.register({ is: Foo }); + let result = PropType.for(Foo) === registered; + PropType.registry.delete(Foo); + return result; + }, + expect: true, + }, + { + name: "ListType.register produces a ListType", + run () { + class FooList {} + let t = ListType.register({ is: FooList }); + let result = t instanceof ListType; + PropType.registry.delete(FooList); + return result; + }, + expect: true, + }, + { + name: "DictionaryType.register produces a DictionaryType", + run () { + class FooDict {} + let t = DictionaryType.register({ is: FooDict }); + let result = t instanceof DictionaryType; + PropType.registry.delete(FooDict); + return result; + }, + expect: true, + }, + { + name: "Registered parse is actually invoked", + run () { + class Foo {} + PropType.register({ is: Foo, parse: () => "parsed!" }); + let result = PropType.for(Foo).parse("anything"); + PropType.registry.delete(Foo); + return result; + }, + expect: "parsed!", + }, + ], + }, + ], +}; diff --git a/test/index.js b/test/index.js index 37359c7e..10d25fd6 100644 --- a/test/index.js +++ b/test/index.js @@ -1,3 +1,4 @@ +import PropType from "./PropType.js"; import Prop from "./Prop.js"; import Props from "./Props.js"; import hooks from "./hooks.js"; @@ -6,5 +7,5 @@ import plugins from "./plugins/index.js"; export default { name: "All tests", - tests: [Prop, Props, hooks, split, plugins], + tests: [PropType, Prop, Props, hooks, split, plugins], }; From e67a67a49de6a710d8abe53d6c5582fe4d5dac85 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 22 May 2026 11:35:35 -0400 Subject: [PATCH 02/27] Update PropType.js --- src/plugins/props/util/PropType.js | 52 +++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 73a61b84..6ec3a035 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -31,18 +31,39 @@ const callableBuiltins = new Set([ */ export default class PropType { /** - * Slot for type-specific method overrides supplied at registration: - * `{equals?, parse?, stringify?}`. May be inherited via the instance - * prototype chain; for derivatives that introduce their own override, - * the constructor sets a new `Object.create(parent.spec)` so unspecified - * slots still fall through. - * @type {{equals?: Function, parse?: Function, stringify?: Function} | undefined} + * The spec object this instance was constructed with — stored verbatim, + * not cloned. Type-specific method overrides (`equals`, `parse`, + * `stringify`) live here and are invoked by the prototype dispatchers + * after walking the {@link super} chain. + * @type {PropTypeSpec | undefined} */ spec; + /** + * The next instance up the type chain — a registered singleton for + * derivatives, or the class prototype for roots. Used by the dispatchers + * to walk for inherited method overrides; subclasses and consumers can + * walk it to inspect lineage. + * @type {PropType | object | undefined} + */ + super; + /** @param {TSpec} [spec] */ - constructor (spec = {}) { + constructor (spec) { + if (!spec) { + return this.constructor.any; + } + + if (typeof spec !== "object") { + spec = { is: spec }; + } + let { is, ...extras } = spec; + + if (!is) { + return this.constructor.any; + } + is = PropType.normalizeIs(is); let parent = PropType.registry.get(is); @@ -52,10 +73,11 @@ export default class PropType { } let instance = parent ? Object.create(parent) : this; - // Only set `is` on the instance when there's no parent (root path). - // On the derivative path, `is` is inherited via the prototype chain - // (parent was looked up by this `is`, so they match). - if (is !== undefined && !parent) { + if (parent) { + instance.super = parent; + } + else { + instance.super = this.constructor.prototype; instance.is = is; } @@ -89,7 +111,7 @@ export default class PropType { return true; } - for (let obj = this; obj; obj = Object.getPrototypeOf(obj)) { + for (let obj = this; obj; obj = obj.super) { if (obj.spec?.equals) { return obj.spec.equals.call(this, a, b); } @@ -107,7 +129,7 @@ export default class PropType { return value; } - for (let obj = this; obj; obj = Object.getPrototypeOf(obj)) { + for (let obj = this; obj; obj = obj.super) { if (obj.spec?.parse) { return obj.spec.parse.call(this, value); } @@ -131,7 +153,7 @@ export default class PropType { return null; } - for (let obj = this; obj; obj = Object.getPrototypeOf(obj)) { + for (let obj = this; obj; obj = obj.super) { if (obj.spec?.stringify) { return obj.spec.stringify.call(this, value); } @@ -146,7 +168,7 @@ export default class PropType { /** * Option keys whose values are themselves type specs and should be * recursively resolved via {@link PropType.for}. Subclasses override - * (e.g. `ListType` → `["values"]`). + * (e.g. `IterableType` → `["values"]`). * @type {string[]} */ static nestedSpecKeys = []; From c17f503e9f0a8d9f684976b0e343fa50d2ad73ef Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 22 May 2026 18:10:14 -0400 Subject: [PATCH 03/27] Collapse type system to a single PropType class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abstract types (IterableType, formerly DictionaryType) used to be JS subclasses of PropType, encoding the type chain twice: once in `obj.super` (walked by the dispatcher) and once in `class X extends Y` (ignored by the dispatcher). The duplication surfaced as a wart — `parseItems` couldn't be a generator without colliding with the dispatched `parse` method, and Dictionary couldn't inherit Iterable's splitting because they were sibling classes. PropType is now the sole class. Abstracts are PropType instances registered by `name`; concrete types declare `extends: ` to decouple the chain parent from `is`. The dispatcher walks one chain for everything. - IterableType.parseItems (raw splitter) + parse (typed, generator) + stringify + equals — all streaming, single-pass. - MapType absorbs the former DictionaryType (Map IS the dictionary in JS; Dictionary was a fabricated abstract). ObjectType extends MapType. - Concrete types (array.js, set.js, object.js, map.js) live in one-file-per- type form; each consumes its parent's iterator with the appropriate container constructor. - `subTypes` spec key replaces `nestedSpecKeys`; unspecified sub-types default to PropType.any (set at the abstract root, inherited via the prototype chain) so `this.values.parse(v)` works unconditionally. - split() extracted to util/split.js. - `isA(other)` helper replaces `instanceof` for abstract-type checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/README.md | 21 +-- src/plugins/props/types/array.js | 10 ++ src/plugins/props/types/dictionaries.js | 100 ------------- src/plugins/props/types/index.js | 12 +- src/plugins/props/types/iterable.js | 112 ++++++++++++++ src/plugins/props/types/lists.js | 58 -------- src/plugins/props/types/map.js | 150 +++++++++++++++++++ src/plugins/props/types/object.js | 30 ++++ src/plugins/props/types/set.js | 23 +++ src/plugins/props/types/util.js | 117 --------------- src/plugins/props/util/DictionaryType.js | 98 ------------ src/plugins/props/util/ListType.js | 68 --------- src/plugins/props/util/PropType.js | 180 ++++++++++++++++------- src/plugins/props/util/split.js | 103 +++++++++++++ src/plugins/types.js | 6 +- test/PropType.js | 29 ++-- test/split.js | 4 +- 17 files changed, 597 insertions(+), 524 deletions(-) create mode 100644 src/plugins/props/types/array.js delete mode 100644 src/plugins/props/types/dictionaries.js create mode 100644 src/plugins/props/types/iterable.js delete mode 100644 src/plugins/props/types/lists.js create mode 100644 src/plugins/props/types/map.js create mode 100644 src/plugins/props/types/object.js create mode 100644 src/plugins/props/types/set.js delete mode 100644 src/plugins/props/util/DictionaryType.js delete mode 100644 src/plugins/props/util/ListType.js create mode 100644 src/plugins/props/util/split.js diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index bdf77106..bf4d8337 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -155,7 +155,7 @@ It can be either a constant (e.g. `true`) or a function, in which case it’s pa #### Custom types -Types are *instances* of `PropType` (or one of its subclasses, `ListType` / `DictionaryType`). Each instance carries the spec it was constructed with — its constructor (`is`) and any type options — and supplies `equals` / `parse` / `stringify` either as methods on a subclass prototype, or as overrides passed via the constructor spec. A type is an abstract, prop-agnostic definition; the same instance is shared across every prop that references it. +Types are *instances* of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`) and any type options — and supplies `equals` / `parse` / `stringify` as overrides on the spec. `IterableType` is an abstract `PropType` instance registered by `name`; concrete types like `Array`, `Set`, and `Map` declare `extends: IterableType` to inherit its parsing behavior via the JS prototype chain. `Object` further extends `Map` since it's a constrained dictionary realization. A type is an abstract, prop-agnostic definition; the same instance is shared across every prop that references it. For most custom types, the simplest path is to register a spec object directly. Methods read options from `this.spec`: @@ -170,20 +170,23 @@ PropType.register({ }); ``` -For types that need richer shared behavior (e.g. resolving nested type specs, or reusing logic across several constructors), subclass `PropType` — or one of the built-in bases `ListType` / `DictionaryType`: +For types that need richer shared behavior (e.g. resolving nested type specs, or reusing parsing logic), `extends` an existing abstract or concrete — `IterableType` for any iterable, `MapType` for dictionary-shaped types: ```js -import { PropType, ListType } from "nude-element/plugins"; +import { PropType, IterableType } from "nude-element/plugins"; -class TupleType extends ListType { - // override / extend as needed; `this.spec` holds the merged spec, +PropType.register({ + is: Tuple, + extends: IterableType, + // override / extend as needed; `this.spec` holds the spec, // `this.values` is the resolved nested type -} - -PropType.register(new TupleType({ is: Tuple, /* ...spec */ })); + parse (value) { + return new Tuple(...IterableType.spec.parse.call(this, value)); + }, +}); ``` -**Derivative types.** A type spec with options beyond `is` produces a *derivative* type — a new `PropType` instance whose prototype is the registered singleton for that `is`. Lookups for unspecified options fall through to the parent via the JS prototype chain. +**Derivative types.** A type spec with options beyond `is` produces a *derivative* type — a new `PropType` instance whose prototype is the registered singleton for that `is` (or the abstract named via `extends`). Lookups for unspecified options fall through to the parent via the JS prototype chain. ```js import { PropType } from "nude-element/plugins"; diff --git a/src/plugins/props/types/array.js b/src/plugins/props/types/array.js new file mode 100644 index 00000000..68bb6013 --- /dev/null +++ b/src/plugins/props/types/array.js @@ -0,0 +1,10 @@ +import PropType from "../util/PropType.js"; +import IterableType from "./iterable.js"; + +export default PropType.register({ + is: Array, + extends: IterableType, + parse (value) { + return [...IterableType.spec.parse.call(this, value)]; + }, +}); diff --git a/src/plugins/props/types/dictionaries.js b/src/plugins/props/types/dictionaries.js deleted file mode 100644 index b5a5ffe3..00000000 --- a/src/plugins/props/types/dictionaries.js +++ /dev/null @@ -1,100 +0,0 @@ -import DictionaryType from "../util/DictionaryType.js"; - -export const ObjectType = DictionaryType.register({ - is: Object, - equals (a, b) { - let aKeys = Object.keys(a); - let bKeys = Object.keys(b); - - if (aKeys.length !== bKeys.length) { - return false; - } - - let { values } = this; - return aKeys.every(key => (values ? values.equals(a[key], b[key]) : a[key] === b[key])); - }, - parse (value) { - if (value instanceof Map) { - value = value.entries(); - } - else if (typeof value === "object") { - let { values } = this; - if (values) { - for (let key in value) { - value[key] = values.parse(value[key]); - } - } - - return value; - } - - return Object.fromEntries(this.parseEntries(value)); - }, - stringify (value) { - let { values } = this; - let { separator = ", " } = this.spec; - let entries = Object.entries(value); - - if (values) { - entries = entries.map(([key, val]) => [key, values.stringify(val)]); - } - - return entries.map(([key, val]) => `${key}: ${val}`).join(separator); - }, -}); - -export const MapType = DictionaryType.register({ - is: Map, - equals (a, b) { - if (a.size !== b.size) { - return false; - } - - let { values } = this; - for (let [key, val] of a) { - if (!b.has(key)) { - return false; - } - - let bVal = b.get(key); - if (values ? !values.equals(val, bVal) : val !== bVal) { - return false; - } - } - - return true; - }, - parse (value) { - if (value instanceof Map) { - let { keys, values } = this; - if (keys || values) { - for (let [key, val] of value) { - value.delete(key); - value.set(keys?.parse(key) ?? key, values?.parse(val) ?? val); - } - } - - return value; - } - else if (typeof value === "object") { - value = Object.entries(value); - } - - let entries = this.parseEntries(value); - return Array.isArray(entries) ? new Map(entries) : entries; - }, - stringify (value) { - let { keys, values } = this; - let { separator = ", " } = this.spec; - let entries = [...value.entries()]; - - if (keys || values) { - entries = entries.map(([key, val]) => [ - keys ? keys.stringify(key) : key, - values ? values.stringify(val) : val, - ]); - } - - return entries.map(([key, val]) => `${key}: ${val}`).join(separator); - }, -}); diff --git a/src/plugins/props/types/index.js b/src/plugins/props/types/index.js index 2451d899..ae8b8842 100644 --- a/src/plugins/props/types/index.js +++ b/src/plugins/props/types/index.js @@ -1,8 +1,12 @@ // Side-effect imports register the built-in types' singletons. +// Order matters: `iterable` must register before the concrete types that +// `extends` it (array, set, map); `map` before `object` (since object extends map). import "./basic.js"; -import "./lists.js"; -import "./dictionaries.js"; +import "./iterable.js"; +import "./array.js"; +import "./set.js"; +import "./map.js"; +import "./object.js"; export { default as PropType } from "../util/PropType.js"; -export { default as ListType } from "../util/ListType.js"; -export { default as DictionaryType } from "../util/DictionaryType.js"; +export { default as IterableType } from "./iterable.js"; diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js new file mode 100644 index 00000000..db6632bb --- /dev/null +++ b/src/plugins/props/types/iterable.js @@ -0,0 +1,112 @@ +import PropType from "../util/PropType.js"; +import { split } from "../util/split.js"; + +/** + * Abstract type for any iterable. The parsing pipeline is two streaming + * generators: {@link parseItems} yields raw items (no type parsing), and + * {@link parse} yields each item through `this.values`. Concrete types + * (Array, Set, …) `extends: IterableType` and pipe the {@link parse} + * iterator straight into their own container — every input flows through + * the chain exactly once, no intermediate arrays. + * + * `MapType` extends this and reuses `parseItems` to grab the raw string + * parts, then splits each on `:` in its own `parseEntries` to yield + * `[key, value]` tuples. Distinct names because the return types differ. + */ +const IterableType = PropType.register({ + name: "Iterable", + subTypes: ["values"], + + /** + * Yield raw items of `value` — strings get split via {@link split}, + * iterables are consumed verbatim, scalars are yielded once. No + * `values.parse` is applied; this is the splitter, not the typer. + * @this {PropType} + * @param {string | Iterable | unknown} value + * @returns {Iterator} + */ + *parseItems (value) { + if (typeof value === "string") { + yield* split(value, this.spec); + } + else if (value?.[Symbol.iterator]) { + yield* value; + } + else { + yield value; + } + }, + + /** + * Yield each item from {@link parseItems} through `this.values`. + * @this {PropType} + * @param {string | Iterable | unknown} value + * @returns {Iterator} + */ + *parse (value) { + for (let v of this.parseItems(value)) { + yield this.values.parse(v); + } + }, + + /** + * Stringify an iterable: each item passes through `this.values`, joined + * by `spec.joiner` (falling back to `spec.separator`, default `", "`). + * Whitespace is *not* added automatically — consumers who want spaces + * include them in the joiner (or separator) themselves. + * @this {PropType} + * @param {Iterable} value + * @returns {string} + */ + stringify (value) { + let { separator = ", ", joiner = separator } = this.spec; + let parts = []; + for (let v of value) { + parts.push(this.values.stringify(v)); + } + return parts.join(joiner); + }, + + /** + * Walk two iterables in parallel, comparing each pair via + * `this.values.equals`. Returns true iff both yield the same number + * of items and every pair compares equal. + * @this {PropType} + * @param {Iterable} a + * @param {Iterable} b + * @returns {boolean} + */ + equals (a, b) { + let aIter = a[Symbol.iterator](); + let bIter = b[Symbol.iterator](); + while (true) { + let { value: av, done: ad } = aIter.next(); + let { value: bv, done: bd } = bIter.next(); + if (ad !== bd) { + return false; + } + if (ad) { + return true; + } + if (!this.values.equals(av, bv)) { + return false; + } + } + }, +}); + +export default IterableType; + +/** + * @typedef {import("../util/PropType.js").SpecifiedType} SpecifiedType + * @typedef {import("../util/PropType.js").PropTypeSpec} PropTypeSpec + */ + +/** + * @typedef {PropTypeSpec & { + * values?: SpecifiedType, + * separator?: string, + * joiner?: string, + * pairs?: object, + * }} IterableTypeSpec + */ diff --git a/src/plugins/props/types/lists.js b/src/plugins/props/types/lists.js deleted file mode 100644 index 6d6ad70a..00000000 --- a/src/plugins/props/types/lists.js +++ /dev/null @@ -1,58 +0,0 @@ -import ListType from "../util/ListType.js"; - -export const ArrayType = ListType.register({ - is: Array, - equals (a, b) { - if (a.length !== b.length) { - return false; - } - - let { values } = this; - return a.every((item, i) => (values ? values.equals(item, b[i]) : item === b[i])); - }, - parse (value) { - return this.parseItems(value); - }, - stringify (value) { - return this.joinItems(value); - }, -}); - -export const SetType = ListType.register({ - is: Set, - equals (a, b) { - if (a.size !== b.size) { - return false; - } - - for (let item of a) { - if (!b.has(item)) { - return false; - } - } - - return true; - }, - parse (value) { - if (value instanceof Set) { - let { values } = this; - if (values) { - // Parse values in place - for (let item of value) { - let parsed = values.parse(item); - if (parsed !== item) { - value.delete(item); - value.add(parsed); - } - } - } - - return value; - } - - return new Set(this.parseItems(value)); - }, - stringify (value) { - return this.joinItems([...value]); - }, -}); diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js new file mode 100644 index 00000000..e2b3f3e0 --- /dev/null +++ b/src/plugins/props/types/map.js @@ -0,0 +1,150 @@ +import PropType from "../util/PropType.js"; +import IterableType from "./iterable.js"; + +const entrySplitter = /(? | unknown} value + * @returns {Iterator<[unknown, unknown]>} + */ + *parseEntries (value) { + let { defaultKey, defaultValue = true } = this.spec; + let index = 0; + for (let item of this.parseItems(value)) { + let k, v; + if (typeof item === "string") { + let parts = item.split(entrySplitter); + if (parts.length >= 2) { + k = parts.shift(); + v = parts.join(":"); + } + else if (defaultKey !== undefined) { + v = parts[0]; + k = typeof defaultKey === "function" ? defaultKey(v, index) : defaultKey; + } + else { + k = parts[0]; + v = typeof defaultValue === "function" ? defaultValue(k, index) : defaultValue; + } + k = k?.trim?.() ?? k; + v = v?.trim?.() ?? v; + if (v === "false") { + v = false; + } + } + else { + [k, v] = item; + } + + yield [k, v]; + index++; + } + }, + + /** + * Apply `this.keys` / `this.values` to each entry from + * {@link parseEntries} and materialize into a `Map`. ObjectType + * overrides with the same loop body but materializes via + * `Object.fromEntries`. + * @this {PropType} + * @param {string | Iterable<[unknown, unknown]> | object} value + * @returns {Map} + */ + parse (value) { + if (value && typeof value === "object" && !value[Symbol.iterator]) { + value = Object.entries(value); + } + let result = new Map(); + for (let [k, v] of this.parseEntries(value)) { + result.set(this.keys.parse(k), this.values.parse(v)); + } + return result; + }, + + /** + * Stringify an iterable of `[key, value]` entries into `"k: v, k: v"`. + * Each half passes through `this.keys` / `this.values`; entries are + * joined by `spec.separator` (default `", "`). + * @this {PropType} + * @param {Iterable<[unknown, unknown]>} value + * @returns {string} + */ + stringify (value) { + let { separator = ", " } = this.spec; + let parts = []; + for (let [k, v] of value) { + parts.push(`${this.keys.stringify(k)}: ${this.values.stringify(v)}`); + } + return parts.join(separator); + }, + + /** + * Walk two iterables of `[key, value]` entries in parallel, comparing + * each pair via `this.keys.equals` and `this.values.equals`. + * @this {PropType} + * @param {Iterable<[unknown, unknown]>} a + * @param {Iterable<[unknown, unknown]>} b + * @returns {boolean} + */ + equals (a, b) { + let aIter = a[Symbol.iterator](); + let bIter = b[Symbol.iterator](); + while (true) { + let { value: aEntry, done: ad } = aIter.next(); + let { value: bEntry, done: bd } = bIter.next(); + if (ad !== bd) { + return false; + } + if (ad) { + return true; + } + let [ak, av] = aEntry; + let [bk, bv] = bEntry; + if (!this.keys.equals(ak, bk) || !this.values.equals(av, bv)) { + return false; + } + } + }, +}); + +export default MapType; + +/** + * @typedef {import("../util/PropType.js").SpecifiedType} SpecifiedType + * @typedef {import("../util/PropType.js").PropTypeSpec} PropTypeSpec + */ + +/** + * @typedef {PropTypeSpec & { + * keys?: SpecifiedType, + * values?: SpecifiedType, + * separator?: string, + * defaultKey?: ((value: unknown, index: number) => unknown) | unknown, + * defaultValue?: ((key: unknown, index: number) => unknown) | unknown, + * pairs?: object, + * }} MapTypeSpec + */ diff --git a/src/plugins/props/types/object.js b/src/plugins/props/types/object.js new file mode 100644 index 00000000..be3ca096 --- /dev/null +++ b/src/plugins/props/types/object.js @@ -0,0 +1,30 @@ +import PropType from "../util/PropType.js"; +import MapType from "./map.js"; + +export default PropType.register({ + is: Object, + extends: MapType, + equals (a, b) { + let aKeys = Object.keys(a); + let bKeys = Object.keys(b); + + if (aKeys.length !== bKeys.length) { + return false; + } + + return aKeys.every(key => this.values.equals(a[key], b[key])); + }, + parse (value) { + if (value && typeof value === "object" && !value[Symbol.iterator]) { + value = Object.entries(value); + } + let result = {}; + for (let [k, v] of this.parseEntries(value)) { + result[this.keys.parse(k)] = this.values.parse(v); + } + return result; + }, + stringify (value) { + return MapType.spec.stringify.call(this, Object.entries(value)); + }, +}); diff --git a/src/plugins/props/types/set.js b/src/plugins/props/types/set.js new file mode 100644 index 00000000..c81a1e0b --- /dev/null +++ b/src/plugins/props/types/set.js @@ -0,0 +1,23 @@ +import PropType from "../util/PropType.js"; +import IterableType from "./iterable.js"; + +export default PropType.register({ + is: Set, + extends: IterableType, + equals (a, b) { + if (a.size !== b.size) { + return false; + } + + for (let item of a) { + if (!b.has(item)) { + return false; + } + } + + return true; + }, + parse (value) { + return new Set(IterableType.spec.parse.call(this, value)); + }, +}); diff --git a/src/plugins/props/types/util.js b/src/plugins/props/types/util.js index ff7ec097..609ac615 100644 --- a/src/plugins/props/types/util.js +++ b/src/plugins/props/types/util.js @@ -1,118 +1 @@ export * from "../../../util/resolve-value.js"; - -export const defaultPairs = { - nest: { - "(": ")", - "[": "]", - "{": "}", - }, - ignore: { - '"': '"', - // "'": "'", - // "`": "`", - }, -}; - -export function regexEscape (string) { - return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&"); -} - -/** - * Split a value by a separator, respecting pairs (parens, strings, etc.) but failling back gracefully for malformed input - * @param {string} value - * @param {object} options - * @returns - */ -export function split (value, { separator = ",", pairs = defaultPairs } = {}) { - value = value.trim(); - - // Make whitespace optional and flexible, unless the separator consists entirely of whitespace - separator = separator.trim(); - let isSeparatorWhitespace = !separator; - let separatorRegex = isSeparatorWhitespace - ? /\s+/g - : RegExp(regexEscape(separator).replace(/^\s*|\s*$/g, "\\s*"), "g"); - - let pairStrings = new Set([ - ...Object.keys(pairs.nest), - ...Object.values(pairs.nest), - ...Object.keys(pairs.ignore), - ...Object.values(pairs.ignore), - ]); - let pairRegex = RegExp([...pairStrings].map(regexEscape).join("|"), "g"); - - if (!pairRegex.test(value)) { - // value contains no pairs, just split - return value.trim().split(separatorRegex); - } - - let invertedNestPairs = Object.fromEntries( - Object.entries(pairs.nest).map(([start, end]) => [end, start]), - ); - let splitter = RegExp([separatorRegex.source, pairRegex.source].join("|"), "g"); - let stack = []; - let items = []; - let item = ""; - let matches = [...value.matchAll(splitter)]; - let lastIndex = 0; - let ignoreUntil; - - for (let i = 0; i < matches.length; i++) { - let match = matches[i]; - let index = match.index; - let matched = match[0]; - let part = value.slice(lastIndex, index); - - if (ignoreUntil) { - if (ignoreUntil === matched) { - // TODO escape? - ignoreUntil = null; - } - } - else if (matched.trim() === separator) { - if (stack.length === 0) { - // No open pairs, add to items - items.push(part.trim()); - lastIndex = index + matched.length; - } - } - else if (pairs.ignore[matched]) { - // Opening ignored (verbatim) pair - let closingPair = pairs.ignore[matched]; - - // Do we have its closing pair in the string? - if (matches.slice(i + 1).find(m => m[0] === closingPair)) { - ignoreUntil = closingPair; - } - } - else if (pairs.nest[matched]) { - // Opening nested pair - let closingPair = pairs.nest[matched]; - - // Do we have its closing pair in the string? - if (matches.slice(i + 1).find(m => m[0] === closingPair)) { - stack.push(matched); - } - } - else if (invertedNestPairs[matched]) { - // Closing nested pair - // Why not just check and pop? We want malformed (interleaved) pairs to work too - let startIndex = stack.findLastIndex(start => start === invertedNestPairs[matched]); - - if (startIndex > -1) { - stack.splice(startIndex, 1); - } - } - } - - if (stack.length > 0 || ignoreUntil) { - // Malformed pairs, just split - } - - if (lastIndex < value.length || item.length > 0) { - item += value.slice(lastIndex); - items.push(item.trim()); - } - - return items; -} diff --git a/src/plugins/props/util/DictionaryType.js b/src/plugins/props/util/DictionaryType.js deleted file mode 100644 index fe9e61ca..00000000 --- a/src/plugins/props/util/DictionaryType.js +++ /dev/null @@ -1,98 +0,0 @@ -import PropType from "./PropType.js"; -import { resolveValue, split } from "../types/util.js"; - -/** - * Shared behavior for dictionary-like types (Object, Map): entry parsing for - * the shared microsyntax (`key: value, key: value`), delegating to - * `this.keys` / `this.values` (the resolved nested types) and reading - * formatting options (`separator`, `defaultKey`, `defaultValue`, `pairs`) - * off `this`. - * - * @extends {PropType} - */ -export default class DictionaryType extends PropType { - /** - * Parse a string (or pre-built entries array) into `[key, value]` tuples, - * applying `this.keys` / `this.values` parsing if configured. - * - * Microsyntax: entries split by `this.separator` (default `","`); - * within each entry, `:` splits key from value. Lone tokens use - * `this.defaultKey` / `this.defaultValue` (the latter defaults to `true`). - * The literal string `"false"` becomes `false`. Backslash escapes - * are honored for both separators. - * - * @param {string | [unknown, unknown][]} value - * @returns {[unknown, unknown][]} - */ - parseEntries (value) { - let { separator = ", ", defaultValue = true, defaultKey, pairs } = this.spec; - let entries = value; - - if (typeof value === "string") { - entries = split(value, { separator, pairs }); - - entries = entries.map((entry, index) => { - let parts = entry.split(/(?= 2) { - // Value contains colons - key = parts.shift(); - val = parts.join(":"); - } - else if (parts.length === 1) { - if (defaultKey) { - val = parts[0]; - key = resolveValue(defaultKey, [null, val, index]); - } - else { - key = parts[0]; - val = resolveValue(defaultValue, [null, key, index]); - } - } - - [key, val] = [key, val].map(v => v?.trim?.() ?? v); - - if (val === "false") { - val = false; - } - - return [key, val]; - }); - } - - let { keys, values } = this; - entries = entries.map(([key, val]) => { - if (keys) { - key = keys.parse(key); - } - - if (values) { - val = values.parse(val); - } - - return [key, val]; - }); - - return entries; - } - - /** @type {string[]} */ - static nestedSpecKeys = ["keys", "values"]; -} - -/** - * @typedef {import("./PropType.js").SpecifiedType} SpecifiedType - * @typedef {import("./PropType.js").PropTypeSpec} PropTypeSpec - */ - -/** - * @typedef {PropTypeSpec & { - * keys?: SpecifiedType, - * values?: SpecifiedType, - * separator?: string, - * defaultKey?: ((key: unknown, value: unknown, index: number) => unknown) | unknown, - * defaultValue?: ((key: unknown, value: unknown, index: number) => unknown) | unknown, - * pairs?: object, - * }} DictionaryTypeSpec - */ diff --git a/src/plugins/props/util/ListType.js b/src/plugins/props/util/ListType.js deleted file mode 100644 index 01291ba3..00000000 --- a/src/plugins/props/util/ListType.js +++ /dev/null @@ -1,68 +0,0 @@ -import PropType from "./PropType.js"; -import { split } from "../types/util.js"; - -/** - * Shared behavior for list-like types (Array, Set): provides splitting and - * joining helpers that delegate to `this.values` (the resolved nested type) - * and read formatting options (`separator`, `joiner`, `pairs`) off `this`. - * - * @extends {PropType} - */ -export default class ListType extends PropType { - /** - * Split a raw input into items, parsing each through {@link values}. - * @param {string | unknown[] | unknown} value - * @returns {unknown[]} - */ - parseItems (value) { - if (typeof value === "string") { - value = split(value, this.spec); - } - else { - value = Array.isArray(value) ? value : [value]; - } - - let { values } = this; - if (values) { - value = value.map(item => values.parse(item)); - } - - return value; - } - - /** - * Stringify list items via {@link values}, joined by `spec.joiner` or - * `spec.separator` if no joiner is supplied. Whitespace is *not* added - * automatically — consumers who want spaces include them in the joiner - * (or separator) themselves. - * @param {unknown[]} items - * @returns {string} - */ - joinItems (items) { - let { values } = this; - let { separator = ", ", joiner = separator } = this.spec; - - if (values) { - items = items.map(item => values.stringify(item)); - } - - return items.join(joiner); - } - - /** @type {string[]} */ - static nestedSpecKeys = ["values"]; -} - -/** - * @typedef {import("./PropType.js").SpecifiedType} SpecifiedType - * @typedef {import("./PropType.js").PropTypeSpec} PropTypeSpec - */ - -/** - * @typedef {PropTypeSpec & { - * values?: SpecifiedType, - * separator?: string, - * joiner?: string, - * pairs?: object, - * }} ListTypeSpec - */ diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 6ec3a035..0a81372a 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -9,18 +9,30 @@ const callableBuiltins = new Set([ BigInt, ]); +/** + * Standard methods with dedicated dispatchers on the prototype. Any other + * function found in a spec is auto-wrapped into a generic super-walk dispatcher + * at construction time, so abstract types can publish helpers (e.g. `items`, + * `entries`) that descendants invoke as plain `this.x(…)` calls. + */ +const standardMethods = new Set(["equals", "parse", "stringify"]); + /** * Type adapter for prop values: defines equality, parsing from raw input * (typically attribute strings), and stringification back to attributes. * * A `PropType` instance is an *abstract*, prop-agnostic type definition. - * Built-in types are registered as singletons keyed on their JS constructor - * in {@link PropType.registry}. {@link PropType.for} dispatches lookups and - * delegates derivative construction to the constructor. + * The {@link registry} holds every registered type — concrete types are keyed + * on their JS constructor (`is`), abstract types on their string `name`. + * {@link PropType.for} dispatches lookups and delegates derivative + * construction to the constructor. * * Derivatives are created via `Object.create(parent)` so every property * lookup (options + the {@link spec} method-override slot) walks the JS - * prototype chain naturally — no merging, no copies. + * prototype chain naturally — no merging, no copies. The parent is picked + * from `spec.extends` if present, otherwise from the registry entry for + * `spec.is`, allowing the chain parent to differ from the produced JS type + * (e.g. `{is: Array, extends: IterableType}`). * * Type-specific `equals` / `parse` / `stringify` supplied at registration * are stored under `instance.spec`. The prototype methods on this class @@ -40,10 +52,10 @@ export default class PropType { spec; /** - * The next instance up the type chain — a registered singleton for - * derivatives, or the class prototype for roots. Used by the dispatchers - * to walk for inherited method overrides; subclasses and consumers can - * walk it to inspect lineage. + * The next instance up the type chain — the parent picked from + * `spec.extends` or `registry.get(spec.is)`, or the class prototype + * for roots. Used by the dispatchers to walk for inherited method + * overrides; subclasses and consumers can walk it to inspect lineage. * @type {PropType | object | undefined} */ super; @@ -58,40 +70,61 @@ export default class PropType { spec = { is: spec }; } - let { is, ...extras } = spec; + let { is, extends: parentSpec, name, ...extras } = spec; - if (!is) { + if (!is && !parentSpec && !name) { return this.constructor.any; } - is = PropType.normalizeIs(is); - let parent = PropType.registry.get(is); + let normalizedIs = is ? PropType.normalizeIs(is) : undefined; + let parent = parentSpec + ? PropType.for(parentSpec) + : (normalizedIs ? PropType.registry.get(normalizedIs) : undefined); - // Pure lookup: `{is: Array}` with no extras returns the registered singleton. - if (parent && Object.keys(extras).length === 0) { + // Pure lookup: a bare `{is: X}` or `{extends: Y}` (no other keys) is + // just a request for the already-registered singleton. + let hasExtras = name !== undefined || Object.keys(extras).length > 0; + if (parent && !hasExtras && !(normalizedIs && parentSpec)) { return parent; } let instance = parent ? Object.create(parent) : this; - if (parent) { - instance.super = parent; - } - else { - instance.super = this.constructor.prototype; - instance.is = is; + instance.super = parent ?? this.constructor.prototype; + if (normalizedIs) { + instance.is = normalizedIs; } - instance.spec = spec; - // Resolve nested type specs and store them as own properties so - // type-specific code reads them as `this.values` / `this.keys` (the - // resolved PropType instances). Every other spec field — methods, - // separators, defaults, etc. — stays in `spec` and is read via - // `this.spec.X` by type-specific code and the dispatchers. - for (let key of instance.constructor.nestedSpecKeys ?? []) { + // Resolve sub-types and store them as own properties so type-specific + // code reads them as `this.values` / `this.keys` (the resolved + // PropType instances). The `subTypes` list itself is promoted to an + // own property when the spec declares it, so descendants inherit it + // via the JS prototype chain — and a child that declares its own + // `subTypes` replaces the inherited list outright. Unspecified + // sub-types default to {@link PropType.any}, set at the root + // abstract and inherited the same way, so consumers can write + // `this.values.parse(v)` unconditionally. + if (spec.subTypes) { + instance.subTypes = spec.subTypes; + } + for (let key of instance.subTypes ?? []) { if (spec[key] !== undefined) { instance[key] = PropType.for(spec[key]); } + else if (!(key in instance)) { + instance[key] = PropType.any; + } + } + + // Auto-wrap non-standard spec methods (e.g. `items`, `entries`) into + // generic super-walk dispatchers, so callers invoke them as plain + // `this.x(…)` — same shape as the standard methods, no class needed. + for (let key in spec) { + if (typeof spec[key] === "function" && !standardMethods.has(key) && !(key in instance)) { + instance[key] = function (...args) { + return this.dispatch(key, ...args); + }; + } } return instance; @@ -162,34 +195,66 @@ export default class PropType { return String(value); } - /** @type {Map} */ - static registry = new Map(); + /** + * Walk the super chain looking for the named method in each `spec`, and + * invoke the first one found with `this` bound to the original receiver. + * Backs the auto-wrapped helper dispatchers; consumers normally call the + * generated wrappers (`this.items(…)`) rather than this directly. + * @param {string} method + * @param {...unknown} args + * @returns {unknown} + */ + dispatch (method, ...args) { + for (let obj = this; obj; obj = obj.super) { + if (obj.spec?.[method]) { + return obj.spec[method].apply(this, args); + } + } + } /** - * Option keys whose values are themselves type specs and should be - * recursively resolved via {@link PropType.for}. Subclasses override - * (e.g. `IterableType` → `["values"]`). - * @type {string[]} + * Is this type a kind of `other` — i.e. is `other` somewhere in the + * super chain (or is it `this` itself)? Replaces `instanceof` checks + * that no longer apply now that abstract types are PropType instances + * rather than JS classes. + * @param {PropType} other + * @returns {boolean} */ - static nestedSpecKeys = []; + isA (other) { + for (let obj = this; obj; obj = obj.super) { + if (obj === other) { + return true; + } + } + return false; + } + + get [Symbol.toStringTag] () { + return (this.spec?.name ?? this.is?.name ?? "Prop") + "Type"; + } + + /** @type {Map} */ + static registry = new Map(); /** * Shared fallback returned by {@link PropType.for} when no type is - * specified or matched. Subclasses inherit via the class chain. + * specified or matched. * @type {PropType} */ static any = new PropType(); /** - * Register a type: constructs an instance (via `new this(spec)`) and - * stores it in {@link PropType.registry} keyed on `spec.is`. Returns - * the registered instance. + * Register a type: constructs an instance and stores it in + * {@link PropType.registry} keyed on `spec.is` (constructor) or + * `spec.name` (for abstract types with no `is`). Returns the + * registered instance. * @param {PropTypeSpec} spec * @returns {PropType} */ static register (spec) { let instance = new this(spec); - this.registry.set(instance.is, instance); + let key = instance.is ?? spec.name; + this.registry.set(key, instance); return instance; } @@ -197,9 +262,10 @@ export default class PropType { * Resolve any user-facing type identifier into a {@link PropType}. * * - PropType instance → returned as-is. - * - constructor / string → registry lookup (string resolved via `globalThis`). - * - object spec → constructs a derivative if it carries extras beyond `is`, - * otherwise returns the registered singleton. + * - constructor / string → registry lookup (string resolved via + * `globalThis` first, then by name). + * - object spec → constructor short-circuits to the singleton if no + * extras, otherwise builds a derivative. * - null / undefined → `options.fallback` (default: the generic instance). * * @param {SpecifiedType} input @@ -216,31 +282,41 @@ export default class PropType { } if (typeof input === "object") { - let { is, ...extras } = input; - if (Object.keys(extras).length > 0) { - return new this(input); - } - input = is; + return new this(input); } return this.registry.get(PropType.normalizeIs(input)) ?? fallback; } /** - * Resolve an `is` identifier to its JS constructor. Strings are looked - * up in `globalThis`; everything else passes through. The single source - * of string-to-constructor resolution in the class. + * Resolve an `is` identifier to its registry key. Strings are first tried + * as `globalThis` lookups (catches built-in constructors like `"Array"`); + * anything else (including named-only abstracts like `"Iterable"`) passes + * through as the bare string. * @param {Function | string | undefined} is - * @returns {Function | undefined} + * @returns {Function | string | undefined} */ static normalizeIs (is) { - return typeof is === "string" ? (globalThis[is] ?? is) : is; + if (typeof is !== "string") { + return is; + } + let resolved = globalThis[is]; + return typeof resolved === "function" ? resolved : is; } } /** * @typedef {object} PropTypeSpec * @property {Function | string} [is] JS constructor (or its global name). + * @property {PropType | string} [extends] Explicit parent in the chain — used + * when the parent differs from `registry.get(is)` (e.g. concrete types + * extending an abstract). + * @property {string} [name] Registry key for abstract types with no `is`. + * @property {string[]} [subTypes] Spec keys whose values are themselves type + * specs and should be resolved to PropType instances at construction time. + * Inherited from the nearest ancestor that declares it — the child's list + * replaces (not extends) the parent's. Unspecified keys default to + * {@link PropType.any}. * @property {(this: PropType, a: unknown, b: unknown) => boolean} [equals] * @property {(this: PropType, value: unknown) => unknown} [parse] * @property {(this: PropType, value: unknown) => (string | null)} [stringify] diff --git a/src/plugins/props/util/split.js b/src/plugins/props/util/split.js new file mode 100644 index 00000000..df0c0a5d --- /dev/null +++ b/src/plugins/props/util/split.js @@ -0,0 +1,103 @@ +export const defaultPairs = { + nest: { + "(": ")", + "[": "]", + "{": "}", + }, + ignore: { + '"': '"', + // "'": "'", + // "`": "`", + }, +}; + +function regexEscape (string) { + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +} + +/** + * Split a value by a separator, respecting pairs (parens, strings, etc.) but + * failing back gracefully for malformed input. Yields each top-level part as + * a trimmed string. + * + * @param {string} value + * @param {object} [options] + * @param {string} [options.separator] + * @param {object} [options.pairs] + * @returns {Generator} + */ +export function* split (value, { separator = ",", pairs = defaultPairs } = {}) { + value = value.trim(); + + // Make whitespace optional and flexible, unless the separator consists entirely of whitespace + separator = separator.trim(); + let isSeparatorWhitespace = !separator; + let separatorRegex = isSeparatorWhitespace + ? /\s+/g + : RegExp(regexEscape(separator).replace(/^\s*|\s*$/g, "\\s*"), "g"); + + let pairStrings = new Set([ + ...Object.keys(pairs.nest), + ...Object.values(pairs.nest), + ...Object.keys(pairs.ignore), + ...Object.values(pairs.ignore), + ]); + let pairRegex = RegExp([...pairStrings].map(regexEscape).join("|"), "g"); + + if (!pairRegex.test(value)) { + // value contains no pairs, just split + yield* value.trim().split(separatorRegex); + return; + } + + let invertedNestPairs = Object.fromEntries( + Object.entries(pairs.nest).map(([start, end]) => [end, start]), + ); + let splitter = RegExp([separatorRegex.source, pairRegex.source].join("|"), "g"); + let stack = []; + let matches = [...value.matchAll(splitter)]; + let lastIndex = 0; + let ignoreUntil; + + for (let i = 0; i < matches.length; i++) { + let match = matches[i]; + let index = match.index; + let matched = match[0]; + + if (ignoreUntil) { + if (ignoreUntil === matched) { + // TODO escape? + ignoreUntil = null; + } + } + else if (matched.trim() === separator) { + if (stack.length === 0) { + yield value.slice(lastIndex, index).trim(); + lastIndex = index + matched.length; + } + } + else if (pairs.ignore[matched]) { + let closingPair = pairs.ignore[matched]; + if (matches.slice(i + 1).find(m => m[0] === closingPair)) { + ignoreUntil = closingPair; + } + } + else if (pairs.nest[matched]) { + let closingPair = pairs.nest[matched]; + if (matches.slice(i + 1).find(m => m[0] === closingPair)) { + stack.push(matched); + } + } + else if (invertedNestPairs[matched]) { + // Why not just check and pop? We want malformed (interleaved) pairs to work too + let startIndex = stack.findLastIndex(start => start === invertedNestPairs[matched]); + if (startIndex > -1) { + stack.splice(startIndex, 1); + } + } + } + + if (lastIndex < value.length) { + yield value.slice(lastIndex).trim(); + } +} diff --git a/src/plugins/types.js b/src/plugins/types.js index 3c153bcd..ce2525c0 100644 --- a/src/plugins/types.js +++ b/src/plugins/types.js @@ -5,5 +5,7 @@ * `import * as Types from "nude-element/plugins/types"; Types.Array, Types.Map, ...` */ -export { ArrayType as Array, SetType as Set } from "./props/types/lists.js"; -export { ObjectType as Object, MapType as Map } from "./props/types/dictionaries.js"; +export { default as Array } from "./props/types/array.js"; +export { default as Set } from "./props/types/set.js"; +export { default as Object } from "./props/types/object.js"; +export { default as Map } from "./props/types/map.js"; diff --git a/test/PropType.js b/test/PropType.js index 1a34766c..42e584f4 100644 --- a/test/PropType.js +++ b/test/PropType.js @@ -1,10 +1,11 @@ import PropType from "../src/plugins/props/util/PropType.js"; -import ListType from "../src/plugins/props/util/ListType.js"; -import DictionaryType from "../src/plugins/props/util/DictionaryType.js"; +import IterableType from "../src/plugins/props/types/iterable.js"; // Side-effect imports register the built-in types. import "../src/plugins/props/types/index.js"; -import { ArrayType, SetType } from "../src/plugins/props/types/lists.js"; -import { ObjectType, MapType } from "../src/plugins/props/types/dictionaries.js"; +import ArrayType from "../src/plugins/props/types/array.js"; +import SetType from "../src/plugins/props/types/set.js"; +import ObjectType from "../src/plugins/props/types/object.js"; +import MapType from "../src/plugins/props/types/map.js"; const NumberType = PropType.for(Number); const StringType = PropType.for(String); @@ -88,8 +89,8 @@ export default { expect: true, }, { - name: "Derivative is an instance of its abstract base class", - run: () => PropType.for({ is: Array, values: Number }) instanceof ListType, + name: "Derivative inherits from its abstract base type", + run: () => PropType.for({ is: Array, values: Number }).isA(IterableType), expect: true, }, { @@ -338,7 +339,7 @@ export default { is: Array, values: { is: Array, values: Number }, }); - return t.values instanceof ListType + return t.values.isA(IterableType) && t.values.is === Array && t.values.values === NumberType; }, @@ -351,7 +352,7 @@ export default { is: Array, values: { is: Set, values: Number }, }); - return t.values instanceof ListType + return t.values.isA(IterableType) && t.values.is === Set; }, expect: true, @@ -441,22 +442,22 @@ export default { expect: true, }, { - name: "ListType.register produces a ListType", + name: "register with extends: IterableType produces an Iterable derivative", run () { class FooList {} - let t = ListType.register({ is: FooList }); - let result = t instanceof ListType; + let t = PropType.register({ is: FooList, extends: IterableType }); + let result = t.isA(IterableType); PropType.registry.delete(FooList); return result; }, expect: true, }, { - name: "DictionaryType.register produces a DictionaryType", + name: "register with extends: MapType produces a Map derivative", run () { class FooDict {} - let t = DictionaryType.register({ is: FooDict }); - let result = t instanceof DictionaryType; + let t = PropType.register({ is: FooDict, extends: MapType }); + let result = t.isA(MapType); PropType.registry.delete(FooDict); return result; }, diff --git a/test/split.js b/test/split.js index 6d0e22e0..7c9eeb9f 100644 --- a/test/split.js +++ b/test/split.js @@ -1,8 +1,8 @@ -import { split } from "../src/plugins/props/types/util.js"; +import { split } from "../src/plugins/props/util/split.js"; export default { name: "split()", - run: split, + run: (...args) => [...split(...args)], tests: [ { name: "Basic", From b584c4d870aa1ddd7a4195226cd92453dde59352 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 13:01:36 -0400 Subject: [PATCH 04/27] Update Custom types section + add types/README reference Refresh the props README's Custom types walkthrough for the new single-class architecture and add a dedicated types/README covering the spec-key catalog, abstract helper methods (parseItems, parseEntries), public API, and a parametrized custom-type example (Length). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/README.md | 10 ++-- src/plugins/props/types/README.md | 81 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 src/plugins/props/types/README.md diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index bf4d8337..63c8eddb 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -155,7 +155,7 @@ It can be either a constant (e.g. `true`) or a function, in which case it’s pa #### Custom types -Types are *instances* of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`) and any type options — and supplies `equals` / `parse` / `stringify` as overrides on the spec. `IterableType` is an abstract `PropType` instance registered by `name`; concrete types like `Array`, `Set`, and `Map` declare `extends: IterableType` to inherit its parsing behavior via the JS prototype chain. `Object` further extends `Map` since it's a constrained dictionary realization. A type is an abstract, prop-agnostic definition; the same instance is shared across every prop that references it. +Types are *instances* of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any type options, and any `equals` / `parse` / `stringify` overrides — and is shared across every prop that references it. An abstract type (`IterableType`) is a `PropType` instance registered by `name` rather than `is`; concrete types declare `extends: ` to inherit its parsing behavior via the JS prototype chain (e.g. `Array`, `Set`, and `Map` all extend `IterableType`, and `Object` extends `Map`). For most custom types, the simplest path is to register a spec object directly. Methods read options from `this.spec`: @@ -170,7 +170,7 @@ PropType.register({ }); ``` -For types that need richer shared behavior (e.g. resolving nested type specs, or reusing parsing logic), `extends` an existing abstract or concrete — `IterableType` for any iterable, `MapType` for dictionary-shaped types: +For types that reuse the iterable / dictionary parsing infrastructure, `extends` an existing abstract — `IterableType` for any iterable, `MapType` for any key→value mapping. Inside method overrides, `this.values` (and `this.keys` for dictionaries) is the resolved nested type — defaulting to `PropType.any`, so you can call `this.values.parse(v)` unconditionally. ```js import { PropType, IterableType } from "nude-element/plugins"; @@ -178,15 +178,13 @@ import { PropType, IterableType } from "nude-element/plugins"; PropType.register({ is: Tuple, extends: IterableType, - // override / extend as needed; `this.spec` holds the spec, - // `this.values` is the resolved nested type parse (value) { return new Tuple(...IterableType.spec.parse.call(this, value)); }, }); ``` -**Derivative types.** A type spec with options beyond `is` produces a *derivative* type — a new `PropType` instance whose prototype is the registered singleton for that `is` (or the abstract named via `extends`). Lookups for unspecified options fall through to the parent via the JS prototype chain. +**Derivative types.** A type spec with options beyond `is` produces a *derivative* — a new `PropType` instance whose prototype chain points to the registered singleton for that `is` (or the abstract named via `extends`). Lookups for unspecified options fall through to the parent via the JS prototype chain. ```js import { PropType } from "nude-element/plugins"; @@ -209,6 +207,8 @@ Types.Array; // the ArrayType singleton Types.Map; // the MapType singleton ``` +For the full spec-key reference, the abstract-type helper methods (`parseItems`, `parseEntries`), and the public API surface, see [`types/README.md`](./types/README.md). + ### Attribute-property reflection The `reflect` property takes the following values: diff --git a/src/plugins/props/types/README.md b/src/plugins/props/types/README.md new file mode 100644 index 00000000..72d52ca6 --- /dev/null +++ b/src/plugins/props/types/README.md @@ -0,0 +1,81 @@ +# PropTypes — reference + +The high-level "what's a PropType / how do I register one" walkthrough lives in [the props README](../README.md#custom-types). This document is the reference: built-in types, spec-key catalog, abstract internals, and the public API surface. + +## Built-in types + +| Type | Spec keys (besides `is`) | Notes | +| ---------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `Boolean` | — | Presence-based: `null` → `null`, any non-null → `true`. | +| `Number` | — | Parses via `Number(value)`. `equals` treats `NaN` as equal to `NaN`. | +| `Function` | `arguments` | Parses to a `Function` constructed from the string body. Stringify throws. | +| `Array` | `values`, `separator`, `joiner`, `pairs` | Splits on `,` (pair-aware: parens, brackets, braces, quotes). | +| `Set` | `values`, `separator`, `joiner`, `pairs` | Same parsing as `Array`, materialized into a `Set`. | +| `Map` | `keys`, `values`, `separator`, `defaultKey`, `defaultValue`, `pairs` | Splits entries on `,` then each on `:`. | +| `Object` | same as `Map` | Same parsing pipeline, materialized into a plain object. | + +All built-ins are exported as singletons under their JS constructor names from `nude-element/plugins/types` (see [props README](../README.md#custom-types) for usage). + +## Spec keys + +| Key | Role | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `is` | JS constructor this type produces. Doubles as registry key. Optional for abstracts. | +| `extends` | Explicit chain parent (a `PropType` instance, or a `name` string). Lets the parent differ from `registry.get(is)` — that's what decouples "what JS constructor does this produce" from "what behavior does this share." | +| `name` | Registry key for abstracts that have no `is`. | +| `subTypes` | Spec keys whose values are themselves type specs (`["values"]` for Iterable, `["keys", "values"]` for Map). Resolved to `PropType` instances at construction; unspecified ones default to `PropType.any`. Declared by the abstract; descendants inherit it via the prototype chain, and a descendant that redeclares it replaces (not extends) the inherited list. | +| `equals(a, b)` | Equality. Default short-circuits null and identity, then walks the chain. | +| `parse(value)` | Parse a raw input. Default passes `null` through and walks the chain. | +| `stringify(value)` | Stringify (returns `null` for null/undefined to signal attribute removal). | +| any other method | Auto-wrapped at construction into a super-walking dispatcher, callable as `this.x(…)` from anywhere in the chain. | + +## Abstract helpers + +| Method | Yields | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `IterableType.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | +| `MapType.parseEntries` | Raw `[key, value]` tuples: built on `parseItems`, with `:`-splitting (escaped `\:` preserved) and shorthand-entry handling via `defaultKey` / `defaultValue` / `"false"`-coercion. | + +Both are generators. Concrete types consume them with the appropriate terminal container constructor (`Array.from`, `new Set`, `new Map`, `Object.fromEntries`) so each input value flows through the chain exactly once — no intermediate arrays. + +To call a parent's method from inside an override, use `ParentType.spec.method.call(this, …args)`. Doing it via `this.super.method(…)` would re-bind `this` to the parent and lose access to your derivative's `this.values` / `this.spec.separator`. + +## A parametrized custom type + +For something more involved than the simple [`Color` example](../README.md#custom-types), here's a `Length` type that accepts a `unit` option, demonstrating how a single registration becomes the basis for many derivatives: + +```js +import { PropType } from "nude-element/plugins"; + +PropType.register({ + is: Length, + parse (value) { + let unit = this.spec.unit ?? "px"; + return value instanceof Length ? value : new Length(value, unit); + }, + stringify: value => value?.toString(), +}); + +const Pixels = PropType.for({ is: Length, unit: "px" }); +const Rems = PropType.for({ is: Length, unit: "rem" }); + +static props = { + width: { type: Pixels }, + margin: { type: Rems }, +}; +``` + +`Pixels` and `Rems` are distinct PropType instances sharing the same `parse` (inherited via the prototype chain from the registered `Length` singleton), but each reads its own `this.spec.unit`. + +## Public API + +| Method | Purpose | +| ------------------------------------- | --------------------------------------------------------------------------------------- | +| `PropType.for(input, { fallback })` | Universal resolver: `PropType` instance, constructor, string, or spec object → `PropType`. | +| `PropType.register(spec)` | Register a built-in or custom type. Returns the registered instance. | +| `instance.isA(otherType)` | Walk the chain looking for `otherType`. Replaces `instanceof` for abstract-type checks. | +| `PropType.any` | The generic fallback `PropType`. Used as the default for unspecified sub-types. | + +## Architecture in one paragraph + +`PropType` is the sole class. Built-in types are registered singletons stored in a single map keyed on JS constructor (for concretes) or string `name` (for abstracts). Derivatives are `Object.create(parent)`, so option lookup walks the JS prototype chain — no spec merging, no copies. The dispatcher walks `obj.super` for each method (`equals` / `parse` / `stringify` plus any custom helpers), invoking the first `spec[method]` it finds with `this` bound to the original caller. Abstracts publish their helpers (`parseItems`, `parseEntries`) the same way, so descendants invoke them as plain `this.x(…)`. From e19f344bc8a5d49ca88d86825256cd8335bc3dcc Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 13:09:33 -0400 Subject: [PATCH 05/27] Rename IterableType to Iterable The `Type` suffix was a class-era artifact; now that abstracts are PropType instances, "Iterable" is the natural name and matches the user-facing `extends: Iterable` keyword. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/README.md | 10 +++++----- src/plugins/props/types/README.md | 2 +- src/plugins/props/types/array.js | 6 +++--- src/plugins/props/types/index.js | 2 +- src/plugins/props/types/iterable.js | 8 ++++---- src/plugins/props/types/map.js | 6 +++--- src/plugins/props/types/set.js | 6 +++--- src/plugins/props/util/PropType.js | 2 +- test/PropType.js | 14 +++++++------- 9 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index 63c8eddb..51dcf625 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -155,7 +155,7 @@ It can be either a constant (e.g. `true`) or a function, in which case it’s pa #### Custom types -Types are *instances* of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any type options, and any `equals` / `parse` / `stringify` overrides — and is shared across every prop that references it. An abstract type (`IterableType`) is a `PropType` instance registered by `name` rather than `is`; concrete types declare `extends: ` to inherit its parsing behavior via the JS prototype chain (e.g. `Array`, `Set`, and `Map` all extend `IterableType`, and `Object` extends `Map`). +Types are *instances* of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any type options, and any `equals` / `parse` / `stringify` overrides — and is shared across every prop that references it. An abstract type (`Iterable`) is a `PropType` instance registered by `name` rather than `is`; concrete types declare `extends: ` to inherit its parsing behavior via the JS prototype chain (e.g. `Array`, `Set`, and `Map` all extend `Iterable`, and `Object` extends `Map`). For most custom types, the simplest path is to register a spec object directly. Methods read options from `this.spec`: @@ -170,16 +170,16 @@ PropType.register({ }); ``` -For types that reuse the iterable / dictionary parsing infrastructure, `extends` an existing abstract — `IterableType` for any iterable, `MapType` for any key→value mapping. Inside method overrides, `this.values` (and `this.keys` for dictionaries) is the resolved nested type — defaulting to `PropType.any`, so you can call `this.values.parse(v)` unconditionally. +For types that reuse the iterable / dictionary parsing infrastructure, `extends` an existing abstract — `Iterable` for any iterable, `MapType` for any key→value mapping. Inside method overrides, `this.values` (and `this.keys` for dictionaries) is the resolved nested type — defaulting to `PropType.any`, so you can call `this.values.parse(v)` unconditionally. ```js -import { PropType, IterableType } from "nude-element/plugins"; +import { PropType, Iterable } from "nude-element/plugins"; PropType.register({ is: Tuple, - extends: IterableType, + extends: Iterable, parse (value) { - return new Tuple(...IterableType.spec.parse.call(this, value)); + return new Tuple(...Iterable.spec.parse.call(this, value)); }, }); ``` diff --git a/src/plugins/props/types/README.md b/src/plugins/props/types/README.md index 72d52ca6..c597684d 100644 --- a/src/plugins/props/types/README.md +++ b/src/plugins/props/types/README.md @@ -33,7 +33,7 @@ All built-ins are exported as singletons under their JS constructor names from ` | Method | Yields | | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `IterableType.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | +| `Iterable.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | | `MapType.parseEntries` | Raw `[key, value]` tuples: built on `parseItems`, with `:`-splitting (escaped `\:` preserved) and shorthand-entry handling via `defaultKey` / `defaultValue` / `"false"`-coercion. | Both are generators. Concrete types consume them with the appropriate terminal container constructor (`Array.from`, `new Set`, `new Map`, `Object.fromEntries`) so each input value flows through the chain exactly once — no intermediate arrays. diff --git a/src/plugins/props/types/array.js b/src/plugins/props/types/array.js index 68bb6013..4a629800 100644 --- a/src/plugins/props/types/array.js +++ b/src/plugins/props/types/array.js @@ -1,10 +1,10 @@ import PropType from "../util/PropType.js"; -import IterableType from "./iterable.js"; +import Iterable from "./iterable.js"; export default PropType.register({ is: Array, - extends: IterableType, + extends: Iterable, parse (value) { - return [...IterableType.spec.parse.call(this, value)]; + return [...Iterable.spec.parse.call(this, value)]; }, }); diff --git a/src/plugins/props/types/index.js b/src/plugins/props/types/index.js index ae8b8842..b0f9e865 100644 --- a/src/plugins/props/types/index.js +++ b/src/plugins/props/types/index.js @@ -9,4 +9,4 @@ import "./map.js"; import "./object.js"; export { default as PropType } from "../util/PropType.js"; -export { default as IterableType } from "./iterable.js"; +export { default as Iterable } from "./iterable.js"; diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js index db6632bb..b1d840c4 100644 --- a/src/plugins/props/types/iterable.js +++ b/src/plugins/props/types/iterable.js @@ -5,7 +5,7 @@ import { split } from "../util/split.js"; * Abstract type for any iterable. The parsing pipeline is two streaming * generators: {@link parseItems} yields raw items (no type parsing), and * {@link parse} yields each item through `this.values`. Concrete types - * (Array, Set, …) `extends: IterableType` and pipe the {@link parse} + * (Array, Set, …) `extends: Iterable` and pipe the {@link parse} * iterator straight into their own container — every input flows through * the chain exactly once, no intermediate arrays. * @@ -13,7 +13,7 @@ import { split } from "../util/split.js"; * parts, then splits each on `:` in its own `parseEntries` to yield * `[key, value]` tuples. Distinct names because the return types differ. */ -const IterableType = PropType.register({ +const Iterable = PropType.register({ name: "Iterable", subTypes: ["values"], @@ -95,7 +95,7 @@ const IterableType = PropType.register({ }, }); -export default IterableType; +export default Iterable; /** * @typedef {import("../util/PropType.js").SpecifiedType} SpecifiedType @@ -108,5 +108,5 @@ export default IterableType; * separator?: string, * joiner?: string, * pairs?: object, - * }} IterableTypeSpec + * }} IterableSpec */ diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js index e2b3f3e0..53c8d76c 100644 --- a/src/plugins/props/types/map.js +++ b/src/plugins/props/types/map.js @@ -1,11 +1,11 @@ import PropType from "../util/PropType.js"; -import IterableType from "./iterable.js"; +import Iterable from "./iterable.js"; const entrySplitter = /(? PropType.for({ is: Array, values: Number }).isA(IterableType), + run: () => PropType.for({ is: Array, values: Number }).isA(Iterable), expect: true, }, { @@ -339,7 +339,7 @@ export default { is: Array, values: { is: Array, values: Number }, }); - return t.values.isA(IterableType) + return t.values.isA(Iterable) && t.values.is === Array && t.values.values === NumberType; }, @@ -352,7 +352,7 @@ export default { is: Array, values: { is: Set, values: Number }, }); - return t.values.isA(IterableType) + return t.values.isA(Iterable) && t.values.is === Set; }, expect: true, @@ -442,11 +442,11 @@ export default { expect: true, }, { - name: "register with extends: IterableType produces an Iterable derivative", + name: "register with extends: Iterable produces an Iterable derivative", run () { class FooList {} - let t = PropType.register({ is: FooList, extends: IterableType }); - let result = t.isA(IterableType); + let t = PropType.register({ is: FooList, extends: Iterable }); + let result = t.isA(Iterable); PropType.registry.delete(FooList); return result; }, From b1962d5006a156ae13370c8aca70acf054acbe96 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 13:24:25 -0400 Subject: [PATCH 06/27] Prettier --- src/plugins/props/util/PropType.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 6dedbd00..39c6f96e 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -79,7 +79,9 @@ export default class PropType { let normalizedIs = is ? PropType.normalizeIs(is) : undefined; let parent = parentSpec ? PropType.for(parentSpec) - : (normalizedIs ? PropType.registry.get(normalizedIs) : undefined); + : normalizedIs + ? PropType.registry.get(normalizedIs) + : undefined; // Pure lookup: a bare `{is: X}` or `{extends: Y}` (no other keys) is // just a request for the already-registered singleton. @@ -120,7 +122,11 @@ export default class PropType { // generic super-walk dispatchers, so callers invoke them as plain // `this.x(…)` — same shape as the standard methods, no class needed. for (let key in spec) { - if (typeof spec[key] === "function" && !standardMethods.has(key) && !(key in instance)) { + if ( + typeof spec[key] === "function" && + !standardMethods.has(key) && + !(key in instance) + ) { instance[key] = function (...args) { return this.dispatch(key, ...args); }; From a5350881a6f1193bc1f665b46e71ed027a8e2123 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 13:24:44 -0400 Subject: [PATCH 07/27] Revert empty checks to previous logic --- src/plugins/props/util/PropType.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 39c6f96e..6093fbfc 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -142,7 +142,7 @@ export default class PropType { * @returns {boolean} */ equals (a, b) { - if (a == null || b == null) { + if (a === null || b === null || a === undefined || b === undefined) { return a === b; } @@ -164,7 +164,7 @@ export default class PropType { * @returns {unknown} */ parse (value) { - if (value == null) { + if (value === null || value === undefined) { return value; } @@ -188,7 +188,7 @@ export default class PropType { * @returns {string | null} */ stringify (value) { - if (value == null) { + if (value === null || value === undefined) { return null; } From ced4d5aa8f3876a9f9fa33c6351d0528577ceab5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 14:02:54 -0400 Subject: [PATCH 08/27] Rewrite PropType.js Because Claude f*ed up. --- src/plugins/props/util/PropType.js | 134 ++++++++++++++--------------- 1 file changed, 64 insertions(+), 70 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 6093fbfc..94f02836 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -58,7 +58,16 @@ export default class PropType { * overrides; subclasses and consumers can walk it to inspect lineage. * @type {PropType | object | undefined} */ - super; + super = this.constructor.prototype; + + /** + * Resolve sub-types and store them as own properties so type-specific + * code reads them as `this.values` / `this.keys` (the resolved PropType instances). + * Unspecified sub-types default to {@link PropType.any} + * so consumers can use type methods unconditionally. + * @type {Array | undefined} + */ + subTypes; /** @param {TSpec} [spec] */ constructor (spec) { @@ -76,64 +85,60 @@ export default class PropType { return this.constructor.any; } - let normalizedIs = is ? PropType.normalizeIs(is) : undefined; + is = is ? PropType.normalizeIs(is) : undefined; let parent = parentSpec ? PropType.for(parentSpec) - : normalizedIs - ? PropType.registry.get(normalizedIs) + : is + ? PropType.registry.get(is) : undefined; // Pure lookup: a bare `{is: X}` or `{extends: Y}` (no other keys) is // just a request for the already-registered singleton. let hasExtras = name !== undefined || Object.keys(extras).length > 0; - if (parent && !hasExtras && !(normalizedIs && parentSpec)) { + if (parent && !hasExtras && !(is && parentSpec)) { return parent; } - let instance = parent ? Object.create(parent) : this; - instance.super = parent ?? this.constructor.prototype; - if (normalizedIs) { - instance.is = normalizedIs; + if (parent) { + return parent.from(spec); } + + this.spec = spec; + this.init(); + } + + from (spec) { + let instance = Object.create(this); + instance.super = this; instance.spec = spec; + instance.init(); + return instance; + } - // Resolve sub-types and store them as own properties so type-specific - // code reads them as `this.values` / `this.keys` (the resolved - // PropType instances). The `subTypes` list itself is promoted to an - // own property when the spec declares it, so descendants inherit it - // via the JS prototype chain — and a child that declares its own - // `subTypes` replaces the inherited list outright. Unspecified - // sub-types default to {@link PropType.any}, set at the root - // abstract and inherited the same way, so consumers can write - // `this.values.parse(v)` unconditionally. - if (spec.subTypes) { - instance.subTypes = spec.subTypes; - } - for (let key of instance.subTypes ?? []) { - if (spec[key] !== undefined) { - instance[key] = PropType.for(spec[key]); + init () { + let { spec } = this; + + for (let key in spec) { + if (key in this.constructor.prototype) { + this["spec_" + key] = spec[key]; } - else if (!(key in instance)) { - instance[key] = PropType.any; + else { + this[key] = spec[key]; } } - // Auto-wrap non-standard spec methods (e.g. `items`, `entries`) into - // generic super-walk dispatchers, so callers invoke them as plain - // `this.x(…)` — same shape as the standard methods, no class needed. - for (let key in spec) { - if ( - typeof spec[key] === "function" && - !standardMethods.has(key) && - !(key in instance) - ) { - instance[key] = function (...args) { - return this.dispatch(key, ...args); - }; - } + if (Object.hasOwn(spec, "is")) { + this.is = this.constructor.normalizeIs(spec.is); } - return instance; + for (let key of this.subTypes ?? []) { + if (spec[key] !== undefined) { + this[key] = PropType.for(spec[key]); + } + else if (!(key in this)) { + this[key] = PropType.any; + } + } } /** @@ -150,10 +155,8 @@ export default class PropType { return true; } - for (let obj = this; obj; obj = obj.super) { - if (obj.spec?.equals) { - return obj.spec.equals.call(this, a, b); - } + if (this.spec_equals) { + return this.spec_equals(a, b); } return typeof a.equals === "function" ? a.equals(b) : false; @@ -168,10 +171,8 @@ export default class PropType { return value; } - for (let obj = this; obj; obj = obj.super) { - if (obj.spec?.parse) { - return obj.spec.parse.call(this, value); - } + if (this.spec_parse) { + return this.spec_parse(value); } let Type = this.is; @@ -192,32 +193,13 @@ export default class PropType { return null; } - for (let obj = this; obj; obj = obj.super) { - if (obj.spec?.stringify) { - return obj.spec.stringify.call(this, value); - } + if (this.spec_stringify) { + return this.spec_stringify(value); } return String(value); } - /** - * Walk the super chain looking for the named method in each `spec`, and - * invoke the first one found with `this` bound to the original receiver. - * Backs the auto-wrapped helper dispatchers; consumers normally call the - * generated wrappers (`this.items(…)`) rather than this directly. - * @param {string} method - * @param {...unknown} args - * @returns {unknown} - */ - dispatch (method, ...args) { - for (let obj = this; obj; obj = obj.super) { - if (obj.spec?.[method]) { - return obj.spec[method].apply(this, args); - } - } - } - /** * Is this type a kind of `other` — i.e. is `other` somewhere in the * super chain (or is it `this` itself)? Replaces `instanceof` checks @@ -236,7 +218,19 @@ export default class PropType { } get [Symbol.toStringTag] () { - return (this.spec?.name ?? this.is?.name ?? "Prop") + "Type"; + if (this.name) { + return this.name; + } + + if (this.is) { + return this.is?.name ?? this.is; + } + + if (this.super !== this.constructor.prototype) { + return this.super[Symbol.toStringTag]; + } + + return this.constructor.name; } /** @type {Map} */ From c67b545880142c23155bf9798ea9f22322e930c3 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 16:53:33 -0400 Subject: [PATCH 09/27] Lift spec methods onto instances; let JS prototype chain do dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PropType used to walk a custom \`obj.super\` chain for every method dispatch (\`equals\` / \`parse\` / \`stringify\` plus auto-wrapped helpers). That was redundant given \`Object.create(parent)\` already wires up the JS prototype chain — the for-loop was a workaround for spec being nested. Now \`init()\` lifts every spec property onto the instance directly: keys that collide with prototype names (\`equals\`, \`parse\`, \`stringify\`, etc.) get a \`spec_\` prefix; everything else (\`parseItems\`, \`parseEntries\`, sub-types, separators…) goes straight on. Derivatives inherit via JS prototype chain; the for-loop and \`dispatch\` helper are gone. \`from(spec)\` on the prototype lets any PropType spawn derivatives directly (\`Iterable.from({values: Number})\`). The constructor delegates to it. The type files no longer need \`X.spec.parse.call(this, value)\` reach-in: - Iterable splits \`parse\` into a generator \`parsedItems(value)\` + a terminal \`parse(value) = [...this.parsedItems(value)]\`. - ArrayType is now just \`{is: Array, extends: Iterable}\` — inherits everything. - SetType.parse = \`new Set(this.parsedItems(value))\`. - MapType similarly adds \`parsedEntries(value)\` + terminal parse. - ObjectType.parse uses \`this.parsedEntries(value)\`; stringify inlined (5 lines duplicated from MapType in exchange for no spec reach-in). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/types/array.js | 3 -- src/plugins/props/types/iterable.js | 28 ++++++++++++---- src/plugins/props/types/map.js | 27 ++++++++++----- src/plugins/props/types/object.js | 13 ++++---- src/plugins/props/types/set.js | 2 +- src/plugins/props/util/PropType.js | 51 +++++++++++++++-------------- 6 files changed, 73 insertions(+), 51 deletions(-) diff --git a/src/plugins/props/types/array.js b/src/plugins/props/types/array.js index 4a629800..2688887e 100644 --- a/src/plugins/props/types/array.js +++ b/src/plugins/props/types/array.js @@ -4,7 +4,4 @@ import Iterable from "./iterable.js"; export default PropType.register({ is: Array, extends: Iterable, - parse (value) { - return [...Iterable.spec.parse.call(this, value)]; - }, }); diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js index b1d840c4..7c6aa723 100644 --- a/src/plugins/props/types/iterable.js +++ b/src/plugins/props/types/iterable.js @@ -3,15 +3,16 @@ import { split } from "../util/split.js"; /** * Abstract type for any iterable. The parsing pipeline is two streaming - * generators: {@link parseItems} yields raw items (no type parsing), and - * {@link parse} yields each item through `this.values`. Concrete types - * (Array, Set, …) `extends: Iterable` and pipe the {@link parse} - * iterator straight into their own container — every input flows through - * the chain exactly once, no intermediate arrays. + * generators feeding a terminal step: {@link parseItems} yields raw items + * (no type parsing); {@link parsedItems} yields each item through + * `this.values`; {@link parse} materializes into an `Array`. Concrete + * types (Array, Set, …) `extends: Iterable` — Array inherits `parse` + * unchanged; Set overrides `parse` to wrap `this.parsedItems(value)` into + * a `Set`. Each input value flows through the chain exactly once. * * `MapType` extends this and reuses `parseItems` to grab the raw string * parts, then splits each on `:` in its own `parseEntries` to yield - * `[key, value]` tuples. Distinct names because the return types differ. + * `[key, value]` tuples. */ const Iterable = PropType.register({ name: "Iterable", @@ -39,16 +40,29 @@ const Iterable = PropType.register({ /** * Yield each item from {@link parseItems} through `this.values`. + * The intermediate generator that concrete types (Set, …) consume into + * their own container. * @this {PropType} * @param {string | Iterable | unknown} value * @returns {Iterator} */ - *parse (value) { + *parsedItems (value) { for (let v of this.parseItems(value)) { yield this.values.parse(v); } }, + /** + * Materialize {@link parsedItems} into an `Array`. Inherited by ArrayType + * unchanged; SetType overrides to wrap into a `Set`. + * @this {PropType} + * @param {string | Iterable | unknown} value + * @returns {unknown[]} + */ + parse (value) { + return [...this.parsedItems(value)]; + }, + /** * Stringify an iterable: each item passes through `this.values`, joined * by `spec.joiner` (falling back to `spec.separator`, default `", "`). diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js index 53c8d76c..6ea77f85 100644 --- a/src/plugins/props/types/map.js +++ b/src/plugins/props/types/map.js @@ -66,10 +66,23 @@ const MapType = PropType.register({ }, /** - * Apply `this.keys` / `this.values` to each entry from - * {@link parseEntries} and materialize into a `Map`. ObjectType - * overrides with the same loop body but materializes via - * `Object.fromEntries`. + * Yield each `[k, v]` from {@link parseEntries} with `this.keys` and + * `this.values` applied to the respective halves. The intermediate + * generator that concrete types (Object, …) consume into their own + * container. + * @this {PropType} + * @param {string | Iterable<[unknown, unknown]>} value + * @returns {Iterator<[unknown, unknown]>} + */ + *parsedEntries (value) { + for (let [k, v] of this.parseEntries(value)) { + yield [this.keys.parse(k), this.values.parse(v)]; + } + }, + + /** + * Materialize {@link parsedEntries} into a `Map`. ObjectType overrides + * to wrap via `Object.fromEntries`. * @this {PropType} * @param {string | Iterable<[unknown, unknown]> | object} value * @returns {Map} @@ -78,11 +91,7 @@ const MapType = PropType.register({ if (value && typeof value === "object" && !value[Symbol.iterator]) { value = Object.entries(value); } - let result = new Map(); - for (let [k, v] of this.parseEntries(value)) { - result.set(this.keys.parse(k), this.values.parse(v)); - } - return result; + return new Map(this.parsedEntries(value)); }, /** diff --git a/src/plugins/props/types/object.js b/src/plugins/props/types/object.js index be3ca096..7a47a3a2 100644 --- a/src/plugins/props/types/object.js +++ b/src/plugins/props/types/object.js @@ -18,13 +18,14 @@ export default PropType.register({ if (value && typeof value === "object" && !value[Symbol.iterator]) { value = Object.entries(value); } - let result = {}; - for (let [k, v] of this.parseEntries(value)) { - result[this.keys.parse(k)] = this.values.parse(v); - } - return result; + return Object.fromEntries(this.parsedEntries(value)); }, stringify (value) { - return MapType.spec.stringify.call(this, Object.entries(value)); + let { separator = ", " } = this.spec; + let parts = []; + for (let [k, v] of Object.entries(value)) { + parts.push(`${this.keys.stringify(k)}: ${this.values.stringify(v)}`); + } + return parts.join(separator); }, }); diff --git a/src/plugins/props/types/set.js b/src/plugins/props/types/set.js index 8a5bbf08..e0c6fe83 100644 --- a/src/plugins/props/types/set.js +++ b/src/plugins/props/types/set.js @@ -18,6 +18,6 @@ export default PropType.register({ return true; }, parse (value) { - return new Set(Iterable.spec.parse.call(this, value)); + return new Set(this.parsedItems(value)); }, }); diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 94f02836..21547421 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -9,14 +9,6 @@ const callableBuiltins = new Set([ BigInt, ]); -/** - * Standard methods with dedicated dispatchers on the prototype. Any other - * function found in a spec is auto-wrapped into a generic super-walk dispatcher - * at construction time, so abstract types can publish helpers (e.g. `items`, - * `entries`) that descendants invoke as plain `this.x(…)` calls. - */ -const standardMethods = new Set(["equals", "parse", "stringify"]); - /** * Type adapter for prop values: defines equality, parsing from raw input * (typically attribute strings), and stringification back to attributes. @@ -24,29 +16,31 @@ const standardMethods = new Set(["equals", "parse", "stringify"]); * A `PropType` instance is an *abstract*, prop-agnostic type definition. * The {@link registry} holds every registered type — concrete types are keyed * on their JS constructor (`is`), abstract types on their string `name`. - * {@link PropType.for} dispatches lookups and delegates derivative - * construction to the constructor. + * {@link PropType.for} resolves any user-facing identifier into a PropType + * and delegates derivative construction to the constructor. + * + * Derivatives are created via `Object.create(parent)`, and every spec + * property is lifted onto the instance during {@link init} (overrides that + * collide with prototype names — `equals`, `parse`, `stringify`, etc. — + * are stored under a `spec_` prefix). Lookups then walk the JS prototype + * chain naturally: a derivative inherits whatever its parent set, and + * its own values shadow as expected. * - * Derivatives are created via `Object.create(parent)` so every property - * lookup (options + the {@link spec} method-override slot) walks the JS - * prototype chain naturally — no merging, no copies. The parent is picked - * from `spec.extends` if present, otherwise from the registry entry for - * `spec.is`, allowing the chain parent to differ from the produced JS type - * (e.g. `{is: Array, extends: Iterable}`). + * The parent is picked from `spec.extends` if present, otherwise from the + * registry entry for `spec.is`, allowing the chain parent to differ from + * the produced JS type (e.g. `{is: Array, extends: Iterable}`). * - * Type-specific `equals` / `parse` / `stringify` supplied at registration - * are stored under `instance.spec`. The prototype methods on this class - * handle the shared null/identity short-circuits and delegate to that spec - * when present, so type definitions stay free of boilerplate. + * The prototype `equals` / `parse` / `stringify` methods handle the shared + * null/identity short-circuits and then call `this.spec_(…)` if + * present, so type definitions stay free of boilerplate. * * @template {PropTypeSpec} [TSpec=PropTypeSpec] */ export default class PropType { /** * The spec object this instance was constructed with — stored verbatim, - * not cloned. Type-specific method overrides (`equals`, `parse`, - * `stringify`) live here and are invoked by the prototype dispatchers - * after walking the {@link super} chain. + * not cloned, alongside the lifted `spec_` copies of any method + * overrides it carried. * @type {PropTypeSpec | undefined} */ spec; @@ -54,8 +48,9 @@ export default class PropType { /** * The next instance up the type chain — the parent picked from * `spec.extends` or `registry.get(spec.is)`, or the class prototype - * for roots. Used by the dispatchers to walk for inherited method - * overrides; subclasses and consumers can walk it to inspect lineage. + * for roots. Distinct from `Object.getPrototypeOf(this)` only for + * root instances (where the JS prototype is the class prototype). + * Consumers can walk it to inspect lineage (see {@link isA}). * @type {PropType | object | undefined} */ super = this.constructor.prototype; @@ -107,6 +102,12 @@ export default class PropType { this.init(); } + /** + * Create a new type that inherits from this one. + * Mainly used internally. + * @param {PropTypeSpec} spec + * @returns + */ from (spec) { let instance = Object.create(this); instance.super = this; From 313327d62bd47bf7360cc9a8e28a57f55ef2a170 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sat, 23 May 2026 17:43:31 -0400 Subject: [PATCH 10/27] Use this.is --- src/plugins/props/types/array.js | 3 +++ src/plugins/props/types/iterable.js | 2 +- src/plugins/props/types/set.js | 3 --- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/props/types/array.js b/src/plugins/props/types/array.js index 2688887e..704ea160 100644 --- a/src/plugins/props/types/array.js +++ b/src/plugins/props/types/array.js @@ -4,4 +4,7 @@ import Iterable from "./iterable.js"; export default PropType.register({ is: Array, extends: Iterable, + parse (value) { + return [...this.parsedItems(value)]; + }, }); diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js index 7c6aa723..45cb1efe 100644 --- a/src/plugins/props/types/iterable.js +++ b/src/plugins/props/types/iterable.js @@ -60,7 +60,7 @@ const Iterable = PropType.register({ * @returns {unknown[]} */ parse (value) { - return [...this.parsedItems(value)]; + return new this.is(this.parsedItems(value)); }, /** diff --git a/src/plugins/props/types/set.js b/src/plugins/props/types/set.js index e0c6fe83..5d2a0588 100644 --- a/src/plugins/props/types/set.js +++ b/src/plugins/props/types/set.js @@ -17,7 +17,4 @@ export default PropType.register({ return true; }, - parse (value) { - return new Set(this.parsedItems(value)); - }, }); From 4e2e042bf23cf07df37d5ae60bff2fe85eae654e Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 24 May 2026 10:50:59 -0400 Subject: [PATCH 11/27] Update map.js --- src/plugins/props/types/map.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js index 6ea77f85..d88f3037 100644 --- a/src/plugins/props/types/map.js +++ b/src/plugins/props/types/map.js @@ -91,7 +91,7 @@ const MapType = PropType.register({ if (value && typeof value === "object" && !value[Symbol.iterator]) { value = Object.entries(value); } - return new Map(this.parsedEntries(value)); + return new this.is(this.parsedEntries(value)); }, /** From 86c75aa5f883564b20174c18b12048a8c74c744c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 24 May 2026 11:01:53 -0400 Subject: [PATCH 12/27] Note Iterator.map() inlining opportunity on parsedItems/parsedEntries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These intermediate generators are workarounds for not having an ergonomic way to express "lazy typed iteration with `this` binding" inline. `Iterator.prototype.map` solves this, but it's still Baseline 2025 — too fresh to rely on yet. When it's widely available, both methods can be inlined into their respective `parse` and removed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/types/iterable.js | 5 +++++ src/plugins/props/types/map.js | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js index 45cb1efe..4f1509c5 100644 --- a/src/plugins/props/types/iterable.js +++ b/src/plugins/props/types/iterable.js @@ -42,6 +42,11 @@ const Iterable = PropType.register({ * Yield each item from {@link parseItems} through `this.values`. * The intermediate generator that concrete types (Set, …) consume into * their own container. + * + * @todo Inline into {@link parse} via + * `this.parseItems(value).map(v => this.values.parse(v))` once + * `Iterator.prototype.map` is reliably available (Baseline 2025). + * * @this {PropType} * @param {string | Iterable | unknown} value * @returns {Iterator} diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js index d88f3037..df962a0c 100644 --- a/src/plugins/props/types/map.js +++ b/src/plugins/props/types/map.js @@ -70,6 +70,11 @@ const MapType = PropType.register({ * `this.values` applied to the respective halves. The intermediate * generator that concrete types (Object, …) consume into their own * container. + * + * @todo Inline into {@link parse} via + * `this.parseEntries(value).map(([k, v]) => [this.keys.parse(k), this.values.parse(v)])` + * once `Iterator.prototype.map` is reliably available (Baseline 2025). + * * @this {PropType} * @param {string | Iterable<[unknown, unknown]>} value * @returns {Iterator<[unknown, unknown]>} From be3465e1f90c8fe457ec5a16d1ba6b13281ec8e7 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 24 May 2026 12:11:17 -0400 Subject: [PATCH 13/27] Update PropType.js --- src/plugins/props/util/PropType.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 21547421..2b32f76c 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -1,3 +1,6 @@ +/** + * Constructors that should be called as functions + */ const callableBuiltins = new Set([ String, Number, @@ -7,6 +10,7 @@ const callableBuiltins = new Set([ Function, Symbol, BigInt, + RegExp, ]); /** From f0cd471eb68dcd9bfbb9734dca28f7a8a6a3bf38 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Sun, 24 May 2026 12:12:01 -0400 Subject: [PATCH 14/27] Rewrite tests Still needs work but oh well --- test/PropType.js | 166 +++++++++++++++++++++-------------------------- 1 file changed, 75 insertions(+), 91 deletions(-) diff --git a/test/PropType.js b/test/PropType.js index 5ac76292..8cedc138 100644 --- a/test/PropType.js +++ b/test/PropType.js @@ -12,71 +12,48 @@ const StringType = PropType.for(String); export default { name: "PropType", + expect: ArrayType, tests: [ { name: "for() — pure lookup returns the registered singleton", + run: input => PropType.for(input), tests: [ - { - name: "By constructor", - run: () => PropType.for(Array) === ArrayType, - expect: true, - }, - { - name: "By global name string", - run: () => PropType.for("Array") === ArrayType, - expect: true, - }, - { - name: "By bare spec {is: ctor}", - run: () => PropType.for({ is: Array }) === ArrayType, - expect: true, - }, - { - name: "By bare spec {is: 'name'}", - run: () => PropType.for({ is: "Array" }) === ArrayType, - expect: true, - }, - { - name: "PropType instance passes through", - run: () => PropType.for(ArrayType) === ArrayType, - expect: true, - }, + { name: "By constructor", arg: Array }, + { name: "By global name string", arg: "Array" }, + { name: "By bare spec {is: ctor}", arg: { is: Array } }, + { name: "By bare spec {is: 'name'}", arg: { is: "Array" } }, + { name: "PropType instance passes through", arg: ArrayType }, { name: "Lookup is idempotent across calls", - run: () => PropType.for(Array) === PropType.for(Array), - expect: true, + check: () => PropType.for(Array) === PropType.for(Array), }, { name: "undefined yields a fallback that is a PropType", - run: () => PropType.for(undefined) instanceof PropType, - expect: true, + check: () => PropType.for(undefined) instanceof PropType, }, { name: "null yields the same fallback as undefined", - run: () => PropType.for(undefined) === PropType.for(null), - expect: true, + arg: null, + expect: PropType.for(undefined), }, { name: "Custom fallback honored", - run: () => PropType.for(undefined, { fallback: ArrayType }) === ArrayType, - expect: true, + run: () => PropType.for(undefined, { fallback: ArrayType }), }, { name: "Unregistered constructor yields the default fallback", - run () { + check () { class Unknown {} return PropType.for(Unknown) === PropType.for(undefined); }, - expect: true, }, { name: "Built-in singletons match their named exports", - run: () => - PropType.for(Array) === ArrayType - && PropType.for(Set) === SetType - && PropType.for(Object) === ObjectType - && PropType.for(Map) === MapType, - expect: true, + check: () => + PropType.for(Array) === ArrayType && + PropType.for(Set) === SetType && + PropType.for(Object) === ObjectType && + PropType.for(Map) === MapType, }, ], }, @@ -85,44 +62,38 @@ export default { tests: [ { name: "Specs with options produce a fresh instance, not the singleton", - run: () => PropType.for({ is: Array, values: Number }) !== ArrayType, - expect: true, + check: () => PropType.for({ is: Array, values: Number }) !== ArrayType, }, { name: "Derivative inherits from its abstract base type", - run: () => PropType.for({ is: Array, values: Number }).isA(Iterable), - expect: true, + check: () => PropType.for({ is: Array, values: Number }).isA(Iterable), }, { name: "Derivative reports the correct is", - run: () => PropType.for({ is: Array, values: Number }).is === Array, - expect: true, + check: () => PropType.for({ is: Array, values: Number }).is === Array, }, { name: "Nested option specs resolve to PropType instances", - run () { + check () { let t = PropType.for({ is: Array, values: Number }); return t.values === NumberType; }, - expect: true, }, { name: "No caching: repeated calls yield distinct derivatives", - run () { + check () { let t1 = PropType.for({ is: Array, values: Number }); let t2 = PropType.for({ is: Array, values: Number }); return t1 !== t2; }, - expect: true, }, { name: "But nested singletons are shared across calls", - run () { + check () { let t1 = PropType.for({ is: Array, values: Number }); let t2 = PropType.for({ is: Array, values: Number }); return t1.values === t2.values; }, - expect: true, }, { name: "Mutating a derivative does not affect the singleton", @@ -184,8 +155,7 @@ export default { }, { name: "equals: null vs null is true", - run: () => PropType.for(Array).equals(null, null), - expect: true, + check: () => PropType.for(Array).equals(null, null), }, { name: "equals: null vs undefined is false", @@ -194,11 +164,10 @@ export default { }, { name: "equals: identity short-circuit", - run () { + check () { let a = [1, 2]; return PropType.for(Array).equals(a, a); }, - expect: true, }, ], }, @@ -221,11 +190,10 @@ export default { }, { name: "Array equality with matching contents", - run () { + check () { let t = PropType.for({ is: Array, values: Number }); return t.equals([1, 2, 3], [1, 2, 3]); }, - expect: true, }, { name: "Array equality with different length", @@ -246,21 +214,29 @@ export default { { name: "Derivative with custom separator parses with it", run () { - return PropType.for({ is: Array, values: Number, separator: ";" }).parse("1; 2; 3"); + return PropType.for({ is: Array, values: Number, separator: ";" }).parse( + "1; 2; 3", + ); }, expect: [1, 2, 3], }, { name: "Derivative with custom separator stringifies with it (no auto-spacing)", run () { - return PropType.for({ is: Array, values: Number, separator: ";" }).stringify([1, 2, 3]); + return PropType.for({ + is: Array, + values: Number, + separator: ";", + }).stringify([1, 2, 3]); }, expect: "1;2;3", }, { name: "Derivative with custom joiner stringifies with it", run () { - return PropType.for({ is: Array, values: Number, joiner: " " }).stringify([1, 2, 3]); + return PropType.for({ is: Array, values: Number, joiner: " " }).stringify([ + 1, 2, 3, + ]); }, expect: "1 2 3", }, @@ -279,37 +255,50 @@ export default { { name: "Object parses microsyntax", run () { - return PropType.for({ is: Object, keys: String, values: Number }).parse("a: 1, b: 2"); + return PropType.for({ is: Object, keys: String, values: Number }).parse( + "a: 1, b: 2", + ); }, expect: { a: 1, b: 2 }, }, { name: "Map parses to a Map with correct entries", run () { - let m = PropType.for({ is: Map, keys: String, values: Number }).parse("a: 1, b: 2"); + let m = PropType.for({ is: Map, keys: String, values: Number }).parse( + "a: 1, b: 2", + ); return [m instanceof Map, m.get("a"), m.get("b")]; }, expect: [true, 1, 2], }, { name: "Nested key and value types are resolved", - run () { + check () { let t = PropType.for({ is: Map, keys: String, values: Number }); return t.keys === StringType && t.values === NumberType; }, - expect: true, }, { name: "Derivative with custom separator parses with it", run () { - return PropType.for({ is: Object, keys: String, values: Number, separator: ";" }).parse("a: 1; b: 2"); + return PropType.for({ + is: Object, + keys: String, + values: Number, + separator: ";", + }).parse("a: 1; b: 2"); }, expect: { a: 1, b: 2 }, }, { name: "Derivative with custom separator stringifies with it", run () { - return PropType.for({ is: Object, keys: String, values: Number, separator: " | " }).stringify({ a: 1, b: 2 }); + return PropType.for({ + is: Object, + keys: String, + values: Number, + separator: " | ", + }).stringify({ a: 1, b: 2 }); }, expect: "a: 1 | b: 2", }, @@ -323,7 +312,9 @@ export default { { name: "Derivative with defaultKey uses it for keyless entries", run () { - return PropType.for({ is: Object, defaultKey: (v, i) => i }).parse("a, b, c"); + return PropType.for({ is: Object, defaultKey: (v, i) => i }).parse( + "a, b, c", + ); }, expect: { 0: "a", 1: "b", 2: "c" }, }, @@ -334,28 +325,27 @@ export default { tests: [ { name: "Array> — inner type is resolved correctly", - run () { + check () { let t = PropType.for({ is: Array, values: { is: Array, values: Number }, }); - return t.values.isA(Iterable) - && t.values.is === Array - && t.values.values === NumberType; + return ( + t.values.isA(Iterable) && + t.values.is === Array && + t.values.values === NumberType + ); }, - expect: true, }, { name: "Array> — inner type is the SetType derivative", - run () { + check () { let t = PropType.for({ is: Array, values: { is: Set, values: Number }, }); - return t.values.isA(Iterable) - && t.values.is === Set; + return t.values.isA(Iterable) && t.values.is === Set; }, - expect: true, }, ], }, @@ -369,8 +359,7 @@ export default { }, { name: "Boolean.parse(any non-null) → true", - run: () => PropType.for(Boolean).parse("anything"), - expect: true, + check: () => PropType.for(Boolean).parse("anything"), }, { name: "Boolean.stringify(true) → empty string", @@ -389,8 +378,7 @@ export default { }, { name: "Number.equals: NaN === NaN", - run: () => PropType.for(Number).equals(NaN, NaN), - expect: true, + check: () => PropType.for(Number).equals(NaN, NaN), }, { name: "Number.equals: 1 vs 2 false", @@ -414,16 +402,15 @@ export default { }, { name: "Function stringify throws", - run () { + check () { try { PropType.for(Function).stringify(() => {}); - return "no throw"; + return false; } catch (e) { return e instanceof TypeError; } }, - expect: true, }, ], }, @@ -432,36 +419,33 @@ export default { tests: [ { name: "After register, for() returns the registered instance", - run () { + check () { class Foo {} let registered = PropType.register({ is: Foo }); let result = PropType.for(Foo) === registered; PropType.registry.delete(Foo); return result; }, - expect: true, }, { name: "register with extends: Iterable produces an Iterable derivative", - run () { + check () { class FooList {} let t = PropType.register({ is: FooList, extends: Iterable }); let result = t.isA(Iterable); PropType.registry.delete(FooList); return result; }, - expect: true, }, { name: "register with extends: MapType produces a Map derivative", - run () { + check () { class FooDict {} let t = PropType.register({ is: FooDict, extends: MapType }); let result = t.isA(MapType); PropType.registry.delete(FooDict); return result; }, - expect: true, }, { name: "Registered parse is actually invoked", From 01c0e4489194513c9389b8ef4182fbc8a48c9bec Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 13:23:49 -0400 Subject: [PATCH 15/27] JSDoc changes from #128 Co-Authored-By: Dmitry Sharabin --- src/plugins/props/types/basic.js | 8 ++++++++ src/plugins/props/types/iterable.js | 5 +---- src/plugins/props/types/map.js | 5 +---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/plugins/props/types/basic.js b/src/plugins/props/types/basic.js index c43589c0..9d74071e 100644 --- a/src/plugins/props/types/basic.js +++ b/src/plugins/props/types/basic.js @@ -34,3 +34,11 @@ PropType.register({ throw new TypeError("Cannot stringify Function"); }, }); + +/** @import { PropTypeSpec } from "../util/PropType.js" */ + +/** + * @typedef {PropTypeSpec & { + * arguments?: string[], + * }} FunctionTypeSpec + */ diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js index 4f1509c5..e64999c5 100644 --- a/src/plugins/props/types/iterable.js +++ b/src/plugins/props/types/iterable.js @@ -116,10 +116,7 @@ const Iterable = PropType.register({ export default Iterable; -/** - * @typedef {import("../util/PropType.js").SpecifiedType} SpecifiedType - * @typedef {import("../util/PropType.js").PropTypeSpec} PropTypeSpec - */ +/** @import { SpecifiedType, PropTypeSpec } from "../util/PropType.js" */ /** * @typedef {PropTypeSpec & { diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js index df962a0c..9c80cd4d 100644 --- a/src/plugins/props/types/map.js +++ b/src/plugins/props/types/map.js @@ -147,10 +147,7 @@ const MapType = PropType.register({ export default MapType; -/** - * @typedef {import("../util/PropType.js").SpecifiedType} SpecifiedType - * @typedef {import("../util/PropType.js").PropTypeSpec} PropTypeSpec - */ +/** @import { SpecifiedType, PropTypeSpec } from "../util/PropType.js" */ /** * @typedef {PropTypeSpec & { From 5f842a361f63610a3c5227a68537e5b433c533a1 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 13:34:02 -0400 Subject: [PATCH 16/27] Fix exports and docs --- package.json | 2 +- src/plugins/props/README.md | 34 ++++++---------- src/plugins/props/index.js | 7 +++- src/plugins/props/types/README.md | 64 +++++++++++++++---------------- src/plugins/types.js | 11 ------ 5 files changed, 50 insertions(+), 68 deletions(-) delete mode 100644 src/plugins/types.js diff --git a/package.json b/package.json index 21f07da2..b586b5e1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "./fn": "./src/index-fn.js", "./plugins": "./src/plugins/index.js", "./plugins/fn": "./src/plugins/index-fn.js", - "./plugins/types": "./src/plugins/types.js" + "./props": "./src/plugins/props/index.js" }, "repository": { "type": "git", diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index 51dcf625..c097f1b1 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -155,39 +155,36 @@ It can be either a constant (e.g. `true`) or a function, in which case it’s pa #### Custom types -Types are *instances* of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any type options, and any `equals` / `parse` / `stringify` overrides — and is shared across every prop that references it. An abstract type (`Iterable`) is a `PropType` instance registered by `name` rather than `is`; concrete types declare `extends: ` to inherit its parsing behavior via the JS prototype chain (e.g. `Array`, `Set`, and `Map` all extend `Iterable`, and `Object` extends `Map`). +Types are _instances_ of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any type options, and any `equals` / `parse` / `stringify` overrides — and is shared across every prop that references it. An abstract type (`Iterable`) is a `PropType` instance registered by `name` rather than `is`; concrete types declare `extends: ` to inherit its parsing behavior via the JS prototype chain (e.g. `Array`, `Set`, and `Map` all extend `Iterable`, and `Object` extends `Map`). For most custom types, the simplest path is to register a spec object directly. Methods read options from `this.spec`: ```js -import { PropType } from "nude-element/plugins"; +import { PropType } from "nude-element/props"; PropType.register({ - is: Color, - parse: value => (value instanceof Color ? value : new Color(value)), - equals: (a, b) => a === b || a?.equals?.(b), - stringify: value => value?.toString(), + is: Color, + parse: value => (value instanceof Color ? value : new Color(value)), + equals: (a, b) => a === b || a?.equals?.(b), + stringify: value => value?.toString(), }); ``` For types that reuse the iterable / dictionary parsing infrastructure, `extends` an existing abstract — `Iterable` for any iterable, `MapType` for any key→value mapping. Inside method overrides, `this.values` (and `this.keys` for dictionaries) is the resolved nested type — defaulting to `PropType.any`, so you can call `this.values.parse(v)` unconditionally. ```js -import { PropType, Iterable } from "nude-element/plugins"; +import { PropType } from "nude-element/props"; PropType.register({ - is: Tuple, - extends: Iterable, - parse (value) { - return new Tuple(...Iterable.spec.parse.call(this, value)); - }, + is: Tuple, + extends: "Iterable", }); ``` -**Derivative types.** A type spec with options beyond `is` produces a *derivative* — a new `PropType` instance whose prototype chain points to the registered singleton for that `is` (or the abstract named via `extends`). Lookups for unspecified options fall through to the parent via the JS prototype chain. +**Derivative types.** A type spec with options beyond `is` produces a _derivative_ — a new `PropType` instance whose prototype chain points to the registered singleton for that `is` (or the abstract named via `extends`). Lookups for unspecified options fall through to the parent via the JS prototype chain. ```js -import { PropType } from "nude-element/plugins"; +import { PropType } from "nude-element/props"; const NumberArray = PropType.for({ is: Array, values: Number }); @@ -198,15 +195,6 @@ static props = { Inline specs in prop definitions work the same way — each occurrence produces its own derivative. Hoist a derivative into a `const` (as above) if you want every prop using it to share the same instance. -The built-in type instances are also available by their native names from a separate endpoint: - -```js -import * as Types from "nude-element/plugins/types"; - -Types.Array; // the ArrayType singleton -Types.Map; // the MapType singleton -``` - For the full spec-key reference, the abstract-type helper methods (`parseItems`, `parseEntries`), and the public API surface, see [`types/README.md`](./types/README.md). ### Attribute-property reflection diff --git a/src/plugins/props/index.js b/src/plugins/props/index.js index b5667735..4b7a1541 100644 --- a/src/plugins/props/index.js +++ b/src/plugins/props/index.js @@ -1,11 +1,16 @@ import Props from "./util/Props.js"; +import Prop from "./util/Prop.js"; import ElementProps from "./util/ElementProps.js"; +import ElementProp from "./util/ElementProp.js"; import { symbols } from "xtensible"; import { defineOwnProperty, getSuperMethod } from "xtensible/util"; import { defineLazyProperty } from "../../util/lazy.js"; +import PropType from "./util/PropType.js"; +import "./types/index.js"; export const { props } = symbols.known; -export * from "./types/index.js"; + +export { PropType, Props, Prop, ElementProps, ElementProp }; const hooks = { setup () { diff --git a/src/plugins/props/types/README.md b/src/plugins/props/types/README.md index c597684d..d59b3f70 100644 --- a/src/plugins/props/types/README.md +++ b/src/plugins/props/types/README.md @@ -4,37 +4,37 @@ The high-level "what's a PropType / how do I register one" walkthrough lives in ## Built-in types -| Type | Spec keys (besides `is`) | Notes | -| ---------- | --------------------------------------------------------------------- | -------------------------------------------------------------------- | -| `Boolean` | — | Presence-based: `null` → `null`, any non-null → `true`. | -| `Number` | — | Parses via `Number(value)`. `equals` treats `NaN` as equal to `NaN`. | -| `Function` | `arguments` | Parses to a `Function` constructed from the string body. Stringify throws. | -| `Array` | `values`, `separator`, `joiner`, `pairs` | Splits on `,` (pair-aware: parens, brackets, braces, quotes). | -| `Set` | `values`, `separator`, `joiner`, `pairs` | Same parsing as `Array`, materialized into a `Set`. | -| `Map` | `keys`, `values`, `separator`, `defaultKey`, `defaultValue`, `pairs` | Splits entries on `,` then each on `:`. | -| `Object` | same as `Map` | Same parsing pipeline, materialized into a plain object. | - -All built-ins are exported as singletons under their JS constructor names from `nude-element/plugins/types` (see [props README](../README.md#custom-types) for usage). +| Type | Spec keys (besides `is`) | Notes | +| ---------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| `Boolean` | — | Presence-based: `null` → `null`, any non-null → `true`. | +| `Number` | — | Parses via `Number(value)`. `equals` treats `NaN` as equal to `NaN`. | +| `Function` | `arguments` | Parses to a `Function` constructed from the string body. Stringify throws. | +| `Array` | `values`, `separator`, `joiner`, `pairs` | Splits on `,` (pair-aware: parens, brackets, braces, quotes). | +| `Set` | `values`, `separator`, `joiner`, `pairs` | Same parsing as `Array`, materialized into a `Set`. | +| `Map` | `keys`, `values`, `separator`, `defaultKey`, `defaultValue`, `pairs` | Splits entries on `,` then each on `:`. | +| `Object` | same as `Map` | Same parsing pipeline, materialized into a plain object. | + +All built-ins can be accessed via `PropType.for(name)` (see [props README](../README.md#custom-types) for usage). ## Spec keys -| Key | Role | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `is` | JS constructor this type produces. Doubles as registry key. Optional for abstracts. | -| `extends` | Explicit chain parent (a `PropType` instance, or a `name` string). Lets the parent differ from `registry.get(is)` — that's what decouples "what JS constructor does this produce" from "what behavior does this share." | -| `name` | Registry key for abstracts that have no `is`. | -| `subTypes` | Spec keys whose values are themselves type specs (`["values"]` for Iterable, `["keys", "values"]` for Map). Resolved to `PropType` instances at construction; unspecified ones default to `PropType.any`. Declared by the abstract; descendants inherit it via the prototype chain, and a descendant that redeclares it replaces (not extends) the inherited list. | -| `equals(a, b)` | Equality. Default short-circuits null and identity, then walks the chain. | -| `parse(value)` | Parse a raw input. Default passes `null` through and walks the chain. | -| `stringify(value)` | Stringify (returns `null` for null/undefined to signal attribute removal). | -| any other method | Auto-wrapped at construction into a super-walking dispatcher, callable as `this.x(…)` from anywhere in the chain. | +| Key | Role | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `is` | JS constructor this type produces. Doubles as registry key. Optional for abstracts. | +| `extends` | Explicit chain parent (a `PropType` instance, or a `name` string). Lets the parent differ from `registry.get(is)` — that's what decouples "what JS constructor does this produce" from "what behavior does this share." | +| `name` | Registry key for abstracts that have no `is`. | +| `subTypes` | Spec keys whose values are themselves type specs (`["values"]` for Iterable, `["keys", "values"]` for Map). Resolved to `PropType` instances at construction; unspecified ones default to `PropType.any`. Declared by the abstract; descendants inherit it via the prototype chain, and a descendant that redeclares it replaces (not extends) the inherited list. | +| `equals(a, b)` | Equality. Default short-circuits null and identity, then walks the chain. | +| `parse(value)` | Parse a raw input. Default passes `null` through and walks the chain. | +| `stringify(value)` | Stringify (returns `null` for null/undefined to signal attribute removal). | +| any other method | Auto-wrapped at construction into a super-walking dispatcher, callable as `this.x(…)` from anywhere in the chain. | ## Abstract helpers -| Method | Yields | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------- | -| `Iterable.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | -| `MapType.parseEntries` | Raw `[key, value]` tuples: built on `parseItems`, with `:`-splitting (escaped `\:` preserved) and shorthand-entry handling via `defaultKey` / `defaultValue` / `"false"`-coercion. | +| Method | Yields | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Iterable.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | +| `MapType.parseEntries` | Raw `[key, value]` tuples: built on `parseItems`, with `:`-splitting (escaped `\:` preserved) and shorthand-entry handling via `defaultKey` / `defaultValue` / `"false"`-coercion. | Both are generators. Concrete types consume them with the appropriate terminal container constructor (`Array.from`, `new Set`, `new Map`, `Object.fromEntries`) so each input value flows through the chain exactly once — no intermediate arrays. @@ -45,7 +45,7 @@ To call a parent's method from inside an override, use `ParentType.spec.method.c For something more involved than the simple [`Color` example](../README.md#custom-types), here's a `Length` type that accepts a `unit` option, demonstrating how a single registration becomes the basis for many derivatives: ```js -import { PropType } from "nude-element/plugins"; +import { PropType } from "nude-element/props"; PropType.register({ is: Length, @@ -69,12 +69,12 @@ static props = { ## Public API -| Method | Purpose | -| ------------------------------------- | --------------------------------------------------------------------------------------- | -| `PropType.for(input, { fallback })` | Universal resolver: `PropType` instance, constructor, string, or spec object → `PropType`. | -| `PropType.register(spec)` | Register a built-in or custom type. Returns the registered instance. | -| `instance.isA(otherType)` | Walk the chain looking for `otherType`. Replaces `instanceof` for abstract-type checks. | -| `PropType.any` | The generic fallback `PropType`. Used as the default for unspecified sub-types. | +| Method | Purpose | +| ----------------------------------- | ------------------------------------------------------------------------------------------ | +| `PropType.for(input, { fallback })` | Universal resolver: `PropType` instance, constructor, string, or spec object → `PropType`. | +| `PropType.register(spec)` | Register a built-in or custom type. Returns the registered instance. | +| `instance.isA(otherType)` | Walk the chain looking for `otherType`. Replaces `instanceof` for abstract-type checks. | +| `PropType.any` | The generic fallback `PropType`. Used as the default for unspecified sub-types. | ## Architecture in one paragraph diff --git a/src/plugins/types.js b/src/plugins/types.js deleted file mode 100644 index ce2525c0..00000000 --- a/src/plugins/types.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Public, named singletons for the built-in {@link PropType} instances. - * - * Exported under the corresponding JS constructor names so consumers can write: - * `import * as Types from "nude-element/plugins/types"; Types.Array, Types.Map, ...` - */ - -export { default as Array } from "./props/types/array.js"; -export { default as Set } from "./props/types/set.js"; -export { default as Object } from "./props/types/object.js"; -export { default as Map } from "./props/types/map.js"; From 441250f3d222a933a126b4771d897349c50ad814 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 14:45:35 -0400 Subject: [PATCH 17/27] Delete misleading comment --- src/plugins/props/types/index.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/plugins/props/types/index.js b/src/plugins/props/types/index.js index b0f9e865..c241e291 100644 --- a/src/plugins/props/types/index.js +++ b/src/plugins/props/types/index.js @@ -1,6 +1,4 @@ // Side-effect imports register the built-in types' singletons. -// Order matters: `iterable` must register before the concrete types that -// `extends` it (array, set, map); `map` before `object` (since object extends map). import "./basic.js"; import "./iterable.js"; import "./array.js"; From 4efb432fae6d0d3f10ded03d513584d38017acb9 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 14:47:42 -0400 Subject: [PATCH 18/27] Split basic.js into boolean.js, number.js, function.js Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/types/boolean.js | 11 +++++++++++ .../props/types/{basic.js => function.js} | 19 +------------------ src/plugins/props/types/index.js | 4 +++- src/plugins/props/types/number.js | 9 +++++++++ 4 files changed, 24 insertions(+), 19 deletions(-) create mode 100644 src/plugins/props/types/boolean.js rename src/plugins/props/types/{basic.js => function.js} (67%) create mode 100644 src/plugins/props/types/number.js diff --git a/src/plugins/props/types/boolean.js b/src/plugins/props/types/boolean.js new file mode 100644 index 00000000..a605d3af --- /dev/null +++ b/src/plugins/props/types/boolean.js @@ -0,0 +1,11 @@ +import PropType from "../util/PropType.js"; + +export default PropType.register({ + is: Boolean, + parse (value) { + return value !== null; + }, + stringify (value) { + return value ? "" : null; + }, +}); diff --git a/src/plugins/props/types/basic.js b/src/plugins/props/types/function.js similarity index 67% rename from src/plugins/props/types/basic.js rename to src/plugins/props/types/function.js index 9d74071e..4658142f 100644 --- a/src/plugins/props/types/basic.js +++ b/src/plugins/props/types/function.js @@ -1,23 +1,6 @@ import PropType from "../util/PropType.js"; -PropType.register({ - is: Boolean, - parse (value) { - return value !== null; - }, - stringify (value) { - return value ? "" : null; - }, -}); - -PropType.register({ - is: Number, - equals (a, b) { - return Number.isNaN(a) && Number.isNaN(b); - }, -}); - -PropType.register({ +export default PropType.register({ is: Function, equals (a, b) { return a.toString() === b.toString(); diff --git a/src/plugins/props/types/index.js b/src/plugins/props/types/index.js index c241e291..a10c6f9d 100644 --- a/src/plugins/props/types/index.js +++ b/src/plugins/props/types/index.js @@ -1,5 +1,7 @@ // Side-effect imports register the built-in types' singletons. -import "./basic.js"; +import "./boolean.js"; +import "./number.js"; +import "./function.js"; import "./iterable.js"; import "./array.js"; import "./set.js"; diff --git a/src/plugins/props/types/number.js b/src/plugins/props/types/number.js new file mode 100644 index 00000000..13942713 --- /dev/null +++ b/src/plugins/props/types/number.js @@ -0,0 +1,9 @@ +import PropType from "../util/PropType.js"; + +export default PropType.register({ + is: Number, + equals (a, b) { + // All other cases are handled by the basic equality check in Prop.equals + return Number.isNaN(a) && Number.isNaN(b); + }, +}); From 7ee136e4763a553d04fba6c5bb56aab79d0b1c4c Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 14:54:27 -0400 Subject: [PATCH 19/27] Type specs should not reference this.spec, just this --- src/plugins/props/types/function.js | 2 +- src/plugins/props/types/iterable.js | 8 +++----- src/plugins/props/types/map.js | 8 ++++---- src/plugins/props/types/object.js | 2 +- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/plugins/props/types/function.js b/src/plugins/props/types/function.js index 4658142f..6e15a714 100644 --- a/src/plugins/props/types/function.js +++ b/src/plugins/props/types/function.js @@ -10,7 +10,7 @@ export default PropType.register({ return value; } - return Function(...(this.spec.arguments ?? []), String(value)); + return Function(...(this.arguments ?? []), String(value)); }, stringify () { // Stringification is explicitly forbidden for functions. diff --git a/src/plugins/props/types/iterable.js b/src/plugins/props/types/iterable.js index e64999c5..7826faeb 100644 --- a/src/plugins/props/types/iterable.js +++ b/src/plugins/props/types/iterable.js @@ -28,7 +28,7 @@ const Iterable = PropType.register({ */ *parseItems (value) { if (typeof value === "string") { - yield* split(value, this.spec); + yield* split(value, this); } else if (value?.[Symbol.iterator]) { yield* value; @@ -70,15 +70,13 @@ const Iterable = PropType.register({ /** * Stringify an iterable: each item passes through `this.values`, joined - * by `spec.joiner` (falling back to `spec.separator`, default `", "`). - * Whitespace is *not* added automatically — consumers who want spaces - * include them in the joiner (or separator) themselves. + * by `joiner` (falling back to `separator`, default `", "`). * @this {PropType} * @param {Iterable} value * @returns {string} */ stringify (value) { - let { separator = ", ", joiner = separator } = this.spec; + let { separator = ", ", joiner = separator } = this; let parts = []; for (let v of value) { parts.push(this.values.stringify(v)); diff --git a/src/plugins/props/types/map.js b/src/plugins/props/types/map.js index 9c80cd4d..f0718d88 100644 --- a/src/plugins/props/types/map.js +++ b/src/plugins/props/types/map.js @@ -22,7 +22,7 @@ const MapType = PropType.register({ /** * Yield raw `[key, value]` entries. Strings coming from {@link parseItems} * are split once on `:` (escaped `\:` preserved); shorthand entries with - * no colon get filled in via `spec.defaultKey` or `spec.defaultValue` + * no colon get filled in via `defaultKey` or `defaultValue` * (default `true`); the literal `"false"` becomes the boolean `false`. * Non-string items (e.g. an already-iterable of tuples) flow through * unchanged. No `keys.parse` / `values.parse` applied — that's @@ -32,7 +32,7 @@ const MapType = PropType.register({ * @returns {Iterator<[unknown, unknown]>} */ *parseEntries (value) { - let { defaultKey, defaultValue = true } = this.spec; + let { defaultKey, defaultValue = true } = this; let index = 0; for (let item of this.parseItems(value)) { let k, v; @@ -102,13 +102,13 @@ const MapType = PropType.register({ /** * Stringify an iterable of `[key, value]` entries into `"k: v, k: v"`. * Each half passes through `this.keys` / `this.values`; entries are - * joined by `spec.separator` (default `", "`). + * joined by `separator` (default `", "`). * @this {PropType} * @param {Iterable<[unknown, unknown]>} value * @returns {string} */ stringify (value) { - let { separator = ", " } = this.spec; + let { separator = ", " } = this; let parts = []; for (let [k, v] of value) { parts.push(`${this.keys.stringify(k)}: ${this.values.stringify(v)}`); diff --git a/src/plugins/props/types/object.js b/src/plugins/props/types/object.js index 7a47a3a2..effb458f 100644 --- a/src/plugins/props/types/object.js +++ b/src/plugins/props/types/object.js @@ -21,7 +21,7 @@ export default PropType.register({ return Object.fromEntries(this.parsedEntries(value)); }, stringify (value) { - let { separator = ", " } = this.spec; + let { separator = ", " } = this; let parts = []; for (let [k, v] of Object.entries(value)) { parts.push(`${this.keys.stringify(k)}: ${this.values.stringify(v)}`); From d11badf9d0270b31448426b2f796223b6a926c86 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 15:16:41 -0400 Subject: [PATCH 20/27] Update README.md --- src/plugins/props/README.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index c097f1b1..03b2d11a 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -155,13 +155,19 @@ It can be either a constant (e.g. `true`) or a function, in which case it’s pa #### Custom types -Types are _instances_ of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any type options, and any `equals` / `parse` / `stringify` overrides — and is shared across every prop that references it. An abstract type (`Iterable`) is a `PropType` instance registered by `name` rather than `is`; concrete types declare `extends: ` to inherit its parsing behavior via the JS prototype chain (e.g. `Array`, `Set`, and `Map` all extend `Iterable`, and `Object` extends `Map`). +Types are _instances_ of the single `PropType` class. Each instance carries the spec it was constructed with — its constructor (`is`), any `equals` / `parse` / `stringify` overrides, and any additional type options they may use (e.g. Iterables use a `separator` option as well). -For most custom types, the simplest path is to register a spec object directly. Methods read options from `this.spec`: +Types without an `is` property are _abstract_ — they don't correspond to a specific JS constructor, but just define behavior that concrete types can inherit via the JS prototype chain. `Iterable` is a current example (though in the future it may use `is: Iterator`). + +Most constructors do not actually need registering. +For example, consider [Color.js](https://colorjs.io/) `Color` objects. + +It may be tempting to do something like this: ```js import { PropType } from "nude-element/props"; +// ❌ Don't do this PropType.register({ is: Color, parse: value => (value instanceof Color ? value : new Color(value)), @@ -170,7 +176,15 @@ PropType.register({ }); ``` -For types that reuse the iterable / dictionary parsing infrastructure, `extends` an existing abstract — `Iterable` for any iterable, `MapType` for any key→value mapping. Inside method overrides, `this.values` (and `this.keys` for dictionaries) is the resolved nested type — defaulting to `PropType.any`, so you can call `this.values.parse(v)` unconditionally. +However, none of this is needed: + +- `parse()` automatically constructs an object of type `type.is` and the `Color` constructor already accepts strings +- `Color` objects already have a good `toString()` method, which is called automatically +- `equals()` already checks `a === b` and uses `a.equals(b)` if such a method is available. + +Using `type: Color` in the prop definition is enough to get all the benefits of type-aware parsing, stringifying, and equality checking for free. + +For custom types that represent more complex objects, you may want to register them as extending an existing type, e.g. `Iterable` for any list of values, or `Map` for any key→value mapping. ```js import { PropType } from "nude-element/props"; @@ -195,7 +209,7 @@ static props = { Inline specs in prop definitions work the same way — each occurrence produces its own derivative. Hoist a derivative into a `const` (as above) if you want every prop using it to share the same instance. -For the full spec-key reference, the abstract-type helper methods (`parseItems`, `parseEntries`), and the public API surface, see [`types/README.md`](./types/README.md). +For the full spec-key reference, the abstract-type helper methods (`parseItems`, `parseEntries`), and the public API surface, see [`types`](./types/README.md). ### Attribute-property reflection From 20403423ab32cd15b5377caab81bb4b78e69bfd4 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 15:51:05 -0400 Subject: [PATCH 21/27] Test that unregistered constructors carry their `is` and behave correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `Unknown` stand-in class (Color.js-shaped: string constructor, toString, equals) and a test group exercising the README's claim that `type: SomeClass` Just Works without `PropType.register()` — parse builds a `new SomeClass(value)`, stringify uses toString, equals uses `a.equals(b)`. Also flips "Unregistered constructor yields the default fallback" to "…yields a derivative carrying its `is`" to encode the contract, and adds an "Unresolvable string still yields the default fallback" test to keep the typo/missing-import carve-out pinned down. --- test/PropType.js | 69 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/test/PropType.js b/test/PropType.js index 8cedc138..e6642116 100644 --- a/test/PropType.js +++ b/test/PropType.js @@ -10,6 +10,21 @@ import MapType from "../src/plugins/props/types/map.js"; const NumberType = PropType.for(Number); const StringType = PropType.for(String); +// Stand-in for any custom class a user might pass as `type` without registering +// it (the README's Color.js example). Constructs from a string, has its own +// toString and equals — enough to exercise the default parse/stringify/equals. +class Unknown { + constructor (value) { + this.value = value instanceof Unknown ? value.value : String(value); + } + toString () { + return this.value; + } + equals (other) { + return other instanceof Unknown && other.value === this.value; + } +} + export default { name: "PropType", expect: ArrayType, @@ -41,12 +56,17 @@ export default { run: () => PropType.for(undefined, { fallback: ArrayType }), }, { - name: "Unregistered constructor yields the default fallback", + name: "Unregistered constructor yields a derivative carrying its `is`", check () { - class Unknown {} - return PropType.for(Unknown) === PropType.for(undefined); + let t = PropType.for(Unknown); + return t instanceof PropType && t !== PropType.any && t.is === Unknown; }, }, + { + name: "Unresolvable string still yields the default fallback", + check: () => + PropType.for("DefinitelyNotAGlobalOrRegisteredType") === PropType.for(undefined), + }, { name: "Built-in singletons match their named exports", check: () => @@ -414,6 +434,49 @@ export default { }, ], }, + { + name: "Custom constructors without registration", + tests: [ + { + name: "parse(string) constructs an Unknown instance", + check: () => PropType.for(Unknown).parse("red") instanceof Unknown, + }, + { + name: "parse(string) preserves the value", + run: () => PropType.for(Unknown).parse("red").toString(), + expect: "red", + }, + { + name: "parse passes through existing Unknown instances", + check () { + let c = new Unknown("red"); + return PropType.for(Unknown).parse(c) === c; + }, + }, + { + name: "stringify uses Unknown#toString", + run: () => PropType.for(Unknown).stringify(new Unknown("blue")), + expect: "blue", + }, + { + name: "equals uses Unknown#equals for distinct-but-equal instances", + check: () => + PropType.for(Unknown).equals( + new Unknown("red"), + new Unknown("red"), + ), + }, + { + name: "equals returns false for different Unknown instances", + run: () => + PropType.for(Unknown).equals( + new Unknown("red"), + new Unknown("blue"), + ), + expect: false, + }, + ], + }, { name: "register()", tests: [ From f99706c1826fc31d5e19ff01e0cd3e8c30cc0c22 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 16:34:18 -0400 Subject: [PATCH 22/27] Fix PropType.for() to construct a derivative for unregistered constructors Co-Authored-By: Claude --- src/plugins/props/util/PropType.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 2b32f76c..3e1ecc26 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -267,8 +267,10 @@ export default class PropType { * Resolve any user-facing type identifier into a {@link PropType}. * * - PropType instance → returned as-is. - * - constructor / string → registry lookup (string resolved via - * `globalThis` first, then by name). + * - constructor → registry lookup, falling back to a fresh `{is: input}` + * derivative so unregistered constructors still carry their `is`. + * - string → resolved via `globalThis` first, then registry; bare strings + * that match neither hit `options.fallback` (typo / missing import). * - object spec → constructor short-circuits to the singleton if no * extras, otherwise builds a derivative. * - null / undefined → `options.fallback` (default: the generic instance). @@ -290,7 +292,18 @@ export default class PropType { return new this(input); } - return this.registry.get(PropType.normalizeIs(input)) ?? fallback; + let is = PropType.normalizeIs(input); + let registered = this.registry.get(is); + if (registered) { + return registered; + } + + // Unregistered constructor → build a derivative so `is` is preserved + // and the default parse/stringify/equals can use it. Bare strings + // (those `normalizeIs` couldn't resolve to a function) fall through + // to the fallback since they almost always indicate a typo or missing + // import. + return typeof is === "function" ? new this({ is }) : fallback; } /** From 186093e262239e917c5fe8619a2d625fc4f0c897 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 20:57:48 -0400 Subject: [PATCH 23/27] Cache on-the fly types --- src/plugins/props/util/PropType.js | 23 ++++++++++++++++------- test/PropType.js | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 3e1ecc26..ba596656 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -288,8 +288,17 @@ export default class PropType { return fallback; } + // Bare `{is: X}` is equivalent to passing `X` directly — collapse it + // so both forms hit the same cached-lookup path. Anything richer + // produces a fresh derivative. if (typeof input === "object") { - return new this(input); + let keys = Object.keys(input); + if (keys.length === 1 && keys[0] === "is") { + input = input.is; + } + else { + return new this(input); + } } let is = PropType.normalizeIs(input); @@ -298,12 +307,12 @@ export default class PropType { return registered; } - // Unregistered constructor → build a derivative so `is` is preserved - // and the default parse/stringify/equals can use it. Bare strings - // (those `normalizeIs` couldn't resolve to a function) fall through - // to the fallback since they almost always indicate a typo or missing - // import. - return typeof is === "function" ? new this({ is }) : fallback; + // Unregistered constructor → register a fresh derivative so `is` is + // preserved and subsequent lookups return the same instance. Bare + // strings (those `normalizeIs` couldn't resolve to a function) fall + // through to the fallback since they almost always indicate a typo + // or missing import. + return typeof is === "function" ? this.register({ is }) : fallback; } /** diff --git a/test/PropType.js b/test/PropType.js index e6642116..01c21a29 100644 --- a/test/PropType.js +++ b/test/PropType.js @@ -62,6 +62,24 @@ export default { return t instanceof PropType && t !== PropType.any && t.is === Unknown; }, }, + { + name: "Unregistered constructor lookup is idempotent across calls", + check: () => PropType.for(Unknown) === PropType.for(Unknown), + }, + { + name: "Bare spec {is: X} is idempotent across calls", + check () { + class Fresh {} + return PropType.for({ is: Fresh }) === PropType.for({ is: Fresh }); + }, + }, + { + name: "Constructor and bare spec forms resolve to the same instance", + check () { + class Cross {} + return PropType.for({ is: Cross }) === PropType.for(Cross); + }, + }, { name: "Unresolvable string still yields the default fallback", check: () => From cfb349d0d394142b69bcabdee15ad8664ec63370 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 20:58:30 -0400 Subject: [PATCH 24/27] Better `isA()` implementation --- src/plugins/props/util/PropType.js | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index ba596656..3ca5cd24 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -214,12 +214,8 @@ export default class PropType { * @returns {boolean} */ isA (other) { - for (let obj = this; obj; obj = obj.super) { - if (obj === other) { - return true; - } - } - return false; + other = PropType.for(other); + return this === other || Object.prototype.isPrototypeOf.call(other, this); } get [Symbol.toStringTag] () { From 84c97dbd1428394e6f297058ca735849bb4a1122 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Tue, 26 May 2026 21:06:05 -0400 Subject: [PATCH 25/27] Add this.super, restructure dispatch via get_X transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher's spec_X indirection is replaced by per-method get_X transforms invoked at lift time. Each user spec method is wrapped with the null/identity short-circuit at init, then stored under its natural name — equals/parse/stringify on the prototype are bootstrapped the same way via get_X() with no spec. this.super lazy-instantiates a Proxy over Object.create(parent) that binds function reads to self, giving JS-class-style super semantics without recursion: super.parse resolves directly to parent's wrapped function rather than the dispatcher. Chain pointer renamed from super (data) to parent. isA rewritten as this === other || isPrototypeOf — derivatives are Object.create(parent), so the JS prototype chain is the type chain. PropType.for() collapses bare {is: X} to X upstream so both input forms hit the cached-lookup path via register(); constructor stays pure. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/util/PropType.js | 128 ++++++++++++++++------------- test/PropType.js | 55 +++++++++++++ 2 files changed, 128 insertions(+), 55 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 3ca5cd24..293cdbe6 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -1,3 +1,5 @@ +import { defineLazyProperty } from "../../../util/lazy.js"; + /** * Constructors that should be called as functions */ @@ -49,16 +51,6 @@ export default class PropType { */ spec; - /** - * The next instance up the type chain — the parent picked from - * `spec.extends` or `registry.get(spec.is)`, or the class prototype - * for roots. Distinct from `Object.getPrototypeOf(this)` only for - * root instances (where the JS prototype is the class prototype). - * Consumers can walk it to inspect lineage (see {@link isA}). - * @type {PropType | object | undefined} - */ - super = this.constructor.prototype; - /** * Resolve sub-types and store them as own properties so type-specific * code reads them as `this.values` / `this.keys` (the resolved PropType instances). @@ -114,7 +106,7 @@ export default class PropType { */ from (spec) { let instance = Object.create(this); - instance.super = this; + instance.parent = this; instance.spec = spec; instance.init(); return instance; @@ -124,8 +116,8 @@ export default class PropType { let { spec } = this; for (let key in spec) { - if (key in this.constructor.prototype) { - this["spec_" + key] = spec[key]; + if ("get_" + key in this) { + this[key] = this["get_" + key](spec[key]); } else { this[key] = spec[key]; @@ -146,63 +138,67 @@ export default class PropType { } } - /** - * @param {unknown} a - * @param {unknown} b - * @returns {boolean} - */ - equals (a, b) { - if (a === null || b === null || a === undefined || b === undefined) { - return a === b; - } + get_equals (specEquals) { + return function equals (a, b) { + if (a === null || b === null || a === undefined || b === undefined) { + return a === b; + } - if (a === b) { - return true; - } + if (a === b) { + return true; + } - if (this.spec_equals) { - return this.spec_equals(a, b); - } + if (specEquals) { + return specEquals.call(this, a, b); + } - return typeof a.equals === "function" ? a.equals(b) : false; + return typeof a.equals === "function" ? a.equals(b) : false; + }; + } + static { + this.prototype.equals = this.prototype.get_equals(); } - /** - * @param {unknown} value - * @returns {unknown} - */ - parse (value) { - if (value === null || value === undefined) { - return value; - } + get_parse (specParse) { + return function parse (value) { + if (value === null || value === undefined) { + return value; + } - if (this.spec_parse) { - return this.spec_parse(value); - } + if (specParse) { + return specParse.call(this, value); + } - let Type = this.is; - if (!Type || value instanceof Type) { - return value; - } + let Type = this.is; + if (!Type || value instanceof Type) { + return value; + } - return callableBuiltins.has(Type) ? Type(value) : new Type(value); + return callableBuiltins.has(Type) ? Type(value) : new Type(value); + }; + } + static { + this.prototype.parse = this.prototype.get_parse(); } /** * Null/undefined produce `null` (signaling attribute removal). - * @param {unknown} value - * @returns {string | null} */ - stringify (value) { - if (value === null || value === undefined) { - return null; - } + get_stringify (specStringify) { + return function stringify (value) { + if (value === null || value === undefined) { + return null; + } - if (this.spec_stringify) { - return this.spec_stringify(value); - } + if (specStringify) { + return specStringify.call(this, value); + } - return String(value); + return String(value); + }; + } + static { + this.prototype.stringify = this.prototype.get_stringify(); } /** @@ -326,6 +322,28 @@ export default class PropType { let resolved = globalThis[is]; return typeof resolved === "function" ? resolved : is; } + + static { + this.prototype.super = this.prototype; + defineLazyProperty(this.prototype, "super", function () { + let self = this; + let proto = this.parent ? this.parent : this.constructor.prototype; + return new Proxy(Object.create(proto), { + get (target, key, receiver) { + let val = target[key]; + + if (typeof val !== "function" || Object.hasOwn(target, key)) { + return val; + } + + return (target[key] = function (...args) { + let thisArg = this === receiver ? self : this; + return val.apply(thisArg, args); + }); + }, + }); + }); + } } /** diff --git a/test/PropType.js b/test/PropType.js index 01c21a29..3e82be90 100644 --- a/test/PropType.js +++ b/test/PropType.js @@ -495,6 +495,61 @@ export default { }, ], }, + { + name: "super", + tests: [ + { + name: "super.X returns parent's data even when child overrides it", + check () { + class Box {} + PropType.register({ is: Box, hint: "from-parent" }); + let child = PropType.for({ is: Box, hint: "from-child" }); + let result = child.super.hint === "from-parent" && child.hint === "from-child"; + PropType.registry.delete(Box); + return result; + }, + }, + { + name: "super.method() runs parent's method with this = self", + check () { + class Box {} + let calledWith; + PropType.register({ + is: Box, + parse (value) { + calledWith = this; + return value; + }, + }); + let child = PropType.for({ is: Box, other: "extra" }); + child.super.parse("anything"); + let result = calledWith === child; + PropType.registry.delete(Box); + return result; + }, + }, + { + name: "super.parse() does not recurse when called from a child's parse override", + run () { + class Box {} + PropType.register({ + is: Box, + parse: value => "parent:" + value, + }); + let child = PropType.for({ + is: Box, + parse (value) { + return "child(" + this.super.parse(value) + ")"; + }, + }); + let result = child.parse("hi"); + PropType.registry.delete(Box); + return result; + }, + expect: "child(parent:hi)", + }, + ], + }, { name: "register()", tests: [ From b6742eb0083ecc1f5a1200f74d0ca0214cd50313 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 27 May 2026 09:51:59 -0400 Subject: [PATCH 26/27] Fix docs to match the get_X transform / this.super architecture - types/README: recommend this.super.method() (the workaround was backwards) - types/README: Length example uses this.unit, not this.spec.unit - types/README: "spread into array" instead of Array.from to match array.js - PropType.js: drop stale spec_ prefix JSDoc, describe get_ transforms and the super proxy - props/README: type-options table now lists separator/joiner/pairs and applies keys to dictionaries (Object extends MapType) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/README.md | 21 +++++++++++++-------- src/plugins/props/types/README.md | 8 ++++---- src/plugins/props/util/PropType.js | 23 +++++++++++++---------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/src/plugins/props/README.md b/src/plugins/props/README.md index 03b2d11a..00c5f90d 100644 --- a/src/plugins/props/README.md +++ b/src/plugins/props/README.md @@ -133,14 +133,19 @@ The `type` property can also take an object that sets both the type (via the `is listed below. All type options are optional. -| Property | Type | Applies to | Description | -| -------------- | ------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -| `is` | `Function` | `string` | `object` | _(All)_ | The type of the property. | -| `values` | `Function` | Lists (`Array`, `Set`), Dictionaries (`Object`, `Map`) | The type of the items in the list. | -| `keys` | `Function` | `Map` | The type of the keys in the dictionary. | -| `defaultKey` | `Function` | Dictionaries (`Object`, `Map`) | Default key for entries with no label. | -| `defaultValue` | (any) | Dictionaries (`Object`, `Map`) | Default value for entries with no label. Ignored if `defaultKey` is set. Default: `true` | -| `arguments` | `string[]` | `Function` | The names of the arguments of the function. Default: `[]` (no arguments) | +| Property | Type | Applies to | Description | +| -------------- | ------------------------------------------ | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `is` | `Function` | `string` | `object` | _(All)_ | The type of the property. | +| `values` | `Function` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | The type of the items in the list. | +| `keys` | `Function` | Dictionaries (`Object`, `Map`) | The type of the keys in the dictionary. | +| `separator` | `string` | `RegExp` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | Separator between items when parsing strings. Default: `,` (pair-aware). | +| `joiner` | `string` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | String used between items when stringifying. Defaults to a normalized form of `separator`. | +| `pairs` | `object` | Iterables (`Array`, `Set`), Dictionaries (`Object`, `Map`) | Override the pair-aware splitter's bracket/quote table. | +| `defaultKey` | `Function` | Dictionaries (`Object`, `Map`) | Default key for entries with no label. | +| `defaultValue` | (any) | Dictionaries (`Object`, `Map`) | Default value for entries with no label. Ignored if `defaultKey` is set. Default: `true` | +| `arguments` | `string[]` | `Function` | The names of the arguments of the function. Default: `[]` (no arguments) | + +See the [PropTypes reference](./types/README.md#built-in-types) for the full per-type breakdown. #### Default key/value in dictionaries diff --git a/src/plugins/props/types/README.md b/src/plugins/props/types/README.md index d59b3f70..ff25c48e 100644 --- a/src/plugins/props/types/README.md +++ b/src/plugins/props/types/README.md @@ -36,9 +36,9 @@ All built-ins can be accessed via `PropType.for(name)` (see [props README](../RE | `Iterable.parseItems` | Raw items: strings split via the pair-aware splitter, iterables consumed verbatim, scalars wrapped. No `values.parse` applied. | | `MapType.parseEntries` | Raw `[key, value]` tuples: built on `parseItems`, with `:`-splitting (escaped `\:` preserved) and shorthand-entry handling via `defaultKey` / `defaultValue` / `"false"`-coercion. | -Both are generators. Concrete types consume them with the appropriate terminal container constructor (`Array.from`, `new Set`, `new Map`, `Object.fromEntries`) so each input value flows through the chain exactly once — no intermediate arrays. +Both are generators. Concrete types consume them with the appropriate terminal container (spread into an array, `new Set`, `new Map`, `Object.fromEntries`) so each input value flows through the chain exactly once — no intermediate arrays. -To call a parent's method from inside an override, use `ParentType.spec.method.call(this, …args)`. Doing it via `this.super.method(…)` would re-bind `this` to the parent and lose access to your derivative's `this.values` / `this.spec.separator`. +To call a parent's method from inside an override, use `this.super.method(…)`. The `super` proxy walks the chain looking for the next implementation while keeping `this` bound to the derivative, so your override still sees its own `this.values` / `this.separator`. It also goes through the same `get_` wrappers as a normal call (e.g. the null-handling in `get_parse`), unlike a direct `ParentType.spec.method.call(this, …args)`. ## A parametrized custom type @@ -50,7 +50,7 @@ import { PropType } from "nude-element/props"; PropType.register({ is: Length, parse (value) { - let unit = this.spec.unit ?? "px"; + let unit = this.unit ?? "px"; return value instanceof Length ? value : new Length(value, unit); }, stringify: value => value?.toString(), @@ -65,7 +65,7 @@ static props = { }; ``` -`Pixels` and `Rems` are distinct PropType instances sharing the same `parse` (inherited via the prototype chain from the registered `Length` singleton), but each reads its own `this.spec.unit`. +`Pixels` and `Rems` are distinct PropType instances sharing the same `parse` (inherited via the prototype chain from the registered `Length` singleton), but each reads its own `this.unit` (every spec key is lifted onto the instance by `init()`). ## Public API diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 293cdbe6..305f24bc 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -26,27 +26,30 @@ const callableBuiltins = new Set([ * and delegates derivative construction to the constructor. * * Derivatives are created via `Object.create(parent)`, and every spec - * property is lifted onto the instance during {@link init} (overrides that - * collide with prototype names — `equals`, `parse`, `stringify`, etc. — - * are stored under a `spec_` prefix). Lookups then walk the JS prototype - * chain naturally: a derivative inherits whatever its parent set, and - * its own values shadow as expected. + * property is lifted onto the instance during {@link init}. For keys that + * collide with shared behavior on the prototype (`equals`, `parse`, + * `stringify`), {@link init} consults a matching `get_` transform on + * the prototype, which wraps the spec function with the shared null/identity + * short-circuits and super-walking behavior before lifting it. Lookups then + * walk the JS prototype chain naturally: a derivative inherits whatever its + * parent set, and its own values shadow as expected. * * The parent is picked from `spec.extends` if present, otherwise from the * registry entry for `spec.is`, allowing the chain parent to differ from * the produced JS type (e.g. `{is: Array, extends: Iterable}`). * - * The prototype `equals` / `parse` / `stringify` methods handle the shared - * null/identity short-circuits and then call `this.spec_(…)` if - * present, so type definitions stay free of boilerplate. + * Any other method on the spec is lifted verbatim; the {@link super} proxy + * lets it call into the next implementation up the chain via `this.super.x(…)` + * while keeping `this` bound to the original caller. * * @template {PropTypeSpec} [TSpec=PropTypeSpec] */ export default class PropType { /** * The spec object this instance was constructed with — stored verbatim, - * not cloned, alongside the lifted `spec_` copies of any method - * overrides it carried. + * not cloned. Every own key is also lifted onto the instance by + * {@link init} (method overrides via the matching `get_` transform + * when one exists, everything else by direct copy). * @type {PropTypeSpec | undefined} */ spec; From 1cbd7f4956d7839d814e32d0702816cc3842868f Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Wed, 27 May 2026 09:53:56 -0400 Subject: [PATCH 27/27] Tighten JSDoc in PropType.js The block comments had grown long enough to interfere with reading the code. Trimmed without dropping anything load-bearing. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/plugins/props/util/PropType.js | 87 +++++++++++------------------- 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/src/plugins/props/util/PropType.js b/src/plugins/props/util/PropType.js index 305f24bc..0f3439a2 100644 --- a/src/plugins/props/util/PropType.js +++ b/src/plugins/props/util/PropType.js @@ -19,46 +19,33 @@ const callableBuiltins = new Set([ * Type adapter for prop values: defines equality, parsing from raw input * (typically attribute strings), and stringification back to attributes. * - * A `PropType` instance is an *abstract*, prop-agnostic type definition. - * The {@link registry} holds every registered type — concrete types are keyed - * on their JS constructor (`is`), abstract types on their string `name`. - * {@link PropType.for} resolves any user-facing identifier into a PropType - * and delegates derivative construction to the constructor. + * Instances are abstract, prop-agnostic type definitions. The {@link registry} + * keys concretes by JS constructor (`is`) and abstracts by `name`; derivatives + * are `Object.create(parent)` so option lookup walks the prototype chain. + * The parent comes from `spec.extends` or, failing that, `registry.get(spec.is)` + * — so e.g. `{is: Array, extends: Iterable}` decouples the chain parent from + * the produced JS type. * - * Derivatives are created via `Object.create(parent)`, and every spec - * property is lifted onto the instance during {@link init}. For keys that - * collide with shared behavior on the prototype (`equals`, `parse`, - * `stringify`), {@link init} consults a matching `get_` transform on - * the prototype, which wraps the spec function with the shared null/identity - * short-circuits and super-walking behavior before lifting it. Lookups then - * walk the JS prototype chain naturally: a derivative inherits whatever its - * parent set, and its own values shadow as expected. - * - * The parent is picked from `spec.extends` if present, otherwise from the - * registry entry for `spec.is`, allowing the chain parent to differ from - * the produced JS type (e.g. `{is: Array, extends: Iterable}`). - * - * Any other method on the spec is lifted verbatim; the {@link super} proxy - * lets it call into the next implementation up the chain via `this.super.x(…)` - * while keeping `this` bound to the original caller. + * {@link init} lifts every spec key onto the instance. Method overrides + * (`equals` / `parse` / `stringify`) go through their matching `get_` + * transform on the prototype, which wraps them with the shared null/identity + * short-circuits and super-walking. Other methods are lifted verbatim and can + * call into the next implementation via the {@link super} proxy. * * @template {PropTypeSpec} [TSpec=PropTypeSpec] */ export default class PropType { /** - * The spec object this instance was constructed with — stored verbatim, - * not cloned. Every own key is also lifted onto the instance by - * {@link init} (method overrides via the matching `get_` transform - * when one exists, everything else by direct copy). + * The spec this instance was constructed with — stored verbatim, not cloned. + * Every own key is also lifted onto the instance by {@link init}. * @type {PropTypeSpec | undefined} */ spec; /** - * Resolve sub-types and store them as own properties so type-specific - * code reads them as `this.values` / `this.keys` (the resolved PropType instances). - * Unspecified sub-types default to {@link PropType.any} - * so consumers can use type methods unconditionally. + * Spec keys whose values are sub-types (e.g. `["values"]`, `["keys", "values"]`). + * {@link init} resolves them to `PropType` instances on the instance, defaulting + * to {@link PropType.any} so `this.values.parse(v)` works unconditionally. * @type {Array | undefined} */ subTypes; @@ -205,10 +192,9 @@ export default class PropType { } /** - * Is this type a kind of `other` — i.e. is `other` somewhere in the - * super chain (or is it `this` itself)? Replaces `instanceof` checks - * that no longer apply now that abstract types are PropType instances - * rather than JS classes. + * Is this type a kind of `other` — i.e. is `other` `this` or anywhere up + * its super chain? Replaces `instanceof` checks (abstract types aren't + * JS classes). * @param {PropType} other * @returns {boolean} */ @@ -244,10 +230,8 @@ export default class PropType { static any = new PropType(); /** - * Register a type: constructs an instance and stores it in - * {@link PropType.registry} keyed on `spec.is` (constructor) or - * `spec.name` (for abstract types with no `is`). Returns the - * registered instance. + * Construct a type and store it in {@link PropType.registry}, keyed on + * `spec.is` (constructor) or `spec.name` (abstract). * @param {PropTypeSpec} spec * @returns {PropType} */ @@ -259,16 +243,10 @@ export default class PropType { } /** - * Resolve any user-facing type identifier into a {@link PropType}. - * - * - PropType instance → returned as-is. - * - constructor → registry lookup, falling back to a fresh `{is: input}` - * derivative so unregistered constructors still carry their `is`. - * - string → resolved via `globalThis` first, then registry; bare strings - * that match neither hit `options.fallback` (typo / missing import). - * - object spec → constructor short-circuits to the singleton if no - * extras, otherwise builds a derivative. - * - null / undefined → `options.fallback` (default: the generic instance). + * Resolve any user-facing type identifier into a {@link PropType}: + * PropType (as-is), constructor (registry → fresh `{is}` derivative), + * string (`globalThis` → registry → `fallback`), spec object (singleton + * if bare, otherwise a derivative), or nullish (`fallback`). * * @param {SpecifiedType} input * @param {{ fallback?: PropType }} [options] @@ -311,10 +289,9 @@ export default class PropType { } /** - * Resolve an `is` identifier to its registry key. Strings are first tried - * as `globalThis` lookups (catches built-in constructors like `"Array"`); - * anything else (including named-only abstracts like `"Iterable"`) passes - * through as the bare string. + * Resolve an `is` identifier to its registry key: strings are looked up on + * `globalThis` (catches built-ins like `"Array"`); anything else passes + * through as-is (including named-only abstracts like `"Iterable"`). * @param {Function | string | undefined} is * @returns {Function | string | undefined} */ @@ -356,11 +333,9 @@ export default class PropType { * when the parent differs from `registry.get(is)` (e.g. concrete types * extending an abstract). * @property {string} [name] Registry key for abstract types with no `is`. - * @property {string[]} [subTypes] Spec keys whose values are themselves type - * specs and should be resolved to PropType instances at construction time. - * Inherited from the nearest ancestor that declares it — the child's list - * replaces (not extends) the parent's. Unspecified keys default to - * {@link PropType.any}. + * @property {string[]} [subTypes] Spec keys whose values are sub-type specs, + * resolved to PropType instances at construction. A child's list replaces + * (not extends) the parent's. Unspecified keys default to {@link PropType.any}. * @property {(this: PropType, a: unknown, b: unknown) => boolean} [equals] * @property {(this: PropType, value: unknown) => unknown} [parse] * @property {(this: PropType, value: unknown) => (string | null)} [stringify]