Question
Can webjs offer a reactive-prop DX with NO declare line, where the types flow from the property declaration itself (the user writes the shape once and this.count is typed), while holding every webjs invariant: no build step, source-is-runtime (what you write is what ships to the browser), erasable TypeScript only, and no decorators?
The current pattern needs three pieces per prop: static properties = { count: { type: Number } }, a declare count: number, and a constructor default. The declare is the part that feels worst.
Answer: yes, it is feasible, via a typed base-class factory. Proven on all three axes.
The blocker is real but narrower than it looks. It only exists if you insist on keeping static properties as a STATIC FIELD: TypeScript cannot type a class's instance members from a value sitting in that class's own static field (no decorator-free mechanism, no chicken-and-egg resolution). So as long as the declaration lives in a static field, a separate declare is the only way to type the accessor.
Move the declaration into a base-class FACTORY call and the limitation disappears, because now the props are a function argument whose type the factory can map into the returned class's instance type:
class Counter extends Component({
count: prop(Number, { reflect: true, default: 0 }),
name: prop(String, { default: 'xyz' }),
student: prop(Student),
tags: prop<Tag[]>(Array, { default: () => [] }), // explicit type for richer shapes
mode: prop<'light' | 'dark'>(String, { default: 'light' }),
}) {
render() {
return html`${this.count} ${this.name} ${this.student.name} ${this.mode}`;
// number string Student.name union, all typed, NO declare
}
}
Counter.register('counter-x');
prop(Number) infers number, prop(Student) infers Student from the constructor; a richer type than the constructor implies (a string[] array, a string union) is given once as prop<T>(Ctor). The base factory maps the shape { count: PropDef<number>, ... } to instance members { count: number, ... } via a mapped type, intersected with the WebComponent base, so this.count AND this.requestUpdate() are both visible.
Verified, not asserted
- Types flow, no
declare. A prototype type-checks under strict + erasableSyntaxOnly: this.count is number, this.student is Student, this.mode is the union; @ts-expect-error confirms a wrong-type assignment (this.count = 'x') is a real error. So the types genuinely flow, they are not any.
- Source-is-runtime holds. Running the user-side source through
module.stripTypeScriptTypes leaves valid runtime: the type args (<Tag[]>, <'light'|'dark'>) erase in place and extends Component({ ... prop(Array, { default: () => [] }) ... }) survives as the real runtime that ships and runs. Nothing is generated, nothing is rewritten. What you write IS what ships.
- Runtime works against the real
WebComponent, no framework rewrite. Component(shape) returns class extends WebComponent { static properties = shape }; the existing _initializeProperties reads Ctor.properties unchanged. An end-to-end run (linkedom) confirmed defaults seeding, reactivity, attribute reflection, and fresh-per-instance function defaults all work.
Why this beats the alternatives (all the dead-ends, with proof)
@property() decorator (Lit's one-liner): non-erasable, would force a build step. Banned by invariant 10. Out.
- TC39
accessor keyword: a runtime syntax error on Node 26 / V8 without the decorators proposal (verified), and the stripper leaves it intact because it is not a type, so it crashes at load. Also does not register reactivity without a decorator. Out.
prop() as a class FIELD (count = prop(Number)): a class-field initializer is clobbered after super() by [[Define]] semantics (verified: the own descriptor's getter becomes undefined), so it stores a box, not a reactive accessor. Out.
- Infer instance types from a
static properties FIELD: impossible in TypeScript (the core blocker above). Out.
- A TS language-service plugin that synthesizes the
declares virtually: works in-editor only; webjs typecheck (tsc on CLI/CI) still errors because a plugin does not affect tsc. Out unless paired with codegen.
- Codegen real
.d.ts via webjs types: reintroduces a generate/watch step and a drift surface. Violates no-build. Out.
The factory is the one approach that clears every constraint at once.
Tradeoffs (why this is a design decision, not an obvious yes)
- It diverges from Lit's
static properties static-field shape. webjs is lit-shaped on purpose; extends Component({ ... }) is not a Lit idiom, and Lit muscle memory reaches for static properties + @property/declare. This is a real positioning cost.
- Split declaration surface. Props move into the factory arg, but other static config (
static shadow, static styles, static lazy) stays as static fields on the subclass, so a component reads as extends Component({props}) plus static shadow = true.
- Broad tooling/docs migration.
webjs check (the reactive-props-use-declare rule scans static properties), @webjsdev/intellisense, MCP list_components, the scaffold templates, the docs site, AGENTS.md, the lit-muscle-memory gotchas, and the four dogfood apps all assume the static-field model. Adopting the factory touches all of them.
- Coexist vs replace. Shipping the factory ALONGSIDE the static-field model doubles the prop-declaration API surface (two ways to do the same thing). Replacing it is a clean single model but a full migration. webjs has no users yet (no backward-compat burden), so a clean replacement is genuinely on the table, but it is a significant call.
Recommendation
The declare-free DX is feasible and the win is real (one declaration, fully typed, no declare, no build, source-is-runtime). I recommend pursuing the typed base-class factory as the path, and deciding replace vs coexist as an explicit design choice (replacing is cleaner given zero users; coexisting is safer but heavier on the API surface). Implementation is separate tracked work.
The incremental default option (drop the constructor for the common case) is a strict prerequisite/complement either way: the factory examples above already rely on it, and it improves the existing static-field model on its own. It is implemented in PR #575.
Question
Can webjs offer a reactive-prop DX with NO
declareline, where the types flow from the property declaration itself (the user writes the shape once andthis.countis typed), while holding every webjs invariant: no build step, source-is-runtime (what you write is what ships to the browser), erasable TypeScript only, and no decorators?The current pattern needs three pieces per prop:
static properties = { count: { type: Number } }, adeclare count: number, and a constructor default. Thedeclareis the part that feels worst.Answer: yes, it is feasible, via a typed base-class factory. Proven on all three axes.
The blocker is real but narrower than it looks. It only exists if you insist on keeping
static propertiesas a STATIC FIELD: TypeScript cannot type a class's instance members from a value sitting in that class's own static field (no decorator-free mechanism, no chicken-and-egg resolution). So as long as the declaration lives in a static field, a separatedeclareis the only way to type the accessor.Move the declaration into a base-class FACTORY call and the limitation disappears, because now the props are a function argument whose type the factory can map into the returned class's instance type:
prop(Number)infersnumber,prop(Student)infersStudentfrom the constructor; a richer type than the constructor implies (astring[]array, a string union) is given once asprop<T>(Ctor). The base factory maps the shape{ count: PropDef<number>, ... }to instance members{ count: number, ... }via a mapped type, intersected with theWebComponentbase, sothis.countANDthis.requestUpdate()are both visible.Verified, not asserted
declare. A prototype type-checks understrict+erasableSyntaxOnly:this.countisnumber,this.studentisStudent,this.modeis the union;@ts-expect-errorconfirms a wrong-type assignment (this.count = 'x') is a real error. So the types genuinely flow, they are notany.module.stripTypeScriptTypesleaves valid runtime: the type args (<Tag[]>,<'light'|'dark'>) erase in place andextends Component({ ... prop(Array, { default: () => [] }) ... })survives as the real runtime that ships and runs. Nothing is generated, nothing is rewritten. What you write IS what ships.WebComponent, no framework rewrite.Component(shape)returnsclass extends WebComponent { static properties = shape }; the existing_initializePropertiesreadsCtor.propertiesunchanged. An end-to-end run (linkedom) confirmed defaults seeding, reactivity, attribute reflection, and fresh-per-instance function defaults all work.Why this beats the alternatives (all the dead-ends, with proof)
@property()decorator (Lit's one-liner): non-erasable, would force a build step. Banned by invariant 10. Out.accessorkeyword: a runtime syntax error on Node 26 / V8 without the decorators proposal (verified), and the stripper leaves it intact because it is not a type, so it crashes at load. Also does not register reactivity without a decorator. Out.prop()as a class FIELD (count = prop(Number)): a class-field initializer is clobbered aftersuper()by[[Define]]semantics (verified: the own descriptor's getter becomesundefined), so it stores a box, not a reactive accessor. Out.static propertiesFIELD: impossible in TypeScript (the core blocker above). Out.declares virtually: works in-editor only;webjs typecheck(tsc on CLI/CI) still errors because a plugin does not affecttsc. Out unless paired with codegen..d.tsviawebjs types: reintroduces a generate/watch step and a drift surface. Violates no-build. Out.The factory is the one approach that clears every constraint at once.
Tradeoffs (why this is a design decision, not an obvious yes)
static propertiesstatic-field shape. webjs is lit-shaped on purpose;extends Component({ ... })is not a Lit idiom, and Lit muscle memory reaches forstatic properties+@property/declare. This is a real positioning cost.static shadow,static styles,static lazy) stays as static fields on the subclass, so a component reads asextends Component({props})plusstatic shadow = true.webjs check(thereactive-props-use-declarerule scansstatic properties),@webjsdev/intellisense, MCPlist_components, the scaffold templates, the docs site, AGENTS.md, the lit-muscle-memory gotchas, and the four dogfood apps all assume the static-field model. Adopting the factory touches all of them.Recommendation
The declare-free DX is feasible and the win is real (one declaration, fully typed, no
declare, no build, source-is-runtime). I recommend pursuing the typed base-class factory as the path, and deciding replace vs coexist as an explicit design choice (replacing is cleaner given zero users; coexisting is safer but heavier on the API surface). Implementation is separate tracked work.The incremental
defaultoption (drop the constructor for the common case) is a strict prerequisite/complement either way: the factory examples above already rely on it, and it improves the existing static-field model on its own. It is implemented in PR #575.