Extensible Components
ivue components are classes, and classes extend. A ContactField that extends the choose-field machinery needs its entire surface to extend with it — props inherited, re-defaulted and grown; emits carried through; slots wrappable rather than replaced. Vue's withDefaults(defineProps<T>()) macro cannot express that: its defaults are locked inside a compiler transform, invisible to any wrapper.
This page describes the params/defaults architecture the advanced field components use: every prop declared once as a type, every default declared once as a plain value, and propsWithDefaults() merging them into a standard Vue props object — one that a child component can spread, re-default, and grow.
The shape
Three declarations per component, each doing exactly one job:
// ChooseFieldProps.ts (excerpt)
import type { ExtractPropTypes, PropType } from 'vue';
import {
propsWithDefaults,
type ExtractEmitTypes,
type ExtractPropDefaultTypes,
} from 'ivue';
/** 1 — the TYPES: a defineComponent-style object, no defaults inside. */
export const chooseFieldParamsTypes = {
multiple: { type: Boolean as PropType<boolean> },
useChips: { type: Boolean as PropType<boolean> },
inputDebounce: { type: Number as PropType<number> },
fetchPath: { type: String as PropType<string> },
fetchRowsPerPage: { type: Number as PropType<number> },
optionLabelPriority: { type: Array as PropType<string[]> },
};
/** 2 — the DEFAULTS: plain values, typed against the types object. */
export const chooseFieldParamsDefaults: ExtractPropDefaultTypes<
typeof chooseFieldParamsTypes
> = {
multiple: false,
useChips: false,
inputDebounce: 250,
fetchPath: '',
fetchRowsPerPage: 20,
optionLabelPriority: ['label', 'name', 'value', 'id'],
};
/** 3 — the MERGE: a standard Vue props object, ready for defineProps. */
export const chooseFieldProps = propsWithDefaults(
chooseFieldParamsDefaults,
chooseFieldParamsTypes,
);
export type IChooseFieldProps = ExtractPropTypes<typeof chooseFieldProps>;Extension is the point
Because the types and defaults are plain, inspectable objects, a wrapping component composes its props surface by spread — inherit everything, override the defaults that define its specialization, add its own:
// ContactFieldProps.ts — extends the choose-field surface
export const contactFieldParamsTypes = {
...chooseFieldParamsTypes,
/** Compact display mode: smaller avatar, name only, denser rows. */
compact: { type: Boolean as PropType<boolean> },
};
export const contactFieldParamsDefaults: ExtractPropDefaultTypes<
typeof contactFieldParamsTypes
> = {
...chooseFieldParamsDefaults,
fetchPath: '/contact', // its endpoint
optionLabelPriority: ['name', 'email', 'id'], // its label rules
optionDescriptionPriority: ['role', 'company'], // its captions
compact: false, // its own prop
};
export const contactFieldParams = propsWithDefaults(
contactFieldParamsDefaults,
contactFieldParamsTypes,
);This is the props-side mirror of class $ContactField extends ChooseField.$Class — the class hierarchy and the props hierarchy extend together. See it live in the Advanced Select Field example, where ContactField is exactly this wrapper.
propsWithDefaults is non-mutating by design: spreading a types map shares the inner { type } descriptor objects, so an in-place implementation would silently rewrite the base component's defaults when the child applies different ones. Each descriptor is copied — the base surface is never touched by its children.
Why not withDefaults(defineProps<T>())?
Vue's macro form is right for small, terminal components. It stops scaling exactly where extensible components begin:
- Macro defaults aren't data. They're locked inside a compiler transform — a wrapper cannot import them, spread them, or override three of them.
- Object and array defaults become factory noise. In the macro form every non-primitive default must be a function.
propsWithDefaultshandles it once: objects and arrays are wrapped in cloning factories automatically (viastructuredClone; pass a custom cloner for exotic values), primitives pass through untouched. ExtractPropDefaultTypeskeeps the pair honest: the defaults object must provide a correctly-typed value for every declared type, so a new prop without a default is a compile error, not a runtime surprise.
Emits and slots, same discipline
Emits are declared as an object of validators and converted to the emit function's type by ExtractEmitTypes; slots interfaces extend a wrapped component's slots with before--/after-- prefixed variants via ExtendSlots, which is what lets a wrapping component inject content around every inherited slot without redeclaring any of them:
export const chooseFieldEmits = {
'update:model-value': (newValue: any) => true,
remove: (removed: { index: number; value: any }) => true,
};
export type IChooseFieldEmits = ExtractEmitTypes<typeof chooseFieldEmits>;Where to see it at scale
The Advanced Select Field and Advanced Media Uploader are built entirely on this pattern — their props files are the production-grade reference, and each class consumes every prop through plain prop-getters per the standard.