feat: typed props
Some checks are pending
Tests / Unit (push) Waiting to run
Tests / E2E (push) Waiting to run

- jsdocs / types change only to enforce giving shapt to `static props`
This commit is contained in:
ayo 2026-07-19 18:25:38 +02:00
parent 737ef54763
commit 861310c6bf
11 changed files with 329 additions and 8 deletions

View file

@ -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. 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<typeof props> {
static props = props
get template() {
this.props.variant // string
this.props.disabled // boolean
this.props.disabled = 'yes' // ❌ compile error
return html`<button class=${this.props.variant}></button>`
}
}
```
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? ## Want to get in touch?
There are many ways to get in touch: There are many ways to get in touch:

View file

@ -0,0 +1,52 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Compile-time prop types</title>
<script type="module" src="./index.ts"></script>
<script>try{document.documentElement.dataset.theme=localStorage.getItem('wcb-theme')||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light')}catch(e){}</script>
<link rel="stylesheet" href="../../shell.css" />
<script type="module" src="../../shell.js"></script>
<style>
.btn.primary { font-weight: 600; }
.btn.secondary { opacity: 0.85; }
.btn.ghost { background: transparent; }
</style>
</head>
<body>
<h1>Compile-time prop types</h1>
<p>
Pass the shape of your defaults as a type argument —
<code>extends WebComponent&lt;typeof props&gt;</code> — and
<code>this.props.*</code> is inferred from them instead of being
<code>any</code>.
</p>
<typed-button></typed-button>
<p>
The buttons above are ordinary reactive props; the point of this example
is what the <em>compiler</em> now sees. In
<code>index.ts</code> below, <code>this.props.variant</code> is
<code>string</code>, <code>this.props.disabled</code> is
<code>boolean</code>, and each line in <code>CompileErrors</code> is a
real type error kept honest by <code>@ts-expect-error</code>.
</p>
<hr />
<h2>Without the type argument</h2>
<p>
Omitting it keeps the previous behavior: every read is <code>any</code>,
so a typo like <code>this.props.varient</code> compiles fine.
</p>
<untyped-button></untyped-button>
<p>
<strong>Runtime is unchanged either way.</strong> Types are erased before
the browser sees this file — <code>static strictProps</code> is still what
guards values arriving from attributes at runtime.
</p>
</body>
</html>

View file

@ -0,0 +1,87 @@
import { WebComponent, html } from 'web-component-base'
const buttonProps = {
variant: 'primary',
disabled: false,
clicks: 0,
}
class TypedButton extends WebComponent<typeof buttonProps> {
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`
<button
id="typed-button"
class="btn ${variant}"
disabled=${disabled}
onclick=${this.bump}
>
${variant} clicked ${clicks}×
</button>
<button id="typed-cycle" onclick=${this.cycle}>Cycle variant</button>
<button id="typed-toggle" onclick=${this.toggle}>
${disabled ? 'Enable' : 'Disable'}
</button>
`
}
}
/**
* ERROR examples
* `@ts-expect-error` keeps this example working
*/
class CompileErrors extends WebComponent<typeof buttonProps> {
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`<button class="btn">${this.props.variant} (untyped)</button>`
}
}
customElements.define('typed-button', TypedButton)
customElements.define('untyped-button', UntypedButton)
export { CompileErrors }

View file

@ -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"]
}

View file

@ -40,6 +40,13 @@
Default log-and-skip vs <code>static strictProps</code> throwing. Default log-and-skip vs <code>static strictProps</code> throwing.
</div> </div>
</a> </a>
<a class="card" href="./examples/typed-props/index.html">
<div class="name">Compile-time prop types<span class="tag">TS</span></div>
<div class="desc">
<code>extends WebComponent&lt;typeof props&gt;</code> infers
<code>this.props.*</code> from the defaults.
</div>
</a>
<a class="card" href="./examples/attribute-lifecycle/index.html"> <a class="card" href="./examples/attribute-lifecycle/index.html">
<div class="name">Attribute lifecycle</div> <div class="name">Attribute lifecycle</div>
<div class="desc"> <div class="desc">

View file

@ -41,6 +41,9 @@ const themeToggle = document.createElement('button')
themeToggle.type = 'button' themeToggle.type = 'button'
themeToggle.className = 'theme-toggle' themeToggle.className = 'theme-toggle'
/**
*
*/
function syncToggle() { function syncToggle() {
const dark = effectiveTheme() === 'dark' const dark = effectiveTheme() === 'dark'
themeToggle.innerHTML = dark ? SUN_ICON : MOON_ICON themeToggle.innerHTML = dark ? SUN_ICON : MOON_ICON
@ -87,7 +90,7 @@ syncToggle()
// and in the hashed production build (unlike reading rewritten <script src>s). // and in the hashed production build (unlike reading rewritten <script src>s).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const RAW_JS = import.meta.glob('./examples/**/*.{js,mjs}', { const RAW_JS = import.meta.glob('./examples/**/*.{js,mjs,ts}', {
query: '?raw', query: '?raw',
import: 'default', import: 'default',
eager: true, eager: true,
@ -103,6 +106,9 @@ const basename = (path) => path.split('/').pop()
// Shiki highlighter, created once and lazily. Uses the JavaScript regex engine // Shiki highlighter, created once and lazily. Uses the JavaScript regex engine
// so no WASM is fetched at runtime, and only the langs/themes we actually need. // so no WASM is fetched at runtime, and only the langs/themes we actually need.
let highlighterPromise let highlighterPromise
/**
*
*/
function getHighlighter() { function getHighlighter() {
if (!highlighterPromise) { if (!highlighterPromise) {
highlighterPromise = (async () => { highlighterPromise = (async () => {
@ -111,8 +117,9 @@ function getHighlighter() {
import('shiki/core'), import('shiki/core'),
import('shiki/engine/javascript'), import('shiki/engine/javascript'),
]) ])
const [js, htmlLang, css, dark, light] = await Promise.all([ const [js, ts, htmlLang, css, dark, light] = await Promise.all([
import('shiki/langs/javascript.mjs'), import('shiki/langs/javascript.mjs'),
import('shiki/langs/typescript.mjs'),
import('shiki/langs/html.mjs'), import('shiki/langs/html.mjs'),
import('shiki/langs/css.mjs'), import('shiki/langs/css.mjs'),
import('shiki/themes/github-dark.mjs'), import('shiki/themes/github-dark.mjs'),
@ -120,7 +127,7 @@ function getHighlighter() {
]) ])
return createHighlighterCore({ return createHighlighterCore({
engine: createJavaScriptRegexEngine(), engine: createJavaScriptRegexEngine(),
langs: [js.default, htmlLang.default, css.default], langs: [js.default, ts.default, htmlLang.default, css.default],
themes: [dark.default, light.default], themes: [dark.default, light.default],
}) })
})() })()
@ -128,10 +135,18 @@ function getHighlighter() {
return highlighterPromise return highlighterPromise
} }
const langFor = (name) => (name.endsWith('.html') ? 'html' : 'javascript') const langFor = (name) => {
if (name.endsWith('.html')) return 'html'
if (name.endsWith('.ts')) return 'typescript'
return 'javascript'
}
// Single-file pens have no separate JS module — show their HTML instead, minus // Single-file pens have no separate JS module — show their HTML instead, minus
// the injected shell tags so only the actual example remains. // the injected shell tags so only the actual example remains.
/**
*
* @param html
*/
function stripShell(html) { function stripShell(html) {
return html return html
.split('\n') .split('\n')
@ -141,6 +156,10 @@ function stripShell(html) {
} }
// ts-check strip comments // ts-check strip comments
/**
*
* @param js
*/
function stripTsCheck(js) { function stripTsCheck(js) {
return js return js
.split('\n') .split('\n')
@ -149,6 +168,10 @@ function stripTsCheck(js) {
.trim() .trim()
} }
/**
*
* @param folder
*/
function sourcesForFolder(folder) { function sourcesForFolder(folder) {
const prefix = `./examples/${folder}/` const prefix = `./examples/${folder}/`
const jsKeys = Object.keys(RAW_JS) const jsKeys = Object.keys(RAW_JS)
@ -166,6 +189,9 @@ function sourcesForFolder(folder) {
.map((k) => ({ name: basename(k), code: stripShell(RAW_HTML[k]) })) .map((k) => ({ name: basename(k), code: stripShell(RAW_HTML[k]) }))
} }
/**
*
*/
async function renderSourcePreview() { async function renderSourcePreview() {
const match = location.pathname.match(/\/examples\/([^/]+)\//) const match = location.pathname.match(/\/examples\/([^/]+)\//)
if (!match) return if (!match) return

View file

@ -40,6 +40,36 @@ The `props` property of `WebComponent` works like `HTMLElement.dataset`, except
Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'. Another advantage over `HTMLElement.dataset` is that `WebComponent.props` can hold primitive types 'number', 'boolean', 'object' and 'string'.
</Aside> </Aside>
### Typed props in TypeScript
By default `this.props` is a permissive `{ [name: string]: any }` map. In TypeScript you can get compile-time types on your declared props by passing the shape of your defaults as a type argument to `WebComponent`. Declare the defaults in a `const` first so the class can refer to their type:
```ts
const props = {
variant: 'primary',
disabled: false,
}
class CozyButton extends WebComponent<typeof props> {
static props = props
get template() {
this.props.variant // string
this.props.disabled // boolean
this.props.notAProp // ❌ compile error: not declared
this.props.disabled = 'yes' // ❌ compile error: string is not boolean
return html`<button class=${this.props.variant}></button>`
}
}
```
This is types-only — the runtime is unchanged, and `static strictProps` still guards writes that come in from attributes at runtime. Omitting the type argument keeps the previous untyped behavior.
<Aside type="tip">
Values in the defaults object widen as usual, so `variant: 'primary'` types as `string`. To narrow it to a union of allowed values, annotate the defaults: `const props = { variant: 'primary' as 'primary' | 'ghost' }`.
</Aside>
### Alternatives ### Alternatives
The current alternatives are using what `HTMLElement` provides out-of-the-box, which are: The current alternatives are using what `HTMLElement` provides out-of-the-box, which are:

View file

@ -38,7 +38,8 @@
"test:e2e:firefox": "E2E_BROWSERS=firefox vitest --run --config vitest.e2e.config.mjs", "test:e2e:firefox": "E2E_BROWSERS=firefox vitest --run --config vitest.e2e.config.mjs",
"test:e2e:webkit": "E2E_BROWSERS=webkit vitest --run --config vitest.e2e.config.mjs", "test:e2e:webkit": "E2E_BROWSERS=webkit vitest --run --config vitest.e2e.config.mjs",
"test:e2e:all": "vitest --run --config vitest.e2e.config.mjs", "test:e2e:all": "vitest --run --config vitest.e2e.config.mjs",
"test:all": "pnpm test && pnpm test:e2e:all", "test:types": "pnpm run build && tsc -p test/types/tsconfig.json && tsc -p demo/examples/typed-props/tsconfig.json",
"test:all": "pnpm test && pnpm test:types && pnpm test:e2e:all",
"demo": "pnpm -F demo dev", "demo": "pnpm -F demo dev",
"demo:build": "pnpm -F demo build", "demo:build": "pnpm -F demo build",
"docs": "pnpm -F docs start", "docs": "pnpm -F docs start",

View file

@ -44,9 +44,25 @@ function cloneDefaults(ctor) {
return out return out
} }
/**
* Blueprint for the Proxy props
* @typedef {{[name: string]: any}} PropStringMap
*/
/** /**
* A minimal base class to reduce the complexity of creating reactive custom elements * A minimal base class to reduce the complexity of creating reactive custom elements
*
* Pass the shape of your `static props` as a type argument to get typed
* `this.props` access in TypeScript:
* ```ts
* const props = { variant: 'primary', disabled: false }
* class CozyButton extends WebComponent<typeof props> {
* static props = props
* }
* ```
* @template {PropStringMap} [Props=PropStringMap]
* @see https://webcomponent.io * @see https://webcomponent.io
* @see https://webcomponent.io/prop-access/
*/ */
export class WebComponent extends HTMLElement { export class WebComponent extends HTMLElement {
#host #host
@ -57,8 +73,9 @@ export class WebComponent extends HTMLElement {
#connected = false #connected = false
/** /**
* Blueprint for the Proxy props * Declared props and their defaults. The value types of this object drive
* @typedef {{[name: string]: any}} PropStringMap * both the runtime type guard and when passed as the class type argument
* the compile-time type of `this.props`.
* @type {PropStringMap} * @type {PropStringMap}
*/ */
static props static props
@ -91,7 +108,7 @@ export class WebComponent extends HTMLElement {
/** /**
* Read-only property containing camelCase counterparts of observed attributes. * Read-only property containing camelCase counterparts of observed attributes.
* @see https://webcomponent.io/prop-access/ * @see https://webcomponent.io/prop-access/
* @type {PropStringMap} * @returns {Props}
*/ */
get props() { get props() {
return this.#props return this.#props

12
test/types/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["./*.ts"]
}

View file

@ -0,0 +1,50 @@
/**
* Type-level test for WCB-REQ-02: `this.props.*` is inferred from the shape
* passed as the class type argument. Checked by `pnpm test:types` (tsc only
* nothing here runs).
*/
import { WebComponent } from '../../dist/index.js'
type Equals<A, B> =
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
? true
: false
type Expect<T extends true> = T
const buttonProps = { variant: 'primary', disabled: false }
class CozyButton extends WebComponent<typeof buttonProps> {
static props = buttonProps
get template() {
// reads are inferred from the defaults, not `any`
type _variantIsString = Expect<Equals<typeof this.props.variant, string>>
type _disabledIsBoolean = Expect<Equals<typeof this.props.disabled, boolean>>
return `<button class="${this.props.variant}"></button>`
}
ok() {
this.props.variant = 'secondary'
this.props.disabled = true
}
wrong() {
// @ts-expect-error string is not assignable to boolean
this.props.disabled = 'x'
// @ts-expect-error boolean is not assignable to string
this.props.variant = false
// @ts-expect-error prop is not declared
this.props.notAProp
}
}
// without a type argument, props stays the permissive PropStringMap
class Untyped extends WebComponent {
static props = { anything: 1 }
read() {
type _isAny = Expect<Equals<typeof this.props.anything, any>>
}
}
export type { CozyButton, Untyped }