From 02dc99b5e8f0846242be5d097ecdbb8ff70e58a3 Mon Sep 17 00:00:00 2001 From: Ayo Date: Sun, 19 Jul 2026 18:25:38 +0200 Subject: [PATCH] feat: typed props! --- README.md | 21 +++++ demo/examples/typed-props/index.html | 52 ++++++++++++ demo/examples/typed-props/index.ts | 87 ++++++++++++++++++++ demo/examples/typed-props/tsconfig.json | 18 ++++ demo/index.html | 7 ++ demo/shell.js | 34 +++++++- docs/src/content/docs/guides/prop-access.mdx | 30 +++++++ package.json | 3 +- src/WebComponent.js | 23 +++++- test/types/tsconfig.json | 12 +++ test/types/typed-props.test-d.ts | 50 +++++++++++ 11 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 demo/examples/typed-props/index.html create mode 100644 demo/examples/typed-props/index.ts create mode 100644 demo/examples/typed-props/tsconfig.json create mode 100644 test/types/tsconfig.json create mode 100644 test/types/typed-props.test-d.ts diff --git a/README.md b/README.md index e7fcd8e..43b4934 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,27 @@ When you extend the `WebComponent` class for your component, you only have to de The result is a reactive UI on property changes. +## TypeScript: typed props + +`this.props` is untyped (`{ [name: string]: any }`) by default. Pass the shape of your defaults as a type argument to get compile-time types on declared props: + +```ts +const props = { variant: 'primary', disabled: false } + +class CozyButton extends WebComponent { + static props = props + + get template() { + this.props.variant // string + this.props.disabled // boolean + this.props.disabled = 'yes' // ❌ compile error + return html`` + } +} +``` + +The runtime is unchanged — this is types-only, and omitting the type argument keeps the previous behavior. See the [prop access guide](https://webcomponent.io/prop-access/) for details. + ## Want to get in touch? There are many ways to get in touch: diff --git a/demo/examples/typed-props/index.html b/demo/examples/typed-props/index.html new file mode 100644 index 0000000..b484a64 --- /dev/null +++ b/demo/examples/typed-props/index.html @@ -0,0 +1,52 @@ + + + + + + Compile-time prop types + + + + + + + +

Compile-time prop types

+

+ Pass the shape of your defaults as a type argument — + extends WebComponent<typeof props> — and + this.props.* is inferred from them instead of being + any. +

+ + + +

+ The buttons above are ordinary reactive props; the point of this example + is what the compiler now sees. In + index.ts below, this.props.variant is + string, this.props.disabled is + boolean, and each line in CompileErrors is a + real type error kept honest by @ts-expect-error. +

+ +
+ +

Without the type argument

+

+ Omitting it keeps the previous behavior: every read is any, + so a typo like this.props.varient compiles fine. +

+ + +

+ Runtime is unchanged either way. Types are erased before + the browser sees this file — static strictProps is still what + guards values arriving from attributes at runtime. +

+ + diff --git a/demo/examples/typed-props/index.ts b/demo/examples/typed-props/index.ts new file mode 100644 index 0000000..72c4c84 --- /dev/null +++ b/demo/examples/typed-props/index.ts @@ -0,0 +1,87 @@ +import { WebComponent, html } from 'web-component-base' + +const buttonProps = { + variant: 'primary', + disabled: false, + clicks: 0, +} + +class TypedButton extends WebComponent { + static props = buttonProps + + bump = () => { + if (!this.props.disabled) this.props.clicks++ + } + + toggle = () => (this.props.disabled = !this.props.disabled) + + cycle = () => { + const order = ['primary', 'secondary', 'ghost'] + const next = order[(order.indexOf(this.props.variant) + 1) % order.length] + this.props.variant = next + } + + get template() { + // Each read below is inferred from the default it was declared with — + // hover them in an editor to see `string`, `boolean` and `number`. + const variant: string = this.props.variant + const disabled: boolean = this.props.disabled + const clicks: number = this.props.clicks + + return html` + + + + ` + } +} + +/** + * ERROR examples + * `@ts-expect-error` keeps this example working + */ +class CompileErrors extends WebComponent { + static props = buttonProps + + demo() { + // if you remove the ts-expect-error comment below, the editor should show red squiggly lines + // @ts-expect-error string is not assignable to boolean + this.props.disabled = 'yes' + + // @ts-expect-error boolean is not assignable to string + this.props.variant = false + + // @ts-expect-error 'varient' is a typo — not a declared prop + this.props.varient + + // @ts-expect-error clicks is a number, not a string + this.props.clicks.toUpperCase() + } +} + +/** + * Omitting the type argument keeps the previous behavior — every read is + * `any`, so none of the mistakes above are caught. This still compiles. + */ +class UntypedButton extends WebComponent { + static props = { variant: 'primary' } + + get template() { + this.props.varient // no error: `any` swallows the typo + return html`` + } +} + +customElements.define('typed-button', TypedButton) +customElements.define('untyped-button', UntypedButton) + +export { CompileErrors } diff --git a/demo/examples/typed-props/tsconfig.json b/demo/examples/typed-props/tsconfig.json new file mode 100644 index 0000000..d986649 --- /dev/null +++ b/demo/examples/typed-props/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "baseUrl": ".", + // typecheck against the emitted `.d.ts` — the same types a consumer gets. + // (Vite aliases this specifier to `src/` at runtime; see demo/vite.config.js) + "paths": { + "web-component-base": ["../../../dist/index.d.ts"] + } + }, + "include": ["./index.ts"] +} diff --git a/demo/index.html b/demo/index.html index 7779446..5a85201 100644 --- a/demo/index.html +++ b/demo/index.html @@ -40,6 +40,13 @@ Default log-and-skip vs static strictProps throwing. + +
Compile-time prop typesTS
+
+ extends WebComponent<typeof props> infers + this.props.* from the defaults. +
+
Attribute lifecycle
diff --git a/demo/shell.js b/demo/shell.js index db32171..cf91f27 100644 --- a/demo/shell.js +++ b/demo/shell.js @@ -41,6 +41,9 @@ const themeToggle = document.createElement('button') themeToggle.type = 'button' themeToggle.className = 'theme-toggle' +/** + * + */ function syncToggle() { const dark = effectiveTheme() === 'dark' themeToggle.innerHTML = dark ? SUN_ICON : MOON_ICON @@ -87,7 +90,7 @@ syncToggle() // and in the hashed production build (unlike reading rewritten