Skip to content
beginner Phase 5 · Components, Props, and Events

Declaring and Validating Props

Type parent data with prop definitions and default values.

45m
0 problems
Topic Progress 0%

Two Ways to Write One Contract: Type-Based vs Runtime defineProps

A reusable Rating component takes a numeric value, an optional max, and a size that must be one of three strings. defineProps — a compile-time macro, so never imported — accepts the contract in two syntaxes, and the difference between them is what runs in the browser.

Type-based declaration (erased at compile time):

<script setup lang="ts">
const props = defineProps<{
  value: number
  max?: number                 // optional via ?
  size: 'sm' | 'md' | 'lg'
}>()
</script>

The SFC compiler reads the type literal, extracts the prop list and optionality, and throws the types away. What ships is a runtime prop config; the type itself exists only for TypeScript. With vue-tsc (or the IDE's Vue plugin) this buys two-way checking: the child template props.value.toFixed() is verified, and the parent's <Rating :value="review.score" /> is verified against the same contract — a string passed to value fails the build, not the runtime. Importing an interface for the shape works the same way since 3.3; the compiler follows the reference.

Runtime declaration (shipped and enforced in dev):

<script setup>
import { PropType } from 'vue'

const props = defineProps({
  value: { type: Number, required: true },
  max: { type: Number, default: 5 },
  size: { type: String as PropType<'sm' | 'md' | 'lg'>, default: 'md' }
})
</script>

Here the object is real JavaScript: it survives the build, and Vue uses it in development to validate every incoming value — wrong type, failed custom validator, missing required prop — producing console warnings that name the component and the prop. PropType<T> is the bridge for TypeScript: it contributes no runtime behavior but tells the type checker what the runtime config means.

The honest comparison, because teams argue about this:

Type-based Runtime object
Checking build-time (vue-tsc), parent and child dev-time console warnings
Custom validators not available (value) => value.length > 0, dev-only
Union/complex types natural in TS PropType casts, limited
Shipped cost prop config only prop config + validator functions
Catches a bad parent at type-check/CI dev console (silent in production)

Most codebases standardize on type-based for the compile-time parent check and accept that unions are validated only by types. Reach for the runtime form when JavaScript consumers must get warnings, when custom validators carry real constraints (a regex on a SKU, a range on a rating), or when the component ships as a library where dev warnings are part of the developer experience.

Reading the props follows the same rules in both worlds. In the template, declared props are directly available and unwrapped — {{ value }}, :class="'size-' + size". In script, the returned object is reactive and read-only:

const props = defineProps<...>()
watch(() => props.value, v => flash(v))   // track a prop with a getter

The prop object is reactive, so a getter-source watcher on a prop re-fires when the parent changes it. What you must never do is destructure for a live view — const { value } = props snapshots the value — the reactivity detach pattern wearing a new hat; Vue 3.5's reactive prop destructure relaxes this in more cases, but reading props.x (or toRefs(props)) remains the portable answer. The next chapters cover defaults and what the dev warnings actually say when the parent gets the contract wrong.

Defaults and Optionality: The Factory-Function Rule and withDefaults

A prop without a default and without ? is required; the moment a parent omits it, the dev console logs Missing required prop: "value" naming your component. Everything else about defaults exists to make "omitted" safe and per-instance.

Type-based defaults go through withDefaults:

const props = withDefaults(defineProps<{
  label: string
  max?: number
  tags?: string[]
  config?: { theme: string }
}>(), {
  max: 5,                       // primitives: the value
  tags: () => [],               // arrays/objects: a FACTORY
  config: () => ({ theme: 'light' })
})

The macro merges the defaults into the generated runtime config, so the behavior is identical to the runtime form — the syntax just keeps types and defaults in one compile step.

Runtime defaults attach to the prop descriptor:

const props = defineProps({
  max: { type: Number, default: 5 },
  tags: { type: Array, default: () => [] }
})

The single rule that generates the most interview questions: object and array defaults must be factory functions. The default expression is evaluated once and stored in the shared prop config if written as a literal — so default: [] hands the same array instance to every Rating ever mounted. One component's tags.push('sale') then appears in every other component's prop, producing a cross-component leak that reproduces only after two instances exist. default: () => [] calls the factory per instance at props initialization, giving each component its own fresh value. Primitives are immutable, so default: 5 needs no wrapper.

Three semantics worth pinning down:

  • undefined triggers the default. <Rating :max="undefined" /> applies the default; the prop is present as a key but has no value. An explicit 0 or '' or null does not — falsy is not absent, and a default that swallows a deliberate 0 is a bug you find in a code review, not a framework quirk.
  • Required and default contradict each other. A default exists precisely to make absence harmless; if a prop is required, absence should warn, and adding a default silently papers over the parent's omission. Pick one meaning.
  • Defaults are evaluated at initialization, not render. A default referencing setup state (default: someLocal.value) captures a snapshot; defaults must be self-contained expressions or factories.

One default rule changed in Vue 3.5, and it is a favorite follow-up question: a default may now reference previously declared props of the same component. The factory receives the props resolved so far, so declaration order becomes meaningful — a default can read props declared above it, never below:

const props = defineProps({
  size: { type: String, default: 'md' },
  // Vue 3.5+: the factory receives previously declared props
  padding: {
    type: String,
    default: (p) => (p.size === 'lg' ? '32px' : '16px')
  }
})

Before 3.5 the factory took no usable argument, so code relying on this pattern carries a version requirement — and either way the factory stays cheap: it runs once per prop, per instance, at props resolution during setup, before the first render.

The full resolution table, because the edge rows are where bugs live:

Parent passes max receives (default: 5, not required)
:max="3" 3
attribute omitted 5 — default applied
:max="undefined" 5 — undefined counts as absent
:max="null" null — null is a value; default skipped
:max="0" 0 — falsy is not absent

The last two rows are why default logic and post-setup normalization must use ??-style checks, never truthiness — a v || 5 silently converts a legitimate 0 into the default, the kind of bug that surfaces as an off-by-one in a price field months later. Between declaration, defaults, and validation, a parent that forgets or mis-types a value gets a named dev-time signal instead of a silent undefined — the practical payoff of the contract set up in Registering Components.

For a JavaScript codebase, defaults plus runtime types give dev-time safety with zero tooling. For TypeScript, withDefaults keeps the contract in one block, and the required/optional distinction (max?: number vs label: string) flows into both the child's logic and the parent's compile check. What neither defaults nor optionality affect is validation — type mismatch messages, custom validators, and the boolean casting rules that turn <Comp ready /> into ready: true are dev-build behaviors of their own, covered next.

What the Dev Console Actually Says: Validators, Mismatches, and Boolean Casting

The runtime prop config is not decoration — in development builds it is an active type checker for the parent-child boundary. Knowing the three warning families and the casting rules is the difference between reading the console and misdiagnosing it.

Missing required prop. <Rating> where value is required: true and the parent passes nothing:

[Vue warn] Missing required prop: "value"
  at <Rating>

The component still renders — props validation warns, it never throws — so a required-prop mistake shows up as undefined behavior downstream (a NaN in a width calculation, an empty heading) plus this warning. The warning is the real signal; treat the downstream crash as a consequence.

Type mismatch. Runtime config checks every incoming value against its declared type in dev:

[Vue warn] Invalid prop: type check failed for prop "max". Expected Number with value 5, got String with value "5".

The classic source of this one is an in-DOM template passing max="5" — a string attribute — where the SFC parent's :max="5" binding correctly produces a number. The fix is the colon, not the prop type.

Custom validators. A function returning false produces:

[Vue warn] Custom validator for prop "value" failed: 4.5

Validators run in development builds only — production strips them for payload size and cost. That has a design consequence: never put business logic where a validator is the only enforcement. The validation exists to catch contract violations in the dev loop; production correctness still needs types, normalizing code, or tests.

Boolean casting is the one rule nobody guesses correctly the first time:

Parent writes Prop type Child receives
<Comp /> (absent) Boolean false
<Comp ready /> (empty string) Boolean true
<Comp :ready="undefined" /> Boolean true — present-but-undefined casts up
<Comp :ready="false" /> Boolean false — the only way to pass false

The rule: for a Boolean-typed prop, presence means true. That is why <Comp :ready="someFlag" /> with someFlag === undefined silently renders the "on" state — undefined does not fall back to a default here, it casts. When false must be expressible, :ready="false" (or omitting the attribute) are the only correct spellings.

Two validation subtleties round out the console-reading skill.

Runtime types accept unions. type: [String, Number] declares "either is fine"; the dev check passes either, and the mismatch message names both (Expected String, Number with value ...). This is the runtime world's limited answer to TypeScript unions — you trade compile-time precision for a permissive dev-time check.

null and undefined short-circuit validation. For a non-required prop, both values skip the type check and the custom validator entirely — no warning, and validator never runs. So :max="null" lands as null silently even though max: { type: Number, validator: v => v > 0 } would reject any real negative. If nullability matters, normalize at the boundary — default: 5 covers undefined but not null — or handle it with ?? in the component.

The interview question this material usually serves: "Why doesn't prop validation throw?" Because validation's job is developer feedback in the dev loop, not runtime enforcement — production strips it entirely, and even in dev a bad render beats a crashed app tree. Production-grade enforcement is schema validation at the app's real boundaries (API responses, form state) with something like Zod, plus the type contract under vue-tsc; the prop config is the friendly early-warning layer on top of both.

One enforcement fact closes the loop: props are read-only proxies, and attempting props.value = 1 in dev warns Set operation on key "value" failed: target is readonly. The framework refuses child-side mutation outright — the parent owns the data — which is the opening argument of One-Way Data Flow Rules and the reason the child's way back up is the event channel of Emitting Events to Parents.

Also remember the excluded names: key, ref, slots, and $attrs are never props no matter what you declare, and undeclared attributes fall through to the child's root element — useful by default, and a warning source when a typo'd attribute (:vallue="x") silently becomes a fallthrough attribute instead of the prop you meant.

Quiz

1. What is the key difference between type-based defineProps<{ max?: number }>() and the runtime form with type: Number?

Question 1 options

2. Why must an array prop default be written default: () => [] instead of default: []?

Question 2 options

3. A Boolean-typed prop receives <Comp :ready="undefined" />. What does the child see, and why?

Question 3 options

4. A parent omits a prop declared as required: true with no default. What happens in development, and what happens in production?

Question 4 options

Flashcards

Question

Compare type-based and runtime defineProps in one breath.

Answer

Type-based (defineProps<{...}>()) is compile-time: types erased at build, checked by vue-tsc for both parent and child, no runtime validators, natural unions. Runtime (defineProps({ x: Number })) ships a real config: dev-build type warnings, custom validators, PropType for TS. Both produce the same reactive, read-only props object.

Question

Why do object/array prop defaults use a factory function, and when is the default applied?

Answer

The prop config is shared, so a literal default would be one instance handed to every mount — mutations leak across components. default: () => [] runs per instance at props initialization. Defaults apply when the prop is absent or bound to undefined; explicit falsy values (0, '', false) do NOT trigger them.

Question

Do props validators run in production, and what follows from that?

Answer

No — type checks and custom validators are development-only, stripped from production bundles for size and cost. So validators catch contract violations during the dev loop, but production correctness must come from types, normalization at the boundary, or tests; never hide business logic in a validator.

Question

State the Boolean prop casting rules.

Answer

For Boolean-typed props, presence means true: attribute absent → false; empty string (<Comp ready />) → true; bound to undefined → true; only :ready="false" passes false. An optional flag bound to undefined silently turns ON — the classic boolean-casting trap.

Question

How do you read a prop safely in script, and what is forbidden?

Answer

Read through the props object (props.x) or via a getter watcher (() => props.x); templates unwrap declared props directly. Forbidden: destructuring for a live view (snapshots the value — toRefs(props) or props.x is portable) and any assignment (props.x = 1 warns 'target is readonly' in dev — the parent owns the data).

Revision Notes

Key Takeaways

  • 1. defineProps declares the component's input contract in either type space (compile-time, vue-tsc-checked, erased) or runtime space (dev-validated, supports custom validators)
  • 2. Object and array defaults must be factory functions — the shared config would otherwise hand one instance to every component mount
  • 3. Defaults fire on absence or undefined, not on falsy-but-present values; required and default are contradictory signals
  • 4. Validation is dev-only and warns rather than throws — production has no validator code, so types and boundary normalization carry correctness
  • 5. Props are reactive and read-only: read props.x (or getter-watch it), never destructure for live state and never assign

Interview Tips

  • • "Type-based or runtime props, and why?" — type-based for the two-sided compile-time check that catches bad parents in CI; runtime when you need dev warnings for JS consumers or custom validators; name that validators never ship to production
  • • "Why default: () => []?" — the shared config would leak one array instance across every mount; the factory runs per instance — mention the two-instance reproduction to show you've debugged it
  • • "My flag renders on even when the value is undefined — why?" — Boolean casting is presence-based; undefined casts to true; :flag="false" or omission are the only false spellings
  • • "Can a child modify a prop?" — no: the props object is a read-only proxy and dev warns on assignment; the data belongs to the parent, so changes go up via events — segue into one-way data flow

Cheat Sheet

Declaring Props

Type-based:

const props = defineProps<{ value: number; max?: number; size: 'sm'|'md' }>()
const p = withDefaults(defineProps<{ tags?: string[] }>(), { tags: () => [] })

Runtime:

const props = defineProps({
  value: { type: Number, required: true },
  size: { type: String as PropType<'sm'|'md'>, default: 'md' },
  sku:  { type: String, validator: (v) => /^[A-Z]{3}-\d+$/.test(v) }
})

The trade: type-based = build-time check of parent AND child, erased at build, no validators · runtime = dev-only console warnings + custom validators, ships validator code.

Defaults: factory per instance for objects/arrays (() => []) — literal shares one instance across every mount · applied when absent or undefined · explicit falsy (0/''/false) never triggers them · don't combine required + default.

Boolean casting: absent → false · <Comp ready /> → true · :x="undefined" → true · only :x="false" gives false.

Dev warnings: Missing required prop: "x" · Invalid prop: type check failed ... Expected Number, got String · Custom validator for prop "x" failed — warn, never throw; none run in production.

Reading: template unwraps · script reads props.x or (() => props.x) for watchers · never destructure for live values, never assign (readonly proxy warns).

Continue Learning

Before You Start

Related Reading