Reserve static names for props and reject keys shared by element and elements

This commit is contained in:
Buddy 2026-11-06 14:10:53 -08:00
parent 6efcdd210e
commit a398fc98f1
3 changed files with 73 additions and 18 deletions

View File

@ -24,6 +24,7 @@ export type {
PropChange, PropChange,
PropNameConflict, PropNameConflict,
PropValues, PropValues,
RefNameConflict,
RefType, RefType,
Refs, Refs,
Spec, Spec,
@ -33,4 +34,5 @@ export type {
WebComponentConstructor, WebComponentConstructor,
WebComponentInstance, WebComponentInstance,
WebComponentMembers, WebComponentMembers,
WebComponentStatics,
} from './types.ts'; } from './types.ts';

View File

@ -176,15 +176,20 @@ export type WebComponentInstance<S> = InstanceType<BaseOf<S>> &
PropValues<PropsOf<S>> & PropValues<PropsOf<S>> &
WebComponentMembers<S>; WebComponentMembers<S>;
/** The constructor type {@link WebComponent} returns. */ /** The static members {@link WebComponent} adds to the generated class. */
export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> & { export interface WebComponentStatics<S> {
new (): WebComponentInstance<S>;
readonly prototype: WebComponentInstance<S>;
readonly observedAttributes: readonly ObservedAttributes<S>[]; readonly observedAttributes: readonly ObservedAttributes<S>[];
/** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */ /** Prop key → attribute name: `El.attributeFor.fetch` is `'disable-fetch'`. */
readonly attributeFor: AttributeMap<S>; readonly attributeFor: AttributeMap<S>;
readonly [spec]?: S; }
};
/** The constructor type {@link WebComponent} returns. */
export type WebComponentConstructor<S> = Omit<BaseOf<S>, 'prototype'> &
WebComponentStatics<S> & {
new (): WebComponentInstance<S>;
readonly prototype: WebComponentInstance<S>;
readonly [spec]?: S;
};
/** The spec behind a {@link WebComponent} class (or subclass), or `T` itself when it is already a spec. */ /** The spec behind a {@link WebComponent} class (or subclass), or `T` itself when it is already a spec. */
export type SpecOf<T> = T extends abstract new (...args: never) => unknown export type SpecOf<T> = T extends abstract new (...args: never) => unknown
@ -199,30 +204,53 @@ export type AttributesOf<T> = ObservedAttributes<SpecOf<T>>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Validation // Validation
/** Mixin members a prop may not shadow. */
type MixinMember = keyof WebComponentMembers<unknown>;
/** /**
* Every name a prop may not use. `attributeFor` is static, so it wouldn't * Every name a prop may not use: members of the base instance (`title`,
* collide at runtime; it's reserved so `El.attributeFor` and a prop can't be * `hidden`, `id` on `HTMLElement`; `value` on a form-ish base), statics of the
* confused. * base class, and the members and statics the mixin adds. The static names
* wouldn't collide at runtime (props live on instances); they're reserved so
* `El.attributeFor` / `El.observedAttributes` and a prop can't be confused.
*/ */
type ReservedName = MixinMember | 'attributeFor'; type ReservedName<S> =
| keyof InstanceType<BaseOf<S>>
| keyof BaseOf<S>
| keyof WebComponentMembers<unknown>
| keyof WebComponentStatics<unknown>;
/** Branded error type: shows up in the message when a prop name collides. */ /** Branded error type: shows up in the message when a prop name collides. */
export type PropNameConflict<K extends PropertyKey> = export type PropNameConflict<K extends PropertyKey> =
`Error: prop "${K & string}" conflicts with an existing element member`; `Error: prop "${K & string}" conflicts with an existing element member`;
/** /**
* Intersected with the spec parameter. Any prop whose name already exists on * Branded error type: shows up in the message when `element` and `elements`
* the base instance (`title`, `hidden`, `id` on `HTMLElement`; `value` on a * share a key. An object rather than a string literal because the selector is
* form-ish base), on the mixin, or is otherwise reserved, is retyped to {@link PropNameConflict}, * itself a string literal, and two string literals intersect to `never`, which
* so the error lands on that key. * would hide the message.
*/
export type RefNameConflict<K extends PropertyKey> = {
readonly [M in `Error: ref "${K & string}" is in both element and elements`]: never;
};
/**
* Intersected with the spec parameter, so each error lands on the offending
* key: a prop with a {@link ReservedName} is retyped to {@link PropNameConflict},
* and a key in both `element` and `elements` (both become `refs.<key>`) is
* retyped to {@link RefNameConflict} on each side.
*/ */
export type ValidateSpec<S> = { export type ValidateSpec<S> = {
readonly props?: { readonly props?: {
readonly [K in keyof PropsOf<S>]: K extends keyof InstanceType<BaseOf<S>> | ReservedName readonly [K in keyof PropsOf<S>]: K extends ReservedName<S>
? PropNameConflict<K> ? PropNameConflict<K>
: PropsOf<S>[K]; : PropsOf<S>[K];
}; };
readonly element?: {
readonly [K in keyof ElementRefsOf<S>]: K extends keyof ElementsRefsOf<S>
? RefNameConflict<K>
: ElementRefsOf<S>[K];
};
readonly elements?: {
readonly [K in keyof ElementsRefsOf<S>]: K extends keyof ElementRefsOf<S>
? RefNameConflict<K>
: ElementsRefsOf<S>[K];
};
}; };

View File

@ -159,6 +159,8 @@ describe('conflicts', () => {
WebComponent({ props: { signal: string() } }); WebComponent({ props: { signal: string() } });
// @ts-expect-error -- `attributeFor` is reserved for the static lookup // @ts-expect-error -- `attributeFor` is reserved for the static lookup
WebComponent({ props: { attributeFor: string() } }); WebComponent({ props: { attributeFor: string() } });
// @ts-expect-error -- `observedAttributes` is a static of the class
WebComponent({ props: { observedAttributes: string() } });
}); });
test('conflicts are checked against the base class', () => { test('conflicts are checked against the base class', () => {
@ -168,8 +170,31 @@ describe('conflicts', () => {
// @ts-expect-error -- `sparkle` is on the base // @ts-expect-error -- `sparkle` is on the base
WebComponent({ base: Fancy, props: { sparkle: enable() } }); WebComponent({ base: Fancy, props: { sparkle: enable() } });
class WithStatic extends HTMLElement {
static shine = 1;
}
// @ts-expect-error -- `shine` is a static on the base
WebComponent({ base: WithStatic, props: { shine: enable() } });
const El = WebComponent({ base: Fancy, props: { glow: enable() } }); const El = WebComponent({ base: Fancy, props: { glow: enable() } });
expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>(); expectTypeOf(new El().sparkle).toEqualTypeOf<boolean>();
expectTypeOf(new El().glow).toEqualTypeOf<boolean>(); expectTypeOf(new El().glow).toEqualTypeOf<boolean>();
}); });
}); });
describe('ref conflicts', () => {
test('a key in both element and elements is rejected', () => {
WebComponent({
// @ts-expect-error -- `input` is also in elements
element: { input: 'input', button: 'button' },
// @ts-expect-error -- `input` is also in element
elements: { input: 'textarea', items: 'li' },
});
});
test('distinct keys are fine', () => {
const El = WebComponent({ element: { input: 'input' }, elements: { inputs: 'input' } });
expectTypeOf(new El().refs.input).toEqualTypeOf<HTMLInputElement | null>();
expectTypeOf(new El().refs.inputs).toEqualTypeOf<HTMLInputElement[]>();
});
});