feat: typed props
- jsdocs / types change only to enforce giving shapt to `static props`
This commit is contained in:
parent
737ef54763
commit
861310c6bf
11 changed files with 329 additions and 8 deletions
21
README.md
21
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<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?
|
||||
|
||||
There are many ways to get in touch:
|
||||
|
|
|
|||
52
demo/examples/typed-props/index.html
Normal file
52
demo/examples/typed-props/index.html
Normal 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<typeof props></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>
|
||||
87
demo/examples/typed-props/index.ts
Normal file
87
demo/examples/typed-props/index.ts
Normal 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 }
|
||||
18
demo/examples/typed-props/tsconfig.json
Normal file
18
demo/examples/typed-props/tsconfig.json
Normal 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"]
|
||||
}
|
||||
|
|
@ -40,6 +40,13 @@
|
|||
Default log-and-skip vs <code>static strictProps</code> throwing.
|
||||
</div>
|
||||
</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<typeof props></code> infers
|
||||
<code>this.props.*</code> from the defaults.
|
||||
</div>
|
||||
</a>
|
||||
<a class="card" href="./examples/attribute-lifecycle/index.html">
|
||||
<div class="name">Attribute lifecycle</div>
|
||||
<div class="desc">
|
||||
|
|
|
|||
|
|
@ -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 <script src>s).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RAW_JS = import.meta.glob('./examples/**/*.{js,mjs}', {
|
||||
const RAW_JS = import.meta.glob('./examples/**/*.{js,mjs,ts}', {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
|
|
@ -103,6 +106,9 @@ const basename = (path) => path.split('/').pop()
|
|||
// 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.
|
||||
let highlighterPromise
|
||||
/**
|
||||
*
|
||||
*/
|
||||
function getHighlighter() {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = (async () => {
|
||||
|
|
@ -111,8 +117,9 @@ function getHighlighter() {
|
|||
import('shiki/core'),
|
||||
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/typescript.mjs'),
|
||||
import('shiki/langs/html.mjs'),
|
||||
import('shiki/langs/css.mjs'),
|
||||
import('shiki/themes/github-dark.mjs'),
|
||||
|
|
@ -120,7 +127,7 @@ function getHighlighter() {
|
|||
])
|
||||
return createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine(),
|
||||
langs: [js.default, htmlLang.default, css.default],
|
||||
langs: [js.default, ts.default, htmlLang.default, css.default],
|
||||
themes: [dark.default, light.default],
|
||||
})
|
||||
})()
|
||||
|
|
@ -128,10 +135,18 @@ function getHighlighter() {
|
|||
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
|
||||
// the injected shell tags so only the actual example remains.
|
||||
/**
|
||||
*
|
||||
* @param html
|
||||
*/
|
||||
function stripShell(html) {
|
||||
return html
|
||||
.split('\n')
|
||||
|
|
@ -141,6 +156,10 @@ function stripShell(html) {
|
|||
}
|
||||
|
||||
// ts-check strip comments
|
||||
/**
|
||||
*
|
||||
* @param js
|
||||
*/
|
||||
function stripTsCheck(js) {
|
||||
return js
|
||||
.split('\n')
|
||||
|
|
@ -149,6 +168,10 @@ function stripTsCheck(js) {
|
|||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param folder
|
||||
*/
|
||||
function sourcesForFolder(folder) {
|
||||
const prefix = `./examples/${folder}/`
|
||||
const jsKeys = Object.keys(RAW_JS)
|
||||
|
|
@ -166,6 +189,9 @@ function sourcesForFolder(folder) {
|
|||
.map((k) => ({ name: basename(k), code: stripShell(RAW_HTML[k]) }))
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
async function renderSourcePreview() {
|
||||
const match = location.pathname.match(/\/examples\/([^/]+)\//)
|
||||
if (!match) return
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
</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
|
||||
|
||||
The current alternatives are using what `HTMLElement` provides out-of-the-box, which are:
|
||||
|
|
|
|||
|
|
@ -38,7 +38,8 @@
|
|||
"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: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:build": "pnpm -F demo build",
|
||||
"docs": "pnpm -F docs start",
|
||||
|
|
|
|||
|
|
@ -44,9 +44,25 @@ function cloneDefaults(ctor) {
|
|||
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
|
||||
*
|
||||
* 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/prop-access/
|
||||
*/
|
||||
export class WebComponent extends HTMLElement {
|
||||
#host
|
||||
|
|
@ -57,8 +73,9 @@ export class WebComponent extends HTMLElement {
|
|||
#connected = false
|
||||
|
||||
/**
|
||||
* Blueprint for the Proxy props
|
||||
* @typedef {{[name: string]: any}} PropStringMap
|
||||
* Declared props and their defaults. The value types of this object drive
|
||||
* both the runtime type guard and — when passed as the class type argument
|
||||
* — the compile-time type of `this.props`.
|
||||
* @type {PropStringMap}
|
||||
*/
|
||||
static props
|
||||
|
|
@ -91,7 +108,7 @@ export class WebComponent extends HTMLElement {
|
|||
/**
|
||||
* Read-only property containing camelCase counterparts of observed attributes.
|
||||
* @see https://webcomponent.io/prop-access/
|
||||
* @type {PropStringMap}
|
||||
* @returns {Props}
|
||||
*/
|
||||
get props() {
|
||||
return this.#props
|
||||
|
|
|
|||
12
test/types/tsconfig.json
Normal file
12
test/types/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./*.ts"]
|
||||
}
|
||||
50
test/types/typed-props.test-d.ts
Normal file
50
test/types/typed-props.test-d.ts
Normal 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 }
|
||||
Loading…
Reference in a new issue