Skip to content

Research a simpler reactive-prop DX than the static-properties + declare pattern #531

@vivek7405

Description

@vivek7405

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

  1. 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.
  2. 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.
  3. 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)

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or requestresearchResearch/design/decision record (no code); filter these to read design history

Type

No type
No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions