Extend Props & Defaults
Badge declares its contract as three static getters: the prop types, the defaults, and the fused props object the SFC hands to defineProps. IconBadge extends it with super, adds one prop and re-tunes one default. The panel below is not a list someone typed. Every knob, every default, the validator verdict and the inheritance ledger are read off the classes at runtime.
What to notice
- The knobs come from the class.
Badge.Class.propsTypesdecides the control kind,Badge.Class.propsDefaultsseeds the value, andBadge.Class.propsChoices, a live static knob, supplies the choices. Add a prop to the class and a knob appears. - The validator is a value. Pick
size0. The panel calls the same function Vue would call, and says so. A type-based prop cannot declare one. - The ledger is a diff of two contracts. Inherited, added and re-tuned are computed from the two classes' statics, not written down.
- Three renders, one contract. The base with the knob values, the variant with the same values plus its icon, and the variant left to its own defaults, where
sizeis16because the child said so.
The base contract
// Badge.ts — a component whose WHOLE contract is a value on the class:
// prop types, defaults and a validator as static getters, the TypeScript
// type derived from them. Anything can read it — the SFC, a subclass, a
// test, a knobs panel — because nothing here is only a type.
import type { ExtractPropTypes, PropType } from 'vue';
import { definePropTypes, propsWithDefaults, Reactive } from '../../ivue';
import { Static } from '../../Static';
class $Badge {
/* Contract — STATIC */
static get propsTypes() {
return definePropTypes({
label: { type: String, required: true },
tone: { type: String as PropType<Badge.Tone> },
size: { type: Number, validator: (size: number) => size > 0 },
rounded: { type: Boolean }
});
}
static get propsDefaults() {
return { tone: 'success' as Badge.Tone, size: 14, rounded: false };
}
static get props() {
return propsWithDefaults(this.propsDefaults, this.propsTypes);
}
/** KNOB — the values a panel may offer for each prop; a live static, read like the rest. */
static get propsChoices() {
return {
label: ['runtime', 'props', 'value', 'ivue'],
tone: ['neutral', 'success', 'danger'] as readonly Badge.Tone[],
size: [0, 12, 14, 16, 20, 28]
};
}
constructor(public props: Badge.Props) {}
// PROPS — plain getters, leaf-tracked through the props proxy
get label() {
return this.props.label;
}
get tone() {
return this.props.tone;
}
get size() {
return this.props.size;
}
get rounded() {
return this.props.rounded;
}
// DERIVED — what the template binds
get classes() {
return ['badge', `badge--${this.tone}`, { 'badge--rounded': this.rounded }];
}
get style() {
return { fontSize: `${this.size}px` };
}
}
export namespace Badge {
export const $Class = Static($Badge); // anchor — it declares statics; children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
export type Tone = 'neutral' | 'success' | 'danger';
export type Props = ExtractPropTypes<typeof $Class.props>;
}<script lang="ts" setup>
import { Badge } from './Badge';
// the runtime contract, straight off the class — types, defaults, validator
const props = defineProps(Badge.Class.props);
const badge = new Badge.Class(props as Badge.Props);
</script>
<template>
<span :class="badge.classes" :style="badge.style">{{ badge.label }}</span>
</template>
<style>
.badge {
display: inline-flex;
align-items: center;
gap: 0.4em;
padding: 0.3em 0.7em;
border-radius: 6px;
font-weight: 600;
line-height: 1;
border: 1px solid transparent;
}
.badge--rounded {
border-radius: 999px;
}
.badge--neutral {
background: rgba(148, 163, 184, 0.16);
border-color: rgba(148, 163, 184, 0.3);
color: var(--vp-c-text-1, #cbd5e1);
}
.badge--success {
background: rgba(52, 211, 153, 0.16);
border-color: rgba(52, 211, 153, 0.4);
color: var(--vp-c-green-1, #6ee7b7);
}
.badge--danger {
background: rgba(248, 113, 113, 0.16);
border-color: rgba(248, 113, 113, 0.4);
color: var(--vp-c-danger-1, #fca5a5);
}
</style>The extended contract
...super.propsTypes and ...super.propsDefaults, then the one addition and the one re-tune. props is re-declared because the child adds a prop and a static's return type is not polymorphic.
// IconBadge.ts — the variant. It ADDS one prop, RE-TUNES one default, and
// inherits the rest by `super` — the compiler checks every override.
import type { ExtractPropTypes } from 'vue';
import { definePropTypes, propsWithDefaults, Reactive } from '../../ivue';
import { Static } from '../../Static';
import { Badge } from './Badge';
class $IconBadge extends Badge.$Class {
/* Contract — STATIC, extended */
static override get propsTypes() {
return definePropTypes({
...super.propsTypes,
icon: { type: String }
});
}
static override get propsDefaults() {
return { ...super.propsDefaults, size: 16, icon: '★' };
}
// re-declared because it ADDS a prop — static return types are not polymorphic
static override get props() {
return propsWithDefaults(this.propsDefaults, this.propsTypes);
}
constructor(public override props: IconBadge.Props) {
super(props);
}
get icon() {
return this.props.icon;
}
}
export namespace IconBadge {
export const $Class = Static($IconBadge); // anchor — it declares statics; children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
export type Props = ExtractPropTypes<typeof $Class.props>;
}<script lang="ts" setup>
import { IconBadge } from './IconBadge';
// the EXTENDED contract — four inherited props, one added, one re-tuned
const props = defineProps(IconBadge.Class.props);
const badge = new IconBadge.Class(props as IconBadge.Props);
</script>
<template>
<span :class="badge.classes" :style="badge.style">
<span aria-hidden="true">{{ badge.icon }}</span>
{{ badge.label }}
</span>
</template>Playground wrapper
The route model reads the statics and owns the knob values. The template binds names only.
// PropsContractExample.ts — the route's ONE model. Everything it shows is
// READ off the two classes' statics: the knobs panel is built from
// Badge's prop types and defaults, the ledger from the difference between
// Badge's contract and IconBadge's. No list of props is written here.
import { ref } from 'vue';
import { Reactive } from '../../ivue';
import { Badge } from './Badge';
import { IconBadge } from './IconBadge';
class $PropsContractExample {
// MUTABLE STATE — the knob values, seeded from the class's own defaults
get values() {
return ref<PropsContractExample.Values>({
label: 'runtime',
...Badge.Class.propsDefaults
});
}
// DERIVED — the knobs panel, read off the contract
get controls(): PropsContractExample.Control[] {
const types = Badge.Class.propsTypes;
const defaults: Partial<Record<PropsContractExample.PropName, unknown>> =
Badge.Class.propsDefaults;
const choices: Partial<
Record<PropsContractExample.PropName, readonly PropsContractExample.Choice[]>
> = Badge.Class.propsChoices;
return (Object.keys(types) as PropsContractExample.PropName[]).map((name) => {
const declaration = types[name] as PropsContractExample.Declaration;
return {
name,
kind: this.kindOf(declaration.type),
required: declaration.required === true,
defaultValue: defaults[name],
validator: declaration.validator,
choices: choices[name]
};
});
}
// DERIVED — the inheritance ledger, read off both contracts
get baseNames() {
return Object.keys(Badge.Class.propsTypes);
}
get variantNames() {
return Object.keys(IconBadge.Class.propsTypes);
}
get addedNames() {
return this.variantNames.filter((name) => !this.baseNames.includes(name));
}
get retunedDefaults() {
const base: Record<string, unknown> = Badge.Class.propsDefaults;
const variant: Record<string, unknown> = IconBadge.Class.propsDefaults;
return this.baseNames
.filter((name) => name in variant && variant[name] !== base[name])
.map((name) => `${name}: ${String(base[name])} → ${String(variant[name])}`);
}
get inheritedLabel() {
return `${this.baseNames.length} inherited`;
}
get addedLabel() {
return `${this.addedNames.length} added (${this.addedNames.join(', ')})`;
}
get retunedLabel() {
return this.retunedDefaults.join(', ');
}
/** The validator is a VALUE — the panel calls it the way Vue would. */
get invalidNames() {
return this.controls
.filter((control) => control.validator && !control.validator(this.values.value[control.name]))
.map((control) => control.name);
}
get hasInvalid() {
return this.invalidNames.length > 0;
}
get validityLabel() {
return this.hasInvalid
? `validator rejects: ${this.invalidNames.join(', ')}`
: 'every value passes its validator';
}
// PER-CONTROL — named template conditions and labels
hasChoices(control: PropsContractExample.Control) {
return control.choices !== undefined;
}
isChosen(control: PropsContractExample.Control, choice: PropsContractExample.Choice) {
return this.values.value[control.name] === choice;
}
isNumberControl(control: PropsContractExample.Control) {
return control.kind === 'number';
}
isBooleanControl(control: PropsContractExample.Control) {
return control.kind === 'boolean';
}
isTextControl(control: PropsContractExample.Control) {
return control.kind === 'text';
}
isInvalid(control: PropsContractExample.Control) {
return this.invalidNames.includes(control.name);
}
defaultLabel(control: PropsContractExample.Control) {
return control.required
? 'required, no default'
: `default: ${JSON.stringify(control.defaultValue)}`;
}
currentLabel(control: PropsContractExample.Control) {
return `current: ${JSON.stringify(this.values.value[control.name])}`;
}
numberValue(control: PropsContractExample.Control) {
return Number(this.values.value[control.name]);
}
booleanValue(control: PropsContractExample.Control) {
return this.values.value[control.name] === true;
}
textValue(control: PropsContractExample.Control) {
return String(this.values.value[control.name] ?? '');
}
// ACTIONS
choose(control: PropsContractExample.Control, choice: PropsContractExample.Choice) {
this.write(control, choice);
}
setNumber(control: PropsContractExample.Control, event: Event) {
this.write(control, Number((event.target as HTMLInputElement).value));
}
setBoolean(control: PropsContractExample.Control, event: Event) {
this.write(control, (event.target as HTMLInputElement).checked);
}
setText(control: PropsContractExample.Control, event: Event) {
this.write(control, (event.target as HTMLInputElement).value);
}
/** One write path for every knob — the value's type is the control's, not the record's. */
write(control: PropsContractExample.Control, value: unknown) {
(this.values.value as Record<string, unknown>)[control.name] = value;
}
reset() {
this.values.value = { label: 'runtime', ...Badge.Class.propsDefaults };
}
kindOf(type: unknown): PropsContractExample.Kind {
if (type === Number) return 'number';
if (type === Boolean) return 'boolean';
return 'text';
}
}
export namespace PropsContractExample {
export const $Class = $PropsContractExample; // raw — children `extends` this
export let Class = Reactive($Class); // reactive — you `new` this
export type Instance = typeof Class.Instance; // defineExpose type & reactive() interop
export type PropName = keyof typeof Badge.Class.propsTypes;
export type Values = Record<PropName, unknown> & { label: string };
export type Kind = 'number' | 'boolean' | 'text';
export type Choice = string | number;
export interface Declaration {
type: unknown;
required?: boolean;
validator?: (value: unknown) => boolean;
}
export interface Control {
name: PropName;
kind: Kind;
required: boolean;
defaultValue: unknown;
validator?: (value: unknown) => boolean;
choices?: readonly Choice[];
}
}<script lang="ts" setup>
import '../example-pane.css';
import Badge from './Badge.vue';
import IconBadge from './IconBadge.vue';
import { PropsContractExample } from './PropsContractExample';
const example = new PropsContractExample.Class();
// the state destructure
const {
// state refs
values
} = example;
</script>
<template>
<div class="pane pane-props">
<p class="note">
Nothing below is hand-listed. The knobs are read off
<code>Badge.Class.propsTypes</code>, seeded from <code>Badge.Class.propsDefaults</code>, and
their choices come from <code>Badge.Class.propsChoices</code>; the ledger is the difference
between Badge's contract and IconBadge's. Pick size 0 and the class's own validator says so —
it is a value the panel can call.
</p>
<div class="knobs">
<div
v-for="control in example.controls"
:key="control.name"
class="knob"
:class="{ 'knob--invalid': example.isInvalid(control) }"
>
<span class="k">{{ control.name }}</span>
<span v-if="example.hasChoices(control)" class="choices">
<button
v-for="choice in control.choices"
:key="choice"
type="button"
class="choice"
:class="{ 'choice--active': example.isChosen(control, choice) }"
@click="example.choose(control, choice)"
>
{{ choice }}
</button>
</span>
<input
v-else-if="example.isNumberControl(control)"
type="number"
class="knob__input"
:value="example.numberValue(control)"
@input="example.setNumber(control, $event)"
/>
<label v-else-if="example.isBooleanControl(control)" class="switch">
<input
type="checkbox"
:checked="example.booleanValue(control)"
@change="example.setBoolean(control, $event)"
/>
<span class="mono">on</span>
</label>
<input
v-else
type="text"
class="knob__input"
:value="example.textValue(control)"
@input="example.setText(control, $event)"
/>
<span class="mono">{{ example.defaultLabel(control) }}</span>
<span class="mono current">{{ example.currentLabel(control) }}</span>
</div>
</div>
<div class="row" style="margin-bottom: 18px">
<span class="mono" :class="{ invalid: example.hasInvalid }">{{ example.validityLabel }}</span>
<button class="btn" type="button" @click="example.reset()">reset to defaults</button>
</div>
<div class="stages">
<div class="stage-card">
<div class="k">Badge · the knob values</div>
<div class="stage"><Badge v-bind="values" /></div>
</div>
<div class="stage-card">
<div class="k">IconBadge · the knob values, plus its icon</div>
<div class="stage"><IconBadge v-bind="values" /></div>
</div>
<div class="stage-card">
<div class="k">Badge · its own defaults</div>
<div class="stage"><Badge :label="values.label" /></div>
</div>
<div class="stage-card">
<div class="k">IconBadge · its own defaults (size re-tuned to 16)</div>
<div class="stage"><IconBadge :label="values.label" /></div>
</div>
</div>
<div class="receipt">
<div>IconBadge extends Badge.$Class</div>
<div>{{ example.inheritedLabel }} · {{ example.addedLabel }}</div>
<div>re-tuned default — {{ example.retunedLabel }}</div>
</div>
</div>
</template>
<style scoped>
.pane-props {
max-width: 720px;
padding: 12px 18px 16px;
}
.pane-props .note {
margin: 0 0 12px;
}
.pane-props .receipt {
margin-bottom: 0;
color: var(--vp-c-text-2, #b7c0dc);
background: var(--vp-c-bg-alt, rgba(255, 255, 255, 0.03));
border-color: var(--vp-c-divider, rgba(148, 163, 184, 0.16));
}
.stages {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.stage-card {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--vp-c-divider, rgba(148, 163, 184, 0.16));
background: var(--vp-c-bg-alt, rgba(255, 255, 255, 0.03));
}
.stages .k {
white-space: normal;
margin-bottom: 6px;
}
.knobs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.knob {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--vp-c-divider, rgba(148, 163, 184, 0.16));
background: var(--vp-c-bg-alt, rgba(255, 255, 255, 0.03));
}
.knob--invalid {
border-color: rgba(248, 113, 113, 0.6);
}
.choices {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.choice {
padding: 4px 9px;
border-radius: 6px;
border: 1px solid var(--vp-c-divider, rgba(148, 163, 184, 0.25));
background: var(--vp-c-bg, rgba(255, 255, 255, 0.04));
color: var(--vp-c-text-2, #cbd5e1);
font-size: 12px;
cursor: pointer;
}
.choice:hover {
border-color: #6366f1;
}
.choice--active {
border-color: rgba(99, 102, 241, 0.7);
background: rgba(99, 102, 241, 0.18);
color: var(--vp-c-brand-1, #e0e7ff);
}
.current {
align-self: flex-start;
padding: 2px 7px;
border-radius: 6px;
background: rgba(99, 102, 241, 0.14);
color: var(--vp-c-brand-1, #a5b4fc) !important;
font-weight: 600;
}
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.knob__input {
width: 100%;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(255, 255, 255, 0.04);
color: inherit;
font-size: 13px;
}
.stage {
min-height: 40px;
display: flex;
align-items: center;
}
.invalid {
color: var(--vp-c-danger-1, #fca5a5) !important;
}
@media (max-width: 767px) {
.stages,
.knobs {
grid-template-columns: 1fr;
}
}
</style>Related guide pages
- Props & defaults — the principle: declare the value, derive the type.
- Extensible Components — the four moves a contract can make on a base.
- Inheritance & super — how
superreaches the base contract.