Skip to content

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

ts
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:

KeyTypes
$elthe root element
$refseach declared ref — an array for a list ref
$optionseach declared option
$emitseach 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:

ts
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:

ts
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:

ts
@
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:

ts
import { 
Base
, type BaseConfig } from '@studiometa/js-toolkit';
class
AbstractControl
extends
Base
{
static
config
: BaseConfig = {
name
: 'AbstractControl',
refs
: ['button'],
}; }

Compiler options

json
{
  "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:

js
/**
 * @extends {Base<{ $refs: { output: HTMLElement }, $options: { step: number } }>}
 */
class Counter extends Base {
  static config = {
    name: 'Counter',
    refs: ['output'],
    options: { step: { type: Number, default: 1 } },
  };
}

MIT Licensed