Skip to content

PropType class#127

Merged
LeaVerou merged 27 commits into
rollback-signalsfrom
types-refactor
May 27, 2026
Merged

PropType class#127
LeaVerou merged 27 commits into
rollback-signalsfrom
types-refactor

Conversation

@LeaVerou

@LeaVerou LeaVerou commented May 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Rewrite of the prop-type system. Types are now first-class PropType instances composed via JS prototype-chain inheritance, replacing the previous design where types were entries in an opaque map of {equals, parse, stringify} method packs keyed on JS constructor.

The motivating problem: with method-pack registrations, there was no Type instance, no inheritance, no shared behavior between Array/Set or Object/Map without per-method duplication. Derivative configurations like {is: Array, values: Number} were re-resolved through ad-hoc spec merging on every lookup.

Architecture

One class. PropType is the sole class. Type instances hold their spec verbatim and use Object.create(parent) for derivatives — every option lookup walks the JS prototype chain, no merging, no copying.

Registry, single map. Keyed on JS constructor (is) for concrete types and on string name for abstract types. PropType.for(input, {fallback}) resolves any user-facing identifier (PropType instance, constructor, string, spec object) to the right instance: registered singleton, fresh derivative for specs with extras, or fallback.

Abstract vs concrete. Abstracts (currently just IterableType) are registered with name and no is — they exist to be inherited from, not to claim a JS constructor. Concrete types use extends: <abstract> to decouple their chain parent from is, e.g. {is: Array, extends: IterableType}.

One dispatch chain. PropType.prototype.equals/parse/stringify walk obj.super, dispatching to the first override found on obj.spec. The same walker handles auto-wrapped helper methods (e.g. parseItems, parseEntries), so abstracts can publish arbitrary helpers and descendants invoke them as plain this.x(...).

Streaming pipeline. IterableType.parseItems (raw splitter) and IterableType.parse (typed) are both generators. MapType extends IterableType, reusing inherited this.parseItems(value) for raw string parts and splitting each on : in its own parseEntries to yield [key, value] tuples. Concrete types (array.js, set.js, map.js, object.js) consume the iterator with the appropriate container constructor (Array.from, new Set, new Map, Object.fromEntries). Each input value flows through the chain exactly once, no intermediate arrays.

Map IS the dictionary. There's no separate DictionaryType abstract — Map is the canonical JS dictionary, Object a constrained realization. ObjectType extends MapType, inheriting the entry-parsing pipeline.

Spec keys

Key Role
is JS constructor this type produces; doubles as registry key. Optional for abstracts.
extends Explicit chain parent. Lets the parent differ from registry.get(is) (e.g. concretes extending an abstract). Accepts a PropType instance or a name string.
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 keys default to PropType.any (inherited via the prototype chain), so this.values.parse(v) works unconditionally.
equals/parse/stringify Method overrides with shared null/identity short-circuits.
custom methods Auto-wrapped at construction into super-walking dispatchers, callable as this.x(...).

Public API additions

  • PropType.for(input, { fallback }) — universal type resolver.
  • PropType.register(spec) — register a built-in or custom type.
  • instance.isA(otherType) — chain membership check (replaces the instanceof checks that don't apply when abstracts aren't JS classes).
  • import { PropType, Iterable } from "nude-element/props" for extension. Other built-in singletons can be reached via PropType.for(name).
  • Symbol.toStringTag on PropType so console.log(arrayType) shows ArrayType.

File layout (under src/plugins/props/)

  • util/PropType.js — the class.
  • util/split.js — shared string splitter (pair-aware, fault-tolerant).
  • types/iterable.js — abstract.
  • types/array.js, set.js, map.js, object.js — one file per concrete iterable/dictionary type.
  • types/basic.jsBoolean, Number, Function.

Behavior changes worth flagging

  • The auto-spacing heuristic in joinItems is gone — custom separators are used verbatim. Default separator stays ", " so the common case is unchanged.

Test plan

  • npm test — 163/167 pass (4 pre-existing skips, 0 failures).
  • New test/PropType.js covers for() / register() / derivation / dispatch / built-in primitives / list & dict behavior / nested composition / isA.
  • Manual sanity check on a consumer using extends: IterableType.
  • Confirm Map/Object derivatives with custom keys/values still round-trip cleanly.

🤖 Generated with Claude Code

@LeaVerou LeaVerou changed the title Collapse PropType system to a single class PropType class May 23, 2026
@LeaVerou LeaVerou marked this pull request as ready for review May 24, 2026 16:12
@LeaVerou LeaVerou requested a review from DmitrySharabin May 24, 2026 16:12
LeaVerou and others added 14 commits May 24, 2026 14:43
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) <noreply@anthropic.com>
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: <abstract>` 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Because Claude f*ed up.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Still needs work but oh well
Comment thread src/plugins/props/util/PropType.js Outdated
Comment thread src/plugins/props/util/PropType.js Outdated
Comment thread src/plugins/props/util/PropType.js Outdated
Comment thread src/plugins/props/util/PropType.js
Comment thread src/plugins/props/util/PropType.js
Comment thread src/plugins/props/util/split.js
Comment thread src/plugins/props/README.md Outdated
Comment thread src/plugins/props/types/README.md Outdated
@DmitrySharabin

DmitrySharabin commented May 25, 2026

Copy link
Copy Markdown
Member

I left a few comments and proposals and submitted a PR with a few more fixes.

I also tested it with the color elements. There was only one change needed to adopt the changes introduced by this PR: to register the Color type before defining the ColorElement class.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This file is dead code now. Should we just remove it?

@DmitrySharabin

Copy link
Copy Markdown
Member

A few spots where the docs describe the mechanism slightly differently from what the code does. The user-facing behavior described is correct in each case — it's the "how" that's off.


1. "Auto-wrapped … super-walking dispatcher" (spec-keys table)

types/README.md L30:

any other method — Auto-wrapped at construction into a super-walking dispatcher, callable as this.x(…) from anywhere in the chain.

In init() (PropType.js L126-132), non-prototype keys are assigned directly (this[key] = spec[key]) — no wrapping, no dispatcher. They're inherited by derivatives through the plain JS prototype chain (Object.create(parent)), which is why this.x(…) works from anywhere in the chain. But the mechanism is prototype inheritance, not auto-wrapping.

Possible fix: "Stored as own properties at construction and inherited by derivatives via the prototype chain, so this.x(…) resolves to the nearest ancestor's implementation."


2. Architecture paragraph — "The dispatcher walks obj.super"

types/README.md L81:

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.

obj.super is only walked by isA(). The equals/parse/stringify prototype methods read this.spec_<name>, which resolves through the JS prototype chain set up by Object.create — finding the first override in the chain. Custom helpers are stored as own properties on the registered instance and inherited the same way.

Possible fix: "Each prototype method (equals/parse/stringify) reads this.spec_<name>, which resolves through the JS prototype chain set up by Object.create(parent) — the first override in the chain wins. Custom helpers (parseItems, parseEntries) are own properties that propagate to derivatives the same way, so this.x(…) always finds the nearest ancestor's implementation."


3. MapType in the "Abstract helpers" table

types/README.md L37 and the section heading "Abstract helpers":

MapType is a concrete type (is: Mapmap.js L17-18), not an abstract. It does serve as a base for Object, but it has an is. The table heading "Abstract helpers" groups it with the genuinely abstract Iterable.

Also, MapType is a local variable in map.js — it's not exported under that name from any public entry point. nude-element/plugins/types exports it as Map. The same applies to props/README.md L173, which recommends extends: MapType in prose.

Possible fix: rename the section to something like "Helpers from base types", and either export MapType as a named export from types/index.js, or refer to it as Types.Map in the docs (matching what users can actually import).


4. Terminal constructors — "Array.from"

types/README.md L39:

Concrete types consume them with the appropriate terminal container constructor (Array.from, new Set, new Map, Object.fromEntries)

Array uses spread ([...this.parsedItems(value)]array.js L8), not Array.from. Set/Map use new this.is(…).

Possible fix: "an appropriate terminal consumer (spread, new this.is(…), Object.fromEntries)" or just drop the parenthetical list.


— flagged by @claude

@LeaVerou

Copy link
Copy Markdown
Contributor Author

I left a few comments and proposals and submitted a PR with a few more fixes.

I also tested it with the color elements. There was only one change needed to adopt the changes introduced by this PR: to register the Color type before defining the ColorElement class.

Could you elaborate on this? It should not need registration, the any type should work fine for that.

LeaVerou and others added 3 commits May 26, 2026 13:23
Co-Authored-By: Dmitry Sharabin <dmitrysharabin@gmail.com>
LeaVerou and others added 2 commits May 26, 2026 14:47
LeaVerou and others added 3 commits May 26, 2026 15:16
…ctly

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.
…ctors

Co-Authored-By: Claude <claude@users.noreply.github.com>
@DmitrySharabin

DmitrySharabin commented May 26, 2026

Copy link
Copy Markdown
Member

I left a few comments and proposals and submitted a PR with a few more fixes.
I also tested it with the color elements. There was only one change needed to adopt the changes introduced by this PR: to register the Color type before defining the ColorElement class.

Could you elaborate on this? It should not need registration, the any type should work fine for that.

The issue is with parse. In the old system, generic.parse(value, type) received the type spec as a second argument, so even for unregistered constructors it could do new Type(value). In the new system, PropType.for(Color) looks up Color in the registry, doesn't find it, and returns PropType.any — which has no is, so parse() just passes the value through as-is.

Several color-elements props declare type: Color, relying on auto-instantiation from attribute strings. Without registration, those props hold the raw string "red" instead of a Color instance.

PropType.register({ is: Color }) restores the old behavior by giving the type an is that parse() can use for new Color(value).

That said, this could also be fixed upstream — PropType.for() could auto-create a minimal PropType({ is: Constructor }) for unregistered constructors instead of falling back to any, which would match the old behavior without requiring consumers to register every custom class.

This bug is the last blocker.

LeaVerou and others added 3 commits May 26, 2026 20:57
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) <noreply@anthropic.com>
@DmitrySharabin

Copy link
Copy Markdown
Member

The issue with unregistered constructors is fixed. Verified with the color elements: No need to register Color anymore.

@DmitrySharabin

Copy link
Copy Markdown
Member

Docs-vs-code review

Flagged by Claude and Codex.

P2 — this.super warning is backwards

types/README.md line 41 says:

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.

The super-proxy does the opposite — it explicitly preserves the child's this:

let thisArg = this === receiver ? self : this;
return val.apply(thisArg, args);

The tests confirm this works correctly. The recommended workaround (ParentType.spec.method.call(this, …args)) hard-codes the parent and bypasses the get_ wrappers (e.g. null-handling in get_parse), so it's the worse option. This will actively mislead users — suggest flipping the guidance to recommend this.super.method(…).

P2 — Stale spec_ prefix in PropType JSDoc

PropType.js lines 28–41, 48 describe a spec_ storage/dispatch mechanism:

"overrides that collide with prototype names … are stored under a spec_ prefix" / "then call this.spec_<name>(…) if present"

No spec_ properties exist in the code. The actual mechanism is get_ transforms (get_equals, get_parse, get_stringify) that close over the spec function in a wrapper. If these JSDoc comments feed generated API docs, they'll describe an architecture that doesn't exist.

P2 — Non-existent import path in PR description

The PR body claims:

import * as Types from "nude-element/plugins/types" exports the built-in singletons under their JS constructor names

There's no "./plugins/types" in package.json exports. The actual export is "./props"src/plugins/props/index.js, and types/index.js only exports PropType and Iterable, not named singletons like Types.Array.

P3 — Props README type-options table is stale

The existing table at README.md line 136:

  • Lists keys as applying only to Map, but Object now extends MapType and inherits subTypes: ["keys", "values"].
  • Omits separator, joiner, and pairs, which are used by all iterable/dict types.
  • The types/README built-in types table has the correct info. Either update this table to match or make it a brief summary that explicitly defers to the full reference.

P3 — Array.from in docs vs spread in code

Both the types/README line 39 and the PR description claim Array.from as a terminal constructor, but array.js uses [...this.parsedItems(value)].

P3 — Length example teaches this.spec.unit instead of this.unit

The example at types/README.md line 53 uses this.spec.unit ?? "px". Since init() lifts all spec keys onto the instance, the idiomatic access is this.unit — consistent with how all built-in types access their options (this.separator, this.defaultKey, etc.). this.spec.unit works but will confuse anyone reading the built-in source.

- 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_<name> 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) <noreply@anthropic.com>
@LeaVerou LeaVerou requested a review from DmitrySharabin May 27, 2026 13:52
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) <noreply@anthropic.com>

@DmitrySharabin DmitrySharabin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we fixed all that we spotted during the reviews. If not, we'll fix it in the follow-up work. LGTM!

@LeaVerou LeaVerou merged commit b5d805c into rollback-signals May 27, 2026
@LeaVerou LeaVerou deleted the types-refactor branch May 27, 2026 15:07
@LeaVerou LeaVerou mentioned this pull request May 27, 2026
2 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants