Skip to content

Decorators

Every decorator is a thin wrapper over a function API that works without it. No engine ships stage-3 decorators, so a page that loads the package from an ESM CDN keeps registerComponent, $provide, $watchChildren, $read, $write and the on<Child><Event> method names.

Decorators are sugar. They are never a requirement.

The six

DecoratorWrapsNotes
@component({ name })static config + registerComponent()Registers as soon as the class is defined.
@on(target, type) / @on(type)the on<Child><Event> namesThe target is a name or a value.
@provide(key) / @inject(key)$provide() / $inject()The shape of Lit's @provide and @consume.
@children(nameOrClass, callbacks)$watchChildren()Exact name, or constructor and subclasses. Callbacks are bound to the instance.
@read / @write$read() / $write()Runs the method body in that phase, cancelled on unmount.

Each value decorator works on a plain field and on an accessor field.

A component with decorators

ts
import {
  
Base
,
children
,
component
,
on
,
type
ChildrenCollection
,
type
DelegatedEvent
,
} from '@studiometa/js-toolkit'; @
component
({
name
: 'AccordionItem' })
class
AccordionItem
extends
Base
<{
$emits
: {
open
: void } }> {
@
on
('click')
toggle
() {
this.
$emit
('open');
} } @
component
({
name
: 'Accordion',
components
: {
AccordionItem
} })
class
Accordion
extends
Base
{
@
children
(
AccordionItem
)
items
!:
ChildrenCollection
<
AccordionItem
>;
@
on
(
AccordionItem
, 'open')
closeOthers
({
target
}:
DelegatedEvent
<
AccordionItem
, 'open'>) {
for (const
item
of this.
items
) {
if (
item
!==
target
)
item
.
$el
.
removeAttribute
('data-option-open');
} } }

@component and static config merge

They merge in a class initializer, which runs after the fields and inside the class definition, so registerComponent() on the next line reads the finished config. The rules are the rules of $config: refs union, options and components merge entry by entry, a declared value overrides.

A key both sides declare differently is reported as component.config-conflict.

The decorator is applied last, so it registers a finished class.

@on — a name or a value

ts
@
component
({
name
: 'Demo',
components
: {
AccordionItem
},
refs
: ['dots[]'] })
class
Demo
extends
Base
{
@
on
('click')
a
(
event
: MouseEvent) {} // own element, typed from HTMLElementEventMap
@
on
('submit')
b
(
event
: SubmitEvent) {} // idem
@
on
('dots[]', 'click')
c
() {} // a ref, named as declared
@
on
('AccordionItem', 'open')
d
() {} // a child, by name — imports nothing
@
on
(
AccordionItem
, 'open')
e
() {} // a child, by class — the class is the type
@
on
(
window
, 'load')
f
() {} // a global
@
on
(
document
, 'click')
g
() {} // a global
}
  • The one-argument form types its event from HTMLElementEventMap, so @on('click') hands over a MouseEvent and @on('submit') a SubmitEvent. A name outside that map is a component event, whose detail only its emitter knows, so the handler declares the type it expects: @on('content') inject(event: CustomEvent<{ content: string }>).
  • A class resolves to its merged config.name and lands on the same delegated entry as the string form. The class is the type, so target is the component and payload comes from its $emits.
  • A lazy child needs the string form. @on('Child', 'open') imports nothing; a thunk is not a target and both the overloads and the runtime refuse it.
  • A name is a child or a ref, resolved children-first, so the handler is typed as DelegatedEvent or RefEvent.
  • A ref is named as it is declared: @on('dots[]', 'click') for config.refs: ['dots[]']. A mismatched @on('dots', 'click') warns at bind time when the other spelling is declared. A name that matches nothing stays silent.
  • A global target goes through the same binding onWindow<Event> uses: bubble phase, one listener per mount cycle, removed by $unmount(). @on(window, 'click') types the event from WindowEventMap and falls back to Event.
  • Nothing is reserved in its string space: 'Window' means the child and window means the global.
  • Any other EventTarget is refused, by the overloads and by a TypeError. A decorator is evaluated once, at class definition, so an arbitrary target can only be a module-scope value.

@read and @write

They are leaf-method sugar: the phase belongs to the call site.

ts
import { 
Base
,
component
,
read
,
write
} from '@studiometa/js-toolkit';
@
component
({
name
: 'Measure' })
class
Measure
extends
Base
{
#height = 0; @
read
measure
() {
this.#height = this.
$el
.
scrollHeight
;
} @
write
apply
() {
this.
$el
.
style
.
setProperty
('--height', `${this.#height}px`);
} }

Decorate a method nobody overrides

A phase decorator returns a wrapper around the method it decorates, and that wrapper is a property of that class. A subclass that overrides the method defines its own, undecorated, and this.method() resolves to it — so the base's scheduling disappears and the body runs in whatever phase the caller was in.

A template method — a base that schedules work its subclasses implement — schedules at the call site instead:

js
// in the base, where the call is
state.subscribe((value) => this.$write(() => this.update(value)));

The alternative, dispatching a decorated method through something a subclass cannot replace, was refused: it would make a decorator's behaviour depend on inheritance depth, which nothing else in v4 does.

Stacking with @on

The skip is keyed by the method name, so @on and @read/@write stack in either order. The order decides what the listener calls:

  • a phase decorator written below the @on, nearest the method, schedules the body of the handler;
  • one written above it schedules direct calls only.

Write @read and @write closest to the method body.

Build setup

Vite 8 transforms TypeScript with Oxc, which passes decorators through untouched. The package itself compiles them with @rollup/plugin-swc and decoratorVersion: '2023-11', filtered to the files that contain a decorator. See Installation.

The same component, without any of them

ts
import { 
Base
,
registerComponent
, type
DelegatedEvent
} from '@studiometa/js-toolkit';
class
AccordionItem
extends
Base
<{
$emits
: {
open
: void } }> {
static
config
= {
name
: 'AccordionItem' };
onClick
() {
this.
$emit
('open');
} } class
Accordion
extends
Base
{
static
config
= {
name
: 'Accordion',
components
: {
AccordionItem
},
};
items
= this.
$watchChildren
(
AccordionItem
);
onAccordionItemOpen
({
target
}:
DelegatedEvent
<
AccordionItem
, 'open'>) {
for (const
item
of this.
items
) {
if (
item
!==
target
)
item
.
$el
.
removeAttribute
('data-option-open');
} } }
registerComponent
(
Accordion
);

MIT Licensed