Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
faccd05
Refactor types to prototype-chain inheritance
LeaVerou May 22, 2026
e67a67a
Update PropType.js
LeaVerou May 22, 2026
c17f503
Collapse type system to a single PropType class
LeaVerou May 22, 2026
b584c4d
Update Custom types section + add types/README reference
LeaVerou May 23, 2026
e19f344
Rename IterableType to Iterable
LeaVerou May 23, 2026
b1962d5
Prettier
LeaVerou May 23, 2026
a535088
Revert empty checks to previous logic
LeaVerou May 23, 2026
ced4d5a
Rewrite PropType.js
LeaVerou May 23, 2026
c67b545
Lift spec methods onto instances; let JS prototype chain do dispatch
LeaVerou May 23, 2026
313327d
Use this.is
LeaVerou May 23, 2026
4e2e042
Update map.js
LeaVerou May 24, 2026
86c75aa
Note Iterator.map() inlining opportunity on parsedItems/parsedEntries
LeaVerou May 24, 2026
be3465e
Update PropType.js
LeaVerou May 24, 2026
f0cd471
Rewrite tests
LeaVerou May 24, 2026
01c0e44
JSDoc changes from #128
LeaVerou May 26, 2026
5f842a3
Fix exports and docs
LeaVerou May 26, 2026
441250f
Delete misleading comment
LeaVerou May 26, 2026
4efb432
Split basic.js into boolean.js, number.js, function.js
LeaVerou May 26, 2026
7ee136e
Type specs should not reference this.spec, just this
LeaVerou May 26, 2026
d11badf
Update README.md
LeaVerou May 26, 2026
2040342
Test that unregistered constructors carry their `is` and behave corre…
LeaVerou May 26, 2026
f99706c
Fix PropType.for() to construct a derivative for unregistered constru…
LeaVerou May 26, 2026
186093e
Cache on-the fly types
LeaVerou May 27, 2026
cfb349d
Better `isA()` implementation
LeaVerou May 27, 2026
84c97db
Add this.super, restructure dispatch via get_X transforms
LeaVerou May 27, 2026
b6742eb
Fix docs to match the get_X transform / this.super architecture
LeaVerou May 27, 2026
1cbd7f4
Tighten JSDoc in PropType.js
LeaVerou May 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
"./props": "./src/plugins/props/index.js"
},
"repository": {
"type": "git",
Expand Down
79 changes: 71 additions & 8 deletions src/plugins/props/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -153,6 +158,64 @@ 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 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).

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)),
equals: (a, b) => a === b || a?.equals?.(b),
stringify: value => value?.toString(),
});
```

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";

PropType.register({
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.

```js
import { PropType } from "nude-element/props";

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.

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

The `reflect` property takes the following values:
Expand Down
6 changes: 6 additions & 0 deletions src/plugins/props/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
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 { PropType, Props, Prop, ElementProps, ElementProp };

const hooks = {
setup () {
if (Object.hasOwn(this, "props")) {
Expand Down
81 changes: 81 additions & 0 deletions src/plugins/props/types/README.md
Original file line number Diff line number Diff line change
@@ -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 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. |

## 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. |

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 `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

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/props";

PropType.register({
is: Length,
parse (value) {
let unit = this.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.unit` (every spec key is lifted onto the instance by `init()`).

## 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(…)`.
10 changes: 10 additions & 0 deletions src/plugins/props/types/array.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import PropType from "../util/PropType.js";
import Iterable from "./iterable.js";

export default PropType.register({
is: Array,
extends: Iterable,
parse (value) {
return [...this.parsedItems(value)];
},
});
67 changes: 0 additions & 67 deletions src/plugins/props/types/basic.js

This file was deleted.

11 changes: 11 additions & 0 deletions src/plugins/props/types/boolean.js
Original file line number Diff line number Diff line change
@@ -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;
},
});
Loading