skip to content

In Vue 3.5, how do `withDefaults` and destructured defaults differ for type-based props such as a SelectInput's `options?: Option[]`?

level: middleimportance: must knowfreq 55%

answer

  1. a second macro wrapping defineProps
  2. native default syntax since 3.5
  3. mutable defaults need a factory
  4. the compiler adds the factory for you
  5. do not combine the two

basics

~20 s

withDefaults(defineProps<Props>(), { options: () => [] }) needs factory functions for mutable defaults. Since 3.5, destructuring, const { options = [] } = defineProps<Props>(), lets the compiler add the factory. Both compile to runtime defaults.

solid answer

~50 s

Type-based props cannot carry defaults inside the type, so Vue offers two ways. `withDefaults(defineProps<Props>(), { placeholder: 'Select...', options: () => [] })` compiles its second argument into runtime `default` options, type-checks the defaults and removes the optional flag from defaulted props on the returned type; array and object defaults must be written as functions so each instance gets its own copy. Since Vue 3.5, reactive props destructure is on by default, so `const { placeholder = 'Select...', options = [] } = defineProps<Props>()` does the same with native syntax: the compiler moves each default into the runtime declaration and wraps non-literal defaults in a factory itself, and it reports a best-effort mismatch between default and type. Do not combine them: destructuring a `withDefaults` result triggers a compiler warning and disables reactive destructure. On 3.4 and below, `withDefaults` was the way.

go deeper

for a junior

Recall that type-based props get defaults through withDefaults or, in Vue 3.5, destructure default values.

for a middle

Explain why withDefaults needs factories for arrays and objects and why destructure defaults do not.

for a senior

Spot mixed styles and shared mutable defaults in review, and migrate withDefaults code to destructure defaults safely.

for a principal

Pick one defaults style for the codebase, weighing a props object against local variables and the version floor of shared libraries.

## Why defaults need special syntax A TypeScript type cannot hold values, so `defineProps<{ placeholder?: string }>()` has nowhere to put a default placeholder. Vue has two answers for type-based props, and which one a codebase uses usually depends on when it was written. ```ts interface Option { value: string; label: string } interface Props { options?: Option[] placeholder?: string searchable?: boolean } ``` ## Option 1: `withDefaults` ```ts const props = withDefaults(defineProps<Props>(), { options: () => [], placeholder: 'Select...', searchable: false }) ``` - It is a **compiler macro** taking the `defineProps` call as its first argument; the second argument is required. - The defaults are compiled into the runtime props' **`default`** options. - The defaults are **type-checked** against `Props`. - The returned `props` type **drops the optional flag** for defaulted props: `props.placeholder` is `string`, not `string | undefined`. - **Mutable defaults must be factory functions** (`() => []`): otherwise every instance would share one array, and a mutation in one select would appear in all of them. - It works only with **type-based** `defineProps`; the compiler rejects it around a runtime declaration. ## Option 2: destructure defaults (3.5+) ```ts const { options = [], placeholder = 'Select...', searchable = false } = defineProps<Props>() ``` - Since **Vue 3.5**, reactive props destructure is enabled by default, so native JavaScript default syntax declares prop defaults. - The compiler moves each default into the runtime declaration. For a **non-literal** default such as `[]`, it generates a factory `() => ([])` itself, so **no manual wrapper** is needed and each instance still gets its own array. - TypeScript's own destructuring rules make `options` an `Option[]` without `undefined`. - The compiler runs a best-effort check and fails with `Default value of prop "x" does not match declared type.` when, say, a string default is given to a `number` prop. - Code in the same `<script setup>` that reads `options` is compiled to `props.options`, which is what keeps it reactive. ## Side by side | Aspect | `withDefaults` | Destructure defaults | |---|---|---| | Available | Vue 3.2 and later | Default since Vue 3.5 | | Array or object default | Must be `() => []` | Plain `[]`; compiler wraps it | | Result | A `props` object | Local variables | | Type of defaulted prop | Optional flag removed | Non-undefined via destructuring | | Mismatch check | Type-checked by TypeScript | Best-effort compiler check | ## Do not combine them ```ts // compiler warning const { options } = withDefaults(defineProps<Props>(), { options: () => [] }) ``` The compiler warns that `withDefaults()` is unnecessary when destructuring, that **reactive destructure is disabled** when using it, and suggests destructure defaults instead. Pick one style per component; for new 3.5 code, destructure defaults are the documented recommendation. ## Migrating a component to destructure defaults Moving a 3.5 component from `withDefaults` to destructure defaults is mechanical, but each step has a trap: 1. **Move each default into the destructuring pattern**, unwrapping factories: `options: () => []` becomes `options = []`, because the compiler now adds the factory. 2. **Keep function-typed defaults as functions**: for a prop typed as a function, the default value itself is the function, and the compiler does not wrap it again. 3. **Replace `props.x` reads with the local names**, or keep a separate `props` object if the template or script relies on it; the destructured names are compiled back into `props.x` accesses. 4. **Check code that passes a prop onward**, for example into `watch` or a composable; destructured props follow their own reactivity rules there, so review those call sites rather than assuming the rewrite is purely cosmetic. 5. **Run `vue-tsc`**: the types of defaulted props stay non-optional, so consumers should see no change. ## Pitfalls 1. Writing `options: []` inside `withDefaults`: one shared array for every instance. 2. Adding `() => []` inside a destructure default: the default becomes a function value, not an array. 3. Assuming destructure works the same on Vue 3.4 and below, where it was not enabled by default. 4. Assigning to a destructured prop: props are readonly, and the compiler rejects it.

  • What does the Vue 3.5 compiler generate for the destructure default `options = []`?
    A runtime prop entry whose `default` is a factory, `() => ([])`, because `[]` is not a literal primitive. Each component instance therefore gets a fresh array, which is why destructure defaults need no manual function wrapper while `withDefaults` does.
  • In Vue 3.5, what does `const { size = 'md' } = defineProps<{ size?: number }>()` do at compile time?
    The compiler's best-effort check sees a string default for a prop whose inferred runtime type is `Number` and fails with `Default value of prop "size" does not match declared type.` That check matters because TypeScript alone would accept the destructure and simply widen `size` to `number | string`.

saying these in an interview costs you the question

  • withDefaults accepts plain [] and {} defaults safely because Vue clones them.
  • Destructure defaults need () => [] exactly like withDefaults does.
  • Destructuring the result of withDefaults is the best of both styles.
  • Destructure defaults only affect the TypeScript type, not the runtime props.
  • Reactive props destructure has been on by default since Vue 3.0.