Skip to content

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

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions