Vue.js 28 🟢 Declaring Props with defineProps()
Props are the primary mechanism for passing data from a parent component to a child component. In Vue 3’s <script setup>, the defineProps() macro is the way to declare them. It is a compiler macro, not a function that runs at runtime. The compiler reads the declaration, generates the equivalent runtime props option, and removes the call from the output. This means defineProps() can only be used inside <script setup>, and the argument cannot reference other variables declared in the same script block, because the entire expression is moved to an outer scope when compiled .
The macro supports two declaration styles. Runtime declaration uses an object with type, required, default, and validator keys. Type-based declaration uses a TypeScript generic parameter and relies on the compiler to infer the runtime types. The two styles are mutually exclusive: a component uses one or the other, never both. The choice between them depends on the project’s TypeScript configuration and whether complex default values are needed .
This chapter covers three areas. First, why defineProps() exists — the problem of declaring a component’s public interface and the difference between runtime and type-based declaration. Second, how each declaration style works — the runtime object syntax with validation and defaults, and the TypeScript generic syntax with withDefaults. Third, how props are accessed and the rules that govern them — the props object in script, the automatic unwrapping in templates, the one-way data flow, and the readonly constraint. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the prop flow.
Key point: defineProps() is a compiler macro that declares a component’s props. It supports runtime declaration (object syntax with type, required, default, validator) and type-based declaration (TypeScript generic with withDefaults). The two styles cannot be mixed. Props are readonly and flow one way from parent to child.
Why defineProps() exists
The component-interface problem. A component’s props are its public API. They define what data the component accepts, which props are required, what types they are, and what defaults apply. Without a declaration mechanism, the component’s interface is implicit in how it uses the data, and the parent has no guidance on what to pass. defineProps() makes the interface explicit. The declaration is the contract, and Vue validates it at runtime in development mode .
The Options-API migration problem. In the Options API, props are declared in the props option of the component object. In <script setup>, there is no component object. The defineProps() macro fills that role. It is the <script setup> equivalent of the props option, with the same semantics and a more concise syntax .
The TypeScript-integration problem. A component written in TypeScript needs its props to be typed. The runtime declaration syntax can be typed with PropType, but it requires declaring the type twice: once in the runtime object and once in a TypeScript interface. The type-based declaration eliminates the duplication. The generic parameter is the type, and the compiler infers the runtime validation from it . This is the recommended approach for TypeScript projects, and the vue/define-props-declaration ESLint rule can enforce it .
The default-value problem. A prop can have a default value that applies when the parent does not pass it. In the runtime declaration, the default is declared in the object. In the type-based declaration, defaults are declared separately, because TypeScript interfaces cannot contain runtime values. The withDefaults() compiler macro provides the defaults for type-based declarations. It is required when the type-based declaration needs defaults, and it type-checks the default values against the declared types .
The validation problem. A prop declaration can include a validator function that checks the value at runtime. If the value does not match the expected type, or if the validator returns false, Vue logs a warning in the development build. This is useful for component libraries and for catching mistakes early. The validation runs before the component instance is created, so the validator cannot access this or other instance properties .
The trade-off. Runtime declaration is more verbose but has no TypeScript dependency. Type-based declaration is more concise and provides better IDE support, but requires TypeScript and the withDefaults macro for defaults. The choice is between explicit runtime validation and compile-time type safety. For TypeScript projects, the type-based declaration is the better choice. For JavaScript projects, the runtime declaration is the only option.
a. Runtime declaration — the object syntax
The runtime declaration passes an object to defineProps(). Each key is a prop name, and the value describes the prop’s type and constraints .
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
count: {
type: Number,
default: 0,
},
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value),
},
items: {
type: Array,
default: () => [],
},
});
</script>
The type can be a single constructor (String, Number, Boolean, Array, Object, Function, Symbol) or an array of constructors for multiple allowed types. A prop with required: true must be passed by the parent. A prop with a default value is optional, and the default is used when the prop is absent or undefined .
The default for an Array or Object must be a factory function. This ensures that each component instance receives its own copy of the default value. A shared reference would cause mutations in one instance to affect all instances that use the default .
items: {
type: Array,
default: () => [], // factory function
}
The validator is a function that receives the prop value and returns true if valid, false if invalid. It can also receive the full props object as a second argument in Vue 3.4 and later. When the validator returns false, Vue logs a warning in the development console .
role: {
type: String,
default: 'user',
validator: (value) => ['admin', 'moderator', 'user', 'guest'].includes(value),
}
A prop can be declared with just a type constructor as a shorthand. This is equivalent to { type: String } with no constraints:
const props = defineProps({
name: String,
age: Number,
});
b. Type-based declaration — the TypeScript generic
The type-based declaration uses a TypeScript generic parameter. The compiler infers the runtime prop types from the TypeScript types .
<script setup lang="ts">
const props = defineProps<{
title: string;
count?: number;
items: string[];
}>();
</script>
The title prop is a required string. The count prop is an optional number (the ? makes it optional). The items prop is a required array of strings. The compiler generates the equivalent runtime declaration automatically. A string becomes { type: String, required: true }, and a string? becomes { type: String, required: false } .
The type can be extracted to an interface, which is the recommended pattern for complex props:
<script setup lang="ts">
interface Props {
title: string;
count?: number;
items: string[];
}
const props = defineProps<Props>();
</script>
The withDefaults() macro provides default values for type-based declarations. It wraps the defineProps() call and takes a second argument with the defaults .
<script setup lang="ts">
interface Props {
title: string;
count?: number;
items?: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [],
});
</script>
The withDefaults() macro is compiled away. It generates the runtime default options and type-checks the defaults against the declared types. For mutable reference types like arrays and objects, the default must be a factory function. The withDefaults() macro does not enforce this, but the compiler will warn if a mutable reference is used directly .
A type-based declaration can use complex types, including imported interfaces and type aliases:
<script setup lang="ts">
import type { Book } from './types';
const props = defineProps<{
book: Book;
}>();
</script>
For runtime declaration with complex types, the PropType utility is used:
<script setup lang="ts">
import type { PropType } from 'vue';
import type { Book } from './types';
const props = defineProps({
book: Object as PropType<Book>,
});
</script>
c. Accessing props and the one-way flow
The return value of defineProps() is a reactive object containing the declared props. In the script, props are accessed through this object. In the template, they are available directly by name, without the props prefix .
<script setup>
const props = defineProps({
title: String,
count: Number,
});
// In script: use props.title, props.count
console.log(props.title);
</script>
<template>
<!-- In template: use title, count directly -->
<p>{{ title }} ({{ count }})</p>
</template>
Props are readonly. Attempting to assign to a prop in the child component produces a warning in the development console. This enforces the one-way data flow: data flows from parent to child, but not the other way around .
<script setup>
const props = defineProps(['foo']);
// ❌ warning: props are readonly
props.foo = 'bar';
</script>
When a prop needs to be used as a local value that can change, the pattern is to create a local ref initialized with the prop’s value. The local ref is disconnected from future prop updates:
<script setup>
const props = defineProps(['initialCounter']);
// counter uses props.initialCounter as its initial value
// but is disconnected from future prop updates
const counter = ref(props.initialCounter);
</script>
When a prop needs to be transformed, the pattern is to create a computed property that depends on the prop:
<script setup>
const props = defineProps(['size']);
// computed property that auto-updates when the prop changes
const normalizedSize = computed(() => props.size.trim().toLowerCase());
</script>
Objects and arrays passed as props can have their nested properties mutated by the child, because JavaScript passes them by reference. Vue cannot prevent this without unreasonable cost. However, such mutations affect the parent’s state in a way that is not obvious from the parent’s perspective. The recommended pattern is to emit an event and let the parent perform the mutation .
Complete Example Session
<!-- ============================================
PART 1: RUNTIME DECLARATION — BASIC TYPES
============================================ -->
<script setup>
const props = defineProps({
title: String,
count: Number,
isActive: Boolean,
});
</script>
<template>
<p>{{ title }}</p>
<p>{{ count }}</p>
<p>{{ isActive }}</p>
</template>
<!-- ============================================
PART 2: RUNTIME DECLARATION — REQUIRED
============================================ -->
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
},
});
</script>
<template>
<h1>{{ title }}</h1>
</template>
<!-- ============================================
PART 3: RUNTIME DECLARATION — DEFAULT VALUES
============================================ -->
<script setup>
const props = defineProps({
count: {
type: Number,
default: 0,
},
variant: {
type: String,
default: 'primary',
},
items: {
type: Array,
default: () => [], // factory function for arrays
},
});
</script>
<!-- ============================================
PART 4: RUNTIME DECLARATION — VALIDATORS
============================================ -->
<script setup>
const props = defineProps({
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value),
},
age: {
type: [Number, String],
validator: (value) => {
const num = typeof value === 'string' ? parseInt(value) : value;
return num >= 0 && num <= 150;
},
},
});
</script>
<!-- ============================================
PART 5: RUNTIME DECLARATION — MULTIPLE TYPES
============================================ -->
<script setup>
const props = defineProps({
id: {
type: [String, Number],
required: true,
},
});
</script>
<!-- ============================================
PART 6: TYPE-BASED DECLARATION — BASIC
============================================ -->
<script setup lang="ts">
const props = defineProps<{
title: string;
count?: number;
items: string[];
}>();
</script>
<template>
<p>{{ title }}</p>
<p>{{ count }}</p>
<ul>
<li v-for="item in items" :key="item">{{ item }}</li>
</ul>
</template>
<!-- ============================================
PART 7: TYPE-BASED DECLARATION — INTERFACE
============================================ -->
<script setup lang="ts">
interface Props {
title: string;
count?: number;
variant?: 'primary' | 'secondary' | 'danger';
}
const props = defineProps<Props>();
</script>
<!-- ============================================
PART 8: TYPE-BASED DECLARATION — WITHDEFAULTS
============================================ -->
<script setup lang="ts">
interface Props {
title: string;
count?: number;
items?: string[];
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [],
});
</script>
<!-- ============================================
PART 9: ACCESSING PROPS IN SCRIPT AND TEMPLATE
============================================ -->
<script setup>
const props = defineProps({
title: String,
count: Number,
});
// In script, use props.title
console.log(props.title);
// In template, use title directly
</script>
<template>
<p>{{ title }}</p>
</template>
<!-- ============================================
PART 10: THE COMPLETE COMPONENT
============================================ -->
<script setup lang="ts">
import { computed } from 'vue';
interface Props {
title: string;
count?: number;
variant?: 'primary' | 'secondary' | 'danger';
items?: Array<{ id: number; name: string }>;
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
variant: 'primary',
items: () => [],
});
const label = computed(() => `${props.title} (${props.count})`);
</script>
<template>
<div :class="`card card--${variant}`">
<h2>{{ label }}</h2>
<ul>
<li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>
</div>
</template>
The ten parts show runtime declaration with basic types, required props, default values, validators, multiple types, type-based declaration with basic types, interfaces, withDefaults, accessing props in script and template, and a complete component.
Quick Reference
Declaration Styles
| Style | Syntax | TypeScript | Defaults |
|---|---|---|---|
| Runtime | defineProps({ type: String }) | Optional | default key |
| Type-based | defineProps<{ title: string }>() | Required | withDefaults |
Runtime Declaration Keys
| Key | Purpose | Example |
|---|---|---|
type | Constructor or array of constructors | String, [String, Number] |
required | Must be passed by parent | required: true |
default | Value when absent | default: 0 |
validator | Function returning boolean | validator: (v) => v > 0 |
Type-Based Declaration
| Type | Generated Runtime |
|---|---|
title: string | { type: String, required: true } |
count?: number | { type: Number, required: false } |
items: string[] | { type: Array, required: true } |
withDefaults
| Usage | Behavior |
|---|---|
withDefaults(defineProps<Props>(), { ... }) | Provides defaults for type-based declaration |
| Mutable reference default | Must be a factory function: () => [] |
| Compilation | Compiled to runtime default options |
Prop Rules
| Rule | Behavior |
|---|---|
| One-way flow | Parent to child only |
| Readonly | Cannot assign to a prop |
| Object/array mutation | Nested properties can be mutated (avoid) |
| Template access | Direct by name, no props. prefix |
| Script access | Through props object |
Best Practices
✅ Do This:
<script setup lang="ts">
// Use type-based declaration with interface
interface Props { title: string; count?: number }
const props = withDefaults(defineProps<Props>(), { count: 0 }); // ✅
// Use factory functions for mutable defaults
items: () => [] // ✅
// Use computed for transformed props
const normalized = computed(() => props.size.trim().toLowerCase()); // ✅
// Use local ref for initial-value props
const counter = ref(props.initialCount); // ✅
// Use validator for constrained values
validator: (v) => ['a', 'b'].includes(v) // ✅
</script>
❌ Don’t Do This:
<script setup>
// Don't mix runtime and type-based declaration
defineProps({ title: String }); // and defineProps<Props>() // ❌
// Don't mutate props
props.title = 'new'; // ❌
// Don't use shared references as defaults
items: { type: Array, default: [] } // shared reference // ❌
// Don't access props without the props object in script
console.log(title); // in script, use props.title // ❌
// Don't use variables in defineProps argument
const defaultCount = 0;
defineProps({ count: { default: defaultCount } }); // cannot access // ❌
</script>
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Prop mutation warning | Assigning to a prop | Use local ref or emit event |
| Default not applied | Using shared reference for object/array | Use factory function |
| Type-based default missing | TypeScript interface cannot have runtime defaults | Use withDefaults |
| Validator not running | Production build | Validation runs only in development |
props undefined in template | Trying to use props.title in template | Use title directly |
| Complex type not inferring | Type analysis limitation | Use runtime declaration with PropType |
| Required prop missing | Parent did not pass it | Check parent binding or make optional |
Real-World Examples
1. Runtime Basic Types
defineProps({ title: String, count: Number });
2. Runtime Required
defineProps({ title: { type: String, required: true } });
3. Runtime Default
defineProps({ count: { type: Number, default: 0 } });
4. Runtime Validator
defineProps({ variant: { validator: (v) => ['a', 'b'].includes(v) } });
5. Array Factory Default
defineProps({ items: { type: Array, default: () => [] } });
6. Type-Based Basic
defineProps<{ title: string; count?: number }>();
7. Type-Based Interface
interface Props { title: string }
defineProps<Props>();
8. withDefaults
withDefaults(defineProps<Props>(), { count: 0 });
9. Computed from Prop
const normalized = computed(() => props.size.trim().toLowerCase());
10. Local Ref from Prop
const counter = ref(props.initialCount);
Visual
Runtime vs Type-Based Declaration
┌──────────────────────────────────────────────────────────────┐
│ RUNTIME vs TYPE-BASED │
│ │
│ RUNTIME: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ defineProps({ │ │
│ │ title: { │ │
│ │ type: String, │ │
│ │ required: true │ │
│ │ }, │ │
│ │ count: { │ │
│ │ type: Number, │ │
│ │ default: 0 │ │
│ │ } │ │
│ │ }) │ │
│ │ │ │
│ │ Explicit type, required, default, validator │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ TYPE-BASED: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ interface Props { │ │
│ │ title: string │ │
│ │ count?: number │ │
│ │ } │ │
│ │ │ │
│ │ const props = withDefaults(defineProps<Props>(), { │ │
│ │ count: 0 │ │
│ │ }) │ │
│ │ │ │
│ │ Type from interface, defaults from withDefaults │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
One-Way Data Flow
┌──────────────────────────────────────────────────────────────┐
│ ONE-WAY DATA FLOW │
│ │
│ Parent Component │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ const count = ref(0) │ │
│ │ │ │
│ │ <Child :count="count" /> │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ count flows down │
│ ▼ │
│ Child Component │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ defineProps({ count: Number }) │ │
│ │ │ │
│ │ props.count is READONLY │ │
│ │ props.count = 5 ❌ warning │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ To change the parent's state: │
│ Child emits an event │
│ Parent handles the event and updates its state │
│ │
└──────────────────────────────────────────────────────────────┘
Default Value Patterns
┌──────────────────────────────────────────────────────────────┐
│ DEFAULT VALUE PATTERNS │
│ │
│ RUNTIME: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ count: { type: Number, default: 0 } │ │
│ │ items: { type: Array, default: () => [] } │ │
│ │ │ │
│ │ Array/Object defaults MUST be factory functions. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ TYPE-BASED: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ withDefaults(defineProps<Props>(), { │ │
│ │ count: 0, │ │
│ │ items: () => [] │ │
│ │ }) │ │
│ │ │ │
│ │ Same factory rule applies. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Vue 3.5+ reactive destructure: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ const { count = 0 } = defineProps<Props>() │ │
│ │ │ │
│ │ No withDefaults needed. Factory not required. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Prop Validation Flow
┌──────────────────────────────────────────────────────────────┐
│ PROP VALIDATION FLOW │
│ │
│ Parent passes prop: │
│ <Child :variant="'invalid'" /> │
│ │ │
│ ▼ │
│ Child defines prop: │
│ variant: { │
│ type: String, │
│ validator: (v) => ['primary', 'danger'].includes(v) │
│ } │
│ │ │
│ ▼ │
│ Vue validates in development: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 'invalid' is not in ['primary', 'danger'] │ │
│ │ → Console warning │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Component still renders (validation is a warning) │
│ │
│ In production, validation is skipped for performance. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
defineProps() | Compiler macro for declaring props in <script setup> |
| Runtime declaration | Object syntax with type, required, default, validator |
| Type-based declaration | TypeScript generic parameter |
withDefaults | Provides defaults for type-based declaration |
| Default factory | Required for Array and Object defaults |
| Validator | Function returning boolean; runs in development |
| Access in script | Through props object |
| Access in template | Direct by name, no props. prefix |
| One-way flow | Parent to child; props are readonly |
| Vue 3.5+ destructure | const { count = 0 } = defineProps<Props>() |
Key takeaways:
defineProps()is a compiler macro, not a runtime function. It can only be used inside<script setup>, and its argument cannot reference other variables declared in the script block. The compiler generates the runtimepropsoption and removes the call .- Runtime and type-based declarations cannot be mixed. A component uses one style or the other. The runtime style is more explicit but verbose. The type-based style is more concise and provides better TypeScript integration .
- Type-based declarations require
withDefaultsfor defaults. TypeScript interfaces cannot contain runtime values, so the defaults are declared in a separatewithDefaultscall. The macro type-checks the defaults against the declared types . - Array and Object defaults must be factory functions. A shared reference would cause mutations in one component instance to affect all instances that use the default. The factory function ensures each instance gets its own copy .
- Props are readonly and flow one way. Attempting to assign to a prop produces a warning. To change the parent’s state, the child emits an event and the parent handles it .
- Objects and arrays passed as props can have their nested properties mutated. Vue cannot prevent this without unreasonable cost, but such mutations affect the parent’s state in a non-obvious way. The recommended pattern is to emit an event and let the parent perform the mutation .
- Props are accessed through the
propsobject in script and directly in the template. The template automatically unwraps thedefinePropsreturn value, sotitleworks in the template butprops.titleis required in the script . - Validators run only in development. They are a development-time debugging aid, not a runtime guarantee. The component still renders if validation fails; only a console warning is produced .
Remember: defineProps() is the entry point for a component’s public interface. The declaration is the contract: what props the component accepts, which are required, what types they have, and what defaults apply. Runtime declaration makes the contract explicit in JavaScript. Type-based declaration makes it explicit in TypeScript, with the compiler inferring the runtime validation. withDefaults provides the defaults that TypeScript cannot express. Props flow one way, from parent to child, and they are readonly. The child can transform them with computed properties, use them as initial values for local refs, or emit events to request changes. The declaration is the API, and the API is the contract between the component and its consumers.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!