TypeScript
Base takes an optional props type. It types $refs and $options, and it checks the event names and payloads of $emit().
The props type
import { Base } from '@studiometa/js-toolkit';
class Slider extends Base<{
$el: HTMLDivElement;
$refs: {
next: HTMLButtonElement;
items: HTMLElement[];
};
$options: {
speed: number;
loop: boolean;
};
$emits: {
goto: { index: number };
stop: void;
};
}> {
static config = {
name: 'Slider',
refs: ['next', 'items[]'],
options: {
speed: { type: Number, default: 1 },
loop: { type: Boolean, default: true },
},
};
mounted() {
this.$refs.next.disabled = true;
this.$refs.items[0];
this.$options.speed;
this.$emit('goto', { index: 1 });
this.$emit('stop');
}
}Four keys, all optional:
| Key | Types |
|---|---|
$el | the root element |
$refs | each declared ref — an array for a list ref |
$options | each declared option |
$emits | each event name to its payload object, or void for none |
$emits replaces the runtime config.emits of v3. Nothing of it stays in the bundle.
Extending a component
A component can take a props parameter of its own. This is how one component extends another:
import { Base, type BaseConfig, type BaseProps } from '@studiometa/js-toolkit';
interface ActionProps extends BaseProps {
$options: { target: string };
}
class Action<T extends BaseProps = BaseProps> extends Base<ActionProps & T> {
// Annotated, so a subclass is free to declare a different config.
static config: BaseConfig = {
name: 'Action',
options: { target: String },
};
mounted() {
this.$options.target; // string
}
}
class SafeAction extends Action<{ $options: { confirm: boolean } }> {
static config = {
name: 'SafeAction',
options: { confirm: Boolean },
};
mounted() {
this.$options.target; // still string
this.$options.confirm; // boolean
}
}Each prop is read as an intersection with its default, such as T['$options'] & Record<string, unknown>.
The price of the intersection
An option or a ref a component does not declare reads as unknown, or as HTMLElement | HTMLElement[], rather than as an error. Declared props keep their exact types. This is the cost of making extension work, and it was chosen deliberately over a conditional type.
$options is read through the same intersection and then mapped to Readonly<…>, which has one price of its own: a mapped type over a props parameter is deferred, so inside a class generic in its props a declared option is a usable value of its declared type rather than a type identical to it. Reading it, passing it and annotating it all work; asserting its identity, or assigning one option to a variable inferred from another, needs an annotation.
src/props.spec.ts holds the assertions, and npm run lint:types enforces them.
Handler payloads
A method named by convention is not typed by convention — the name is resolved at runtime. Annotate the payload:
class Parent extends Base {
static config = { name: 'Parent', components: { Child }, refs: ['dots[]'] };
onChildOpen({ target, payload }: DelegatedEvent<Child, 'open'>) {}
onDotsClick({ target, index }: RefEvent<HTMLButtonElement>) {}
onWindowResize({ event, target }: GlobalEvent<UIEvent>) {}
}The @on decorator does not remove the annotation — it checks it. The decorator derives the payload type its target implies and the method signature has to match, so a wrong annotation is a type error rather than a silent mismatch:
@component({ name: 'Parent', components: { Child } })
class Parent extends Base {
@on(Child, 'open')
onOpen({ payload }: DelegatedEvent<Child, 'open'>) {
payload.height; // number
}
}A decorator cannot infer a method's own parameters — the method signature is checked against what the decorator expects, not derived from it. Annotate the payload either way; with @on the annotation is verified against a real target.
Config typing
$config walks the prototype chain and merges every config it finds, so an intermediate class must not narrow the type its subclasses need.
Left alone, static config = { … } infers a literal type, and every subclass has to match it — a subclass that adds an option gets Class static side 'typeof SafeAction' incorrectly extends base class static side 'typeof Action'. Annotate the intermediate class instead:
import { Base, type BaseConfig } from '@studiometa/js-toolkit';
class AbstractControl extends Base {
static config: BaseConfig = {
name: 'AbstractControl',
refs: ['button'],
};
}Compiler options
{
"compilerOptions": {
"target": "ES2022",
"module": "Preserve",
"moduleResolution": "bundler",
"experimentalDecorators": false,
"useDefineForClassFields": true,
"strict": true
}
}Stage-3 decorators need no flag on TypeScript 5. experimentalDecorators must be off.
JSDoc
The props type works from JSDoc too, for a project with no .ts files:
/**
* @extends {Base<{ $refs: { output: HTMLElement }, $options: { step: number } }>}
*/
class Counter extends Base {
static config = {
name: 'Counter',
refs: ['output'],
options: { step: { type: Number, default: 1 } },
};
}