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
| Decorator | Wraps | Notes |
|---|---|---|
@component({ name }) | static config + registerComponent() | Registers as soon as the class is defined. |
@on(target, type) / @on(type) | the on<Child><Event> names | The 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
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
@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 aMouseEventand@on('submit')aSubmitEvent. 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.nameand lands on the same delegated entry as the string form. The class is the type, sotargetis the component andpayloadcomes 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
DelegatedEventorRefEvent. - A ref is named as it is declared:
@on('dots[]', 'click')forconfig.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 fromWindowEventMapand falls back toEvent. - Nothing is reserved in its string space:
'Window'means the child andwindowmeans the global. - Any other
EventTargetis refused, by the overloads and by aTypeError. 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.
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:
// 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
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);