Decorators
Radiant decorators fall into a few groups:
- host registration and HTML input: @customElement, @controller, @attr, @prop
- reactive state and bindings: @state, @signal, @bindTo, @onUpdated
- DOM and event wiring: @onEvent, @event, @query, @querySlot
- method helpers: @bound, @debounce
Host Support
RadiantElementsupports the full custom-element decorator surface.RadiantControllersupports the controller-oriented subset plus render-driven state and context authoring.
For the controller capability matrix, see RadiantController.
Choosing A Reactive Field Type
- use @attr when the value should come from markup
- use @prop when the value is public API on a host
- use @state for plain internal host state
- use @signal when the field itself should be a writable signal
Typing Decorated Fields
Under standard decorators, TypeScript passes each field decorator the declared field type, and the decorator checks its options, or the value it installs, against it. The field type is the source of truth: defaultValue, initial, source, transform, converter and type must agree with it, and a mismatch is a compile error on the decorator. Legacy decorators, covered at the end of this section, cannot run these checks.
Declare The Type
Prefer an initializer, so TypeScript infers the field type and there is no annotation to keep in sync:
import { RadiantElement, customElement, prop, query, signal } from '@ecopages/radiant';
import type { WritableSignal } from '@ecopages/signals';
type Size = 'sm' | 'md' | 'lg';
@customElement('size-picker')
export class SizePicker extends RadiantElement {
@prop({ type: Number, defaultValue: 0 }) count = 0;
@prop({ type: String, defaultValue: 'md' }) size: Size = 'md';
@prop({ type: Boolean }) open?: boolean;
@signal({ initial: 0 }) clicks!: WritableSignal<number>;
@query({ ref: 'input' }) input!: HTMLInputElement | null;
}Write the type yourself for:
- a string literal union, such as
size: Size = 'md' - a field with no initializer:
?when the value can be absent, which addsundefined, and!when the decorator or a default supplies it, which only silences the missing-initializer error @signal,@queryand the other fields a decorator installs, which have no initializer to infer from
Declare a decorated field as a plain field. TypeScript rejects declare on it under standard decorators (TS1206), and an accessor field is not a target of these decorators.
A field that disagrees with an option is rejected on the decorator. TypeScript may report it only as "Unable to resolve signature of property decorator", or alongside an error that names the option:
@prop({ type: Number, defaultValue: 0 }) count: string = ''; // defaultValue is a number
@prop({ type: Number }) step?: string; // Number parses to a number
@attr({ source: 'data-total' }) total = 0; // the raw attribute is a string
@signal({ initial: 'x' }) label!: WritableSignal<number>; // initial is a string
@query({ ref: 'input' }) input!: HTMLInputElement; // null when nothing matchesWhat Each Decorator Expects
- @state: any type. It is the type @bindTo passes to
map. - @signal:
WritableSignal<T>, withinitialaTandsourceaWritableSignal<T>, or a function returning one. - @bindTo:
mapreceives the field type, so@bindTo({ attr: 'data-n', map: (v) => v.toFixed() }) @state n = 0typesvasnumber. On a@signalfield declaredWritableSignal<number>it also typesvasnumber, the value the runtime passes. - @query and @querySlot:
T | nullfor a single element,T[]withall: true.allmust be a literaltrueorfalse. - @consumeContext:
ContextProvider<typeof context>, which may be optional but not wider. - @provideContext:
ContextProvider<typeof context>. The provider is always installed, so the field cannot be optional or wider. - @event:
EventEmitter<Detail>. The emitter is always installed, so the field cannot be optional. - @onContextUpdate: a method whose parameter accepts the value
selectreturns, or the whole context withoutselect. - @contextSelector: the value
selectreturns, or the whole context withoutselect. The field may be optional or wider, such asunknown, but not narrower: it has to hold what the decorator writes.
@prop And @attr
- The
typeconstant must fit the field:Stringneeds astring,Numberanumber,Booleanaboolean,Arrayan array (readonly included) andObjectan object that is not an array.nullandundefinedare always allowed. A field of several parsed types, such asstring | number, fits no constant. - A parser owns the value type:
transform.fromAttributeon@prop,converter.fromAttributeon@attr.typeis then not compared with the field, anddefaultValueis checked against the field, not againsttype. On@prop,typestill selects the value used when no attribute is set and neitherdefaultValuenor an initializer is given, such as''forString. Pass the parser inline or declare it withsatisfies PropTransform<T>(AttrConverter<T>on@attr): an annotated declaration makesfromAttributeoptional in its type, sotypeis checked again. @attrwithouttypeor a parser holds the raw attribute string, so its field must bestring | null | undefined. A string literal union fits. Any other field needstypeor a parser.defaultValue: nullis accepted when the field allowsnull. Without a parser, adefaultValuethat does not matchtypethrows: when@propis applied to the class, and when a host installs an@attr.
@prop({ type: String, transform: { fromAttribute: (v) => Number(v) } }) rank = 0;
@attr({ converter: { fromAttribute: (v) => (v ? v.split(',') : []) } }) labels: string[] = [];
@attr({ source: 'data-count', type: Number }) count = 0;
@prop({ type: Number, defaultValue: null }) limit: number | null = null;A field typed by the host's own type parameter does not compile with a type constant. On @prop, pass a parser that keeps the raw value. On @attr, drop type:
export class Select<V extends string> extends RadiantElement {
@prop({ type: String, transform: { fromAttribute: (v) => (v ?? '') as V } }) selected!: V;
@attr({ source: 'data-mode' }) mode?: V;
}Legacy Decorators
With experimentalDecorators: true, TypeScript calls a field decorator as (prototype, key) and never passes the field type. Radiant supports this mode, but none of the field checks above run in it. Every field in the rejected list above compiles there. The decorators also take no value type from the field, so a callback that uses the value, such as @bindTo's map or a transform, sees unknown unless you pass the type argument. The type argument must be the whole field type, undefined included (@bindTo<number | undefined> on n?: number). Under legacy decorators it is still checked against the options, but not compared with the field.
// legacy: error, 'v' is of type 'unknown'
@bindTo({ attr: 'data-n', map: (v) => v.toFixed() }) @state n = 0;
// legacy and standard
@bindTo<number>({ attr: 'data-n', map: (v) => v.toFixed() }) @state n = 0;To get the field checks and drop the type arguments, compile with standard decorators and leave experimentalDecorators off.
Recommended Reading Order
- Start with @attr, @state, and @signal.
- If the host keeps authored light DOM, read @bindTo for value-to-DOM paint and @onEvent for events.
- If the host renders, read @onUpdated only for procedures that are not a straight DOM copy, and use
this.$for leaves inrender(). - For custom-element public API, add @prop.
- For controller discovery, add @controller.