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.
|
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:
|
||||||
|
|
|
||||||
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.
|
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<typeof props></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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
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