Skip to content

Props & Defaults ​

In ivue a component's contract is a value on its class. The prop types, the defaults, the validators and the emits are static getters, and the TypeScript types are derived from them. This page states the rule, shows the shape, and says why the runtime form is the standard when Vue's own <script setup> moved to the type-based one.

Types describe a contract. Values keep it.

The shape ​

Three static getters, one derived type. The SFC hands the fused object to defineProps and constructs the class.

ts
import type { ExtractPropTypes, PropType } from 'vue';
import { definePropTypes, propsWithDefaults, Reactive } from 'ivue';
import { Static } from 'ivue/extras';

class $Badge {
  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);
  }

  constructor(public props: Badge.Props) {}
}

export namespace Badge {
  export const $Class = Static($Badge);
  export let Class = Reactive($Class);
  export type Instance = typeof Class.Instance;

  export type Tone = 'neutral' | 'success' | 'danger';
  export type Props = ExtractPropTypes<typeof $Class.props>;
}
vue
<script lang="ts" setup>
import { Badge } from './Badge';

const props = defineProps(Badge.Class.props);

const badge = new Badge.Class(props as Badge.Props);
</script>

The pieces, and what each one is for:

  • propsTypes is the runtime declaration Vue checks: constructor, required, validator. PropType<T> attaches the exact TypeScript type to a value, so an array prop knows its element type and a string prop can carry a literal union.
  • propsDefaults is a plain object of values. propsWithDefaults fuses it into the types and wraps object and array defaults in factories, the way Vue requires. clone copies plain containers while preserving nested callbacks, class constructors and opaque objects by reference. Pass a custom cloner when those objects need their own copies.
  • props is the fused object the SFC uses. A subclass that only re-tunes defaults inherits it. A subclass that adds a prop re-declares it, because a static getter's return type is not polymorphic.
  • Props is derived with ExtractPropTypes, so the type and the value can never disagree. Nothing is declared twice.
  • emits follows the same rule as a static getter returning the emits object, and every emit fires through a method so a subclass can override the moment it fires.

What type-based props lose ​

Vue's <script setup> accepts defineProps<{ … }>() and compiles the type literal into a runtime declaration. The runtime declaration is what ships. The type is an input to the compiler, and the compiler keeps what it can read.

you writethe compiler shipsgone
width: number{ type: Number, required: true }nothing
items: Item[]{ type: Array }the element type
mode: SortMode imported from another file{ type: null }the runtime check
a validatornothingit cannot be expressed as a type

Measured on @vue/compiler-sfc 3.5. The compiled object is real and sits on the component definition, but it is the post-erasure object, untyped, and owned by that one SFC. The article walks the receipts.

Declaring the runtime form yourself, on the class, is not a fallback. It is the form the compiler was translating into all along, written once where it can be typed, reused, and read.

What a value can do that a type cannot ​

  • Inherit. ...super.propsTypes plus the new keys. A variant of a forty-five prop component re-declares nothing. The four moves, re-tune, add, narrow, share, are on Extensible Components.
  • Validate. validator is a function on the declaration. Vue calls it in development, and so can a test or a panel.
  • Be read. Badge.Class.propsDefaults.size is 14 in a test with no component mounted. A knobs panel seeds its controls from the defaults and picks control kinds from the types. An agent writing a variant imports the class and enumerates the contract as data.
  • Outlive the renderer. The contract is on the class, not inside a compiler macro, so the same model runs under a template, a render function, or a Vapor template.

Nested defaults, complete at every depth ​

A knobs prop is an object with objects inside: a wheel's gain, follow and cap; a finger's gain, follow, inertia and cap. A page should be able to set one leaf and keep the rest tuned. Vue does not do that. It resolves a default only when the prop is absent, so a page passing { wheel: { gain: 2 } } hands the component exactly that object, and the touch defaults are gone.

The fill is possible here because the defaults are a value the class owns. nestedProps, from ivue/extras, fills the props from them at the seam where props enter the class, once:

ts
import { nestedProps, type NestedPartial } from 'ivue/extras';

class $Scroller {
  static get propsTypes() {
    return definePropTypes({
      scroll: { type: Object as PropType<NestedPartial<Scroller.ScrollKnobs>> },
    });
  }

  static get propsDefaults() {
    return { scroll: this.SCROLL_KNOBS };
  }

  props: Scroller.MergedProps;

  constructor(props: Scroller.Props) {
    this.props = nestedProps(props, this.self.propsDefaults);
  }
}

The class then reads this.props.scroll.wheel.follow and it is always there. The fill is in place, the semantics lodash's defaultsDeep with arrays taken whole: for every prop whose value and default are both plain objects, each leaf the supplied object lacks is written into it from the default, recursively, and a leaf it has is kept. Missing plain-object and array branches are copied so instances keep independent containers. Class instances and functions retain their references. Arrays are never merged. Vue's props proxy is shallow, so the nested objects are the parent's own and are written directly; the props object itself is untouched and returned. NestedPartial<T> is the type a page may pass, and NestedProps<P, D> the type the class reads.

The parent passes a stable object: a constant inline literal, which Vue's compiler hoists, a ref's value, or a store field. An object built anew on every parent render arrives unfilled each time.

A third argument, customCloner, is the same policy knob propsWithDefaults has. The default copies each default branch it writes, so no two instances share a mutable default. Pass structuredClone when Date or Map defaults need their own copies, and the identity function only when the caller owns a fresh tree per instance.

A withDefaults(defineProps<…>()) component cannot do this. Its defaults exist only inside the compiler and never reach runtime, so there is nothing to merge from. The virtual scroller is the worked example.

Where type-based props still belong ​

Vue chose the type-based form for the architecture it assumed: the SFC owns its contract, nothing extends it, and the props are consumed in the same file. That is still right for a markup-only leaf with a prop or two. Such a leaf has no class, and defineProps<{ … }>() is the shortest correct thing.

The boundary is the same one the whole standard uses. A component that owns state, derivation, or a handler has a model. The model owns the contract, and the contract is a value.

The cost ​

Array as PropType<Item[]> is a cast a type literal never asks for. Inside a generic component that cast is the only way to name T, so the compiler helps less there, not more. That is the whole price, against a contract that is never erased and never declared twice.

See it running ​

Released under the MIT License.