Skip to content

Declare-free reactive-prop DX via a typed base-class factory #593

@vivek7405

Description

@vivek7405

Problem

Reactive props need three pieces today: static properties = { count: { type: Number } }, a declare count: number, and a default. The declare is pure boilerplate that exists only because TypeScript cannot type a class's instance members from a value in that class's own static field. The feasibility research in #531 proved a declare-free DX IS achievable while holding every webjs invariant (no build, source-is-runtime, erasable-TS, no decorators).

Design / approach

Ship a typed base-class factory. The user writes the prop shape once, inside a Component({ ... }) call the class extends, and the types flow into this:

class Counter extends Component({
  count:   prop(Number, { reflect: true, default: 0 }),
  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.student.name} ${this.mode}`; }  // all typed, NO declare
}
Counter.register('counter-x');
  • prop(Number) infers number, prop(Student) infers Student; a richer type than the constructor implies is given once as prop<T>(Ctor).
  • Component(shape) returns class extends WebComponent { static properties = shape }; a mapped type { [K in keyof S]: TypeOf<S[K]> } intersected with the base gives this its typed members.
  • All three were verified in Research a simpler reactive-prop DX than the static-properties + declare pattern #531: tsc strict + erasableSyntaxOnly PASS, module.stripTypeScriptTypes leaves valid runtime (type args erase in place, the factory call ships), and an end-to-end run against the real WebComponent works with no change to _initializeProperties.

Design decision to settle first (do not skip): REPLACE the static properties + declare model with the factory, or ship the factory ALONGSIDE it. Replacing is a single clean model and is viable because webjs has no users yet (no backward-compat burden), but it is a full migration of tooling/docs/apps. Coexisting is safer but doubles the prop-declaration API surface. Confirm the direction with the owner before building.

Implementation notes (for the implementing agent)

  • Read Research a simpler reactive-prop DX than the static-properties + declare pattern #531 (body + the deep-dive comment) first; it carries the exact type/runtime prototypes that type-check and run.
  • New exports in packages/core/index.js (and the browser bundle): prop() and Component(). Runtime is thin: Component(shape) returns an anonymous WebComponent subclass with static properties = normalize(shape); prop(type?, opts?) returns a PropertyDeclaration (handle the prop(opts) no-constructor overload at runtime by sniffing whether arg1 is callable).
  • Types: add the Infer<C> constructor-to-type map, the prop overloads, and the Component factory signature to packages/core/src/component.d.ts (next to PropertyDeclaration and the WebComponent class type). The default option already exists there (PR feat: declarative default option for reactive props #575).
  • The runtime accessor in packages/core/src/component.js _initializeProperties (~L548-589) needs NO change; it already reads Ctor.properties. The default handling landed in PR feat: declarative default option for reactive props #575.
  • webjs check: the reactive-props-use-declare rule (packages/server/src/check.js ~L77 and ~L443-462) only scans static properties. In the factory model there is no static-field initializer to clobber, so decide whether the rule still applies (it should still fire for the OLD model if coexisting). Add a rule/lint for misuse of the factory if warranted.
  • Broad surfaces that assume the static-field model and must be synced: @webjsdev/intellisense (packages/editors/intellisense/src, re-vendor for nvim after edits), MCP list_components (packages/mcp/src), scaffold templates (packages/cli/templates/), docs site (docs/app/docs/components/page.ts), AGENTS.md, agent-docs/components.md, agent-docs/lit-muscle-memory-gotchas.md, and the four dogfood apps if migrated.

Landmines

  • Decorators and the TC39 accessor keyword are both out (verified in Research a simpler reactive-prop DX than the static-properties + declare pattern #531: accessor is a runtime syntax error on V8 and is not erasable). Do not reach for them.
  • A prop() used as a class FIELD is clobbered by class-field semantics; it MUST be a factory argument, not an instance field.
  • Keep prop(Object) honest: a plain Object infers a loose type, so rich object shapes need the explicit prop<Shape>(Object) phantom.

Invariants to respect

  • AGENTS.md invariant 1 (server-only boundary), 10 (erasable TS, no decorators), and the no-build / source-is-runtime promise. The factory call must ship and run as written.

Acceptance criteria

  • Design decision (replace vs coexist) recorded on this issue before implementation.
  • Component() + prop() exported and working: a component declares props with NO declare line and this.<prop> is fully typed.
  • tsc strict + erasableSyntaxOnly passes on a real example; a wrong-type assignment is a type error (counterfactual via @ts-expect-error).
  • Source strips to valid runtime (assert against module.stripTypeScriptTypes).
  • Reactivity, reflect, default (literal + function), and state all work at runtime and SSR (unit + SSR + browser layers).
  • All tooling/docs surfaces above synced (or "N/A because" in the PR).

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

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