Prerequisites
- Basic understanding of JavaScript ๐
- TypeScript installation โก
- VS Code or preferred IDE ๐ป
What you'll learn
- Understand Vue props and emits fundamentals ๐ฏ
- Apply type-safe Vue components in real projects ๐๏ธ
- Debug common Vue TypeScript issues ๐
- Write type-safe Vue code with props and emits โจ
๐ฏ Introduction
Welcome to this exciting tutorial on Vue Props and Emits with TypeScript! ๐ In this guide, weโll explore how to create type-safe Vue components that communicate effectively with each other.
Youโll discover how proper typing of props and emits can transform your Vue development experience. Whether youโre building interactive user interfaces ๐, complex data forms ๐, or reusable component libraries ๐, understanding type-safe component communication is essential for writing robust, maintainable Vue applications.
By the end of this tutorial, youโll feel confident creating type-safe Vue components that communicate flawlessly! Letโs dive in! ๐โโ๏ธ
๐ Understanding Vue Props and Emits
๐ค What are Props and Emits?
Vue Props and Emits are like a conversation between parent and child components! ๐ฃ๏ธ Think of props as โgiftsโ ๐ that parents give to children, and emits as โmessagesโ ๐จ that children send back to their parents.
In TypeScript terms, props define the data flowing down โฌ๏ธ from parent to child, while emits define the events flowing up โฌ๏ธ from child to parent. This means you can:
- โจ Pass typed data to child components
- ๐ Receive typed events from child components
- ๐ก๏ธ Catch communication errors at compile-time
๐ก Why Use TypeScript with Vue Props and Emits?
Hereโs why developers love type-safe Vue component communication:
- Type Safety ๐: Catch prop and emit errors at compile-time
- Better IDE Support ๐ป: Autocomplete for props and event names
- Self-Documenting Code ๐: Types serve as component API docs
- Refactoring Confidence ๐ง: Change component interfaces without fear
Real-world example: Imagine building a product card component ๐๏ธ. With TypeScript, you can ensure the parent always passes the required product data and handles all possible events correctly!
๐ง Basic Syntax and Usage
๐ Simple Props Example
Letโs start with a friendly example:
<script setup lang="ts">
// ๐ Hello, Vue with TypeScript!
interface Props {
title: string; // ๐ Card title
price: number; // ๐ฐ Product price
emoji?: string; // ๐จ Optional emoji
isOnSale?: boolean; // ๐ท๏ธ Sale status
}
// ๐ฏ Define props with TypeScript
const props = defineProps<Props>();
// ๐จ Computed value using props
const displayTitle = computed(() =>
`${props.emoji || '๐ฆ'} ${props.title}`
);
</script>
<template>
<div class="product-card">
<h2>{{ displayTitle }}</h2>
<p class="price">${{ price }}</p>
<span v-if="isOnSale" class="sale-badge">๐ฅ On Sale!</span>
</div>
</template>
๐ก Explanation: Notice how we define a Props
interface and use it with defineProps<Props>()
! The ?
makes properties optional.
๐ฏ Simple Emits Example
Hereโs how to emit events with types:
<script setup lang="ts">
// ๐๏ธ Define emit events with TypeScript
interface Emits {
addToCart: [product: { id: string; name: string }]; // ๐ Add to cart
toggleFavorite: [id: string]; // โค๏ธ Toggle favorite
priceAlert: [price: number, threshold: number]; // ๐ฐ Price alert
}
// ๐จ Define emits with types
const emit = defineEmits<Emits>();
// ๐ฎ Using emits in functions
const handleAddToCart = () => {
emit('addToCart', {
id: props.id,
name: props.title
});
console.log(`๐ Added ${props.title} to cart!`);
};
const handleFavoriteToggle = () => {
emit('toggleFavorite', props.id);
console.log(`โค๏ธ Toggled favorite for ${props.title}!`);
};
</script>
๐ก Practical Examples
๐๏ธ Example 1: Product Card Component
Letโs build a real product card:
<!-- ProductCard.vue -->
<script setup lang="ts">
// ๐๏ธ Product interface
interface Product {
id: string;
name: string;
price: number;
image: string;
category: 'electronics' | 'clothing' | 'books';
inStock: boolean;
rating?: number;
}
// ๐ Component props
interface Props {
product: Product;
showRating?: boolean;
size?: 'small' | 'medium' | 'large';
}
// ๐ข Component emits
interface Emits {
addToCart: [product: Product];
viewDetails: [productId: string];
addToWishlist: [productId: string];
rateProduct: [productId: string, rating: number];
}
const props = withDefaults(defineProps<Props>(), {
showRating: true,
size: 'medium'
});
const emit = defineEmits<Emits>();
// ๐จ Computed properties
const cardClasses = computed(() => [
'product-card',
`product-card--${props.size}`,
{ 'product-card--out-of-stock': !props.product.inStock }
]);
const priceDisplay = computed(() =>
`$${props.product.price.toFixed(2)} ๐ฐ`
);
const categoryEmoji = computed(() => {
const emojis = {
electronics: '๐ป',
clothing: '๐',
books: '๐'
};
return emojis[props.product.category];
});
// ๐ Event handlers
const handleAddToCart = () => {
if (!props.product.inStock) {
console.log('โ ๏ธ Product out of stock!');
return;
}
emit('addToCart', props.product);
console.log(`๐ Added ${props.product.name} to cart!`);
};
const handleViewDetails = () => {
emit('viewDetails', props.product.id);
console.log(`๐ Viewing details for ${props.product.name}`);
};
const handleWishlist = () => {
emit('addToWishlist', props.product.id);
console.log(`โค๏ธ Added ${props.product.name} to wishlist!`);
};
const handleRating = (rating: number) => {
emit('rateProduct', props.product.id, rating);
console.log(`โญ Rated ${props.product.name}: ${rating} stars`);
};
</script>
<template>
<div :class="cardClasses">
<!-- ๐ผ๏ธ Product image -->
<img
:src="product.image"
:alt="product.name"
class="product-image"
/>
<!-- ๐ Product info -->
<div class="product-info">
<h3 class="product-title">
{{ categoryEmoji }} {{ product.name }}
</h3>
<p class="product-price">{{ priceDisplay }}</p>
<!-- โญ Rating display -->
<div v-if="showRating && product.rating" class="rating">
<span v-for="star in 5" :key="star">
{{ star <= product.rating ? 'โญ' : 'โ' }}
</span>
<span class="rating-text">{{ product.rating }}/5</span>
</div>
<!-- ๐ฆ Stock status -->
<div class="stock-status">
<span v-if="product.inStock" class="in-stock">
โ
In Stock
</span>
<span v-else class="out-of-stock">
โ Out of Stock
</span>
</div>
</div>
<!-- ๐ฎ Action buttons -->
<div class="product-actions">
<button
@click="handleAddToCart"
:disabled="!product.inStock"
class="btn btn-primary"
>
๐ Add to Cart
</button>
<button
@click="handleViewDetails"
class="btn btn-secondary"
>
๐ Details
</button>
<button
@click="handleWishlist"
class="btn btn-outline"
>
โค๏ธ Wishlist
</button>
</div>
<!-- โญ Interactive rating -->
<div v-if="showRating" class="rating-input">
<span>Rate this product:</span>
<button
v-for="star in 5"
:key="star"
@click="handleRating(star)"
class="star-btn"
>
{{ star <= (product.rating || 0) ? 'โญ' : 'โ' }}
</button>
</div>
</div>
</template>
๐ฎ Example 2: Modal Dialog Component
Letโs create a flexible modal:
<!-- ModalDialog.vue -->
<script setup lang="ts">
// ๐ญ Modal configuration
interface ModalProps {
isVisible: boolean;
title: string;
size?: 'small' | 'medium' | 'large' | 'fullscreen';
closable?: boolean;
persistent?: boolean; // ๐ซ Can't close by clicking outside
showFooter?: boolean;
}
// ๐ข Modal events
interface ModalEmits {
close: [];
confirm: [];
cancel: [];
opened: [];
closed: [];
}
const props = withDefaults(defineProps<ModalProps>(), {
size: 'medium',
closable: true,
persistent: false,
showFooter: true
});
const emit = defineEmits<ModalEmits>();
// ๐จ Modal classes
const modalClasses = computed(() => [
'modal',
`modal--${props.size}`,
{ 'modal--persistent': props.persistent }
]);
// ๐ Handle close attempts
const handleClose = () => {
if (!props.closable) {
console.log('๐ซ Modal is not closable');
return;
}
emit('close');
emit('closed');
console.log('๐ช Modal closed');
};
// ๐ฏ Handle overlay click
const handleOverlayClick = () => {
if (props.persistent) {
console.log('๐ก๏ธ Persistent modal - can\'t close by clicking outside');
return;
}
handleClose();
};
// โ
Handle confirm
const handleConfirm = () => {
emit('confirm');
console.log('โ
Modal confirmed');
};
// โ Handle cancel
const handleCancel = () => {
emit('cancel');
console.log('โ Modal cancelled');
};
// ๐ Watch for visibility changes
watch(() => props.isVisible, (newValue) => {
if (newValue) {
emit('opened');
console.log('๐๏ธ Modal opened');
}
});
</script>
<template>
<Teleport to="body">
<div
v-if="isVisible"
class="modal-overlay"
@click="handleOverlayClick"
>
<div
:class="modalClasses"
@click.stop
>
<!-- ๐ Modal header -->
<header class="modal-header">
<h2 class="modal-title">{{ title }}</h2>
<button
v-if="closable"
@click="handleClose"
class="modal-close"
aria-label="Close"
>
โ
</button>
</header>
<!-- ๐ Modal content -->
<main class="modal-content">
<slot></slot>
</main>
<!-- ๐ฎ Modal footer -->
<footer v-if="showFooter" class="modal-footer">
<slot name="footer">
<button
@click="handleCancel"
class="btn btn-secondary"
>
โ Cancel
</button>
<button
@click="handleConfirm"
class="btn btn-primary"
>
โ
Confirm
</button>
</slot>
</footer>
</div>
</div>
</Teleport>
</template>
๐ Advanced Concepts
๐งโโ๏ธ Advanced Props with Validators
When youโre ready to level up, try custom prop validation:
<script setup lang="ts">
// ๐ฏ Advanced prop interface with constraints
interface AdvancedProps {
email: string;
age: number;
priority: 'low' | 'medium' | 'high' | 'critical';
tags: string[];
metadata?: Record<string, unknown>;
}
// ๐ช Props with runtime validation
const props = defineProps<AdvancedProps>();
// ๐ก๏ธ Custom validation function
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const validateAge = (age: number): boolean => {
return age >= 0 && age <= 150;
};
// โก Computed validations
const isValidEmail = computed(() => validateEmail(props.email));
const isValidAge = computed(() => validateAge(props.age));
const validationErrors = computed(() => {
const errors: string[] = [];
if (!isValidEmail.value) {
errors.push('๐ง Invalid email format');
}
if (!isValidAge.value) {
errors.push('๐ Age must be between 0 and 150');
}
return errors;
});
</script>
๐๏ธ Advanced Emits with Payload Types
For complex event handling:
<script setup lang="ts">
// ๐ Complex event payload types
interface UserAction {
type: 'create' | 'update' | 'delete';
userId: string;
timestamp: Date;
metadata?: Record<string, any>;
}
interface FormData {
name: string;
email: string;
preferences: {
notifications: boolean;
theme: 'light' | 'dark';
language: string;
};
}
// ๐ญ Advanced emits interface
interface AdvancedEmits {
userAction: [action: UserAction];
formSubmit: [data: FormData, isValid: boolean];
error: [error: Error, context: string];
progress: [current: number, total: number, message?: string];
}
const emit = defineEmits<AdvancedEmits>();
// ๐ฎ Advanced event handlers
const handleUserAction = (type: UserAction['type'], userId: string) => {
const action: UserAction = {
type,
userId,
timestamp: new Date(),
metadata: { source: 'user-interface' }
};
emit('userAction', action);
console.log(`๐ฏ User action: ${type} for user ${userId}`);
};
const handleFormSubmit = (formData: FormData) => {
const isValid = validateForm(formData);
emit('formSubmit', formData, isValid);
if (isValid) {
console.log('โ
Form submitted successfully!');
} else {
console.log('โ Form validation failed');
}
};
</script>
โ ๏ธ Common Pitfalls and Solutions
๐ฑ Pitfall 1: Mutating Props Directly
<script setup lang="ts">
interface Props {
items: string[];
}
const props = defineProps<Props>();
// โ Wrong - mutating props directly!
const addItem = (item: string) => {
props.items.push(item); // ๐ฅ Vue will warn about this!
};
// โ
Correct - emit event to parent!
interface Emits {
addItem: [item: string];
}
const emit = defineEmits<Emits>();
const addItem = (item: string) => {
emit('addItem', item); // โ
Let parent handle the change
console.log(`๐ Requested to add: ${item}`);
};
</script>
๐คฏ Pitfall 2: Missing Required Props
<script setup lang="ts">
// โ Dangerous - no default for required prop!
interface Props {
title: string;
count: number;
isActive?: boolean;
}
// โ
Safe - provide defaults where appropriate!
const props = withDefaults(defineProps<Props>(), {
isActive: false
// Note: title and count are required, no defaults needed
});
// ๐ก๏ธ Always validate in parent component
const isValidProps = computed(() => {
return props.title.length > 0 && props.count >= 0;
});
</script>
๐ ๏ธ Best Practices
- ๐ฏ Be Specific with Props: Define exact types rather than generic objects
- ๐ข Name Emits Clearly: Use descriptive event names like
userSelected
not justselect
- ๐ก๏ธ Validate Props: Add runtime validation for critical props
- ๐จ Use Defaults Wisely: Provide sensible defaults for optional props
- โจ Document Your API: Use JSDoc comments for complex prop interfaces
<script setup lang="ts">
/**
* ๐ User profile card component
* Displays user information with interactive actions
*/
interface Props {
/** ๐ค User data object */
user: {
id: string;
name: string;
email: string;
avatar?: string;
};
/** ๐จ Card display variant */
variant?: 'compact' | 'detailed' | 'minimal';
/** ๐ Whether user can be edited */
editable?: boolean;
}
/**
* ๐ข Component events
*/
interface Emits {
/** ๐ค User profile was clicked */
userClick: [userId: string];
/** โ๏ธ Edit button was clicked */
editUser: [userId: string];
/** ๐ง Email link was clicked */
emailUser: [email: string];
}
</script>
๐งช Hands-On Exercise
๐ฏ Challenge: Build a Comment System
Create a type-safe comment component system:
๐ Requirements:
- โ Comment component with author, content, timestamp
- ๐ท๏ธ Support for replies (nested comments)
- ๐ Like/dislike functionality with vote counts
- โ๏ธ Edit and delete actions (with permissions)
- ๐จ Different display modes (compact, detailed)
- ๐ฑ Responsive design considerations
๐ Bonus Points:
- Add emoji reactions beyond like/dislike
- Implement comment threading
- Add moderation features
- Create a comment composer component
๐ก Solution
๐ Click to see solution
<!-- Comment.vue -->
<script setup lang="ts">
// ๐ฌ Comment data structure
interface Comment {
id: string;
content: string;
author: {
id: string;
name: string;
avatar?: string;
};
timestamp: Date;
likes: number;
dislikes: number;
replies?: Comment[];
isEdited?: boolean;
parentId?: string;
}
// ๐ Component props
interface Props {
comment: Comment;
currentUserId?: string;
mode?: 'compact' | 'detailed';
level?: number; // For nested replies
maxLevel?: number;
showReplies?: boolean;
allowEdit?: boolean;
allowDelete?: boolean;
}
// ๐ข Component emits
interface Emits {
like: [commentId: string];
dislike: [commentId: string];
reply: [parentId: string, content: string];
edit: [commentId: string, newContent: string];
delete: [commentId: string];
report: [commentId: string, reason: string];
}
const props = withDefaults(defineProps<Props>(), {
mode: 'detailed',
level: 0,
maxLevel: 3,
showReplies: true,
allowEdit: true,
allowDelete: true
});
const emit = defineEmits<Emits>();
// ๐จ Computed properties
const isAuthor = computed(() =>
props.currentUserId === props.comment.author.id
);
const canReply = computed(() =>
props.level < props.maxLevel
);
const commentClasses = computed(() => [
'comment',
`comment--${props.mode}`,
`comment--level-${props.level}`,
{ 'comment--author': isAuthor.value }
]);
const timeAgo = computed(() => {
const now = new Date();
const diff = now.getTime() - props.comment.timestamp.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now ๐';
if (minutes < 60) return `${minutes}m ago โฐ`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago ๐`;
const days = Math.floor(hours / 24);
return `${days}d ago ๐
`;
});
// ๐ฎ State for interactions
const isEditing = ref(false);
const editContent = ref('');
const showReplyForm = ref(false);
const replyContent = ref('');
// ๐ Handle like
const handleLike = () => {
emit('like', props.comment.id);
console.log(`๐ Liked comment ${props.comment.id}`);
};
// ๐ Handle dislike
const handleDislike = () => {
emit('dislike', props.comment.id);
console.log(`๐ Disliked comment ${props.comment.id}`);
};
// โ๏ธ Handle edit
const startEdit = () => {
isEditing.value = true;
editContent.value = props.comment.content;
};
const saveEdit = () => {
if (editContent.value.trim()) {
emit('edit', props.comment.id, editContent.value.trim());
isEditing.value = false;
console.log(`โ๏ธ Edited comment ${props.comment.id}`);
}
};
const cancelEdit = () => {
isEditing.value = false;
editContent.value = '';
};
// ๐ฌ Handle reply
const startReply = () => {
showReplyForm.value = true;
};
const submitReply = () => {
if (replyContent.value.trim()) {
emit('reply', props.comment.id, replyContent.value.trim());
showReplyForm.value = false;
replyContent.value = '';
console.log(`๐ฌ Replied to comment ${props.comment.id}`);
}
};
const cancelReply = () => {
showReplyForm.value = false;
replyContent.value = '';
};
// ๐๏ธ Handle delete
const handleDelete = () => {
if (confirm('๐๏ธ Are you sure you want to delete this comment?')) {
emit('delete', props.comment.id);
console.log(`๐๏ธ Deleted comment ${props.comment.id}`);
}
};
</script>
<template>
<article :class="commentClasses">
<!-- ๐ค Comment header -->
<header class="comment-header">
<div class="comment-author">
<img
v-if="comment.author.avatar"
:src="comment.author.avatar"
:alt="`${comment.author.name}'s avatar`"
class="author-avatar"
/>
<span v-else class="author-avatar-placeholder">
{{ comment.author.name.charAt(0).toUpperCase() }}
</span>
<div class="author-info">
<span class="author-name">{{ comment.author.name }}</span>
<span v-if="isAuthor" class="author-badge">๐ค You</span>
<span class="comment-time">{{ timeAgo }}</span>
<span v-if="comment.isEdited" class="edited-badge">โ๏ธ Edited</span>
</div>
</div>
</header>
<!-- ๐ Comment content -->
<div class="comment-body">
<div v-if="!isEditing" class="comment-content">
{{ comment.content }}
</div>
<!-- โ๏ธ Edit form -->
<div v-else class="comment-edit">
<textarea
v-model="editContent"
class="edit-textarea"
placeholder="Edit your comment..."
@keydown.ctrl.enter="saveEdit"
></textarea>
<div class="edit-actions">
<button @click="saveEdit" class="btn btn-primary">
โ
Save
</button>
<button @click="cancelEdit" class="btn btn-secondary">
โ Cancel
</button>
</div>
</div>
</div>
<!-- ๐ฎ Comment actions -->
<footer class="comment-actions">
<div class="vote-actions">
<button
@click="handleLike"
class="vote-btn vote-btn--like"
>
๐ {{ comment.likes }}
</button>
<button
@click="handleDislike"
class="vote-btn vote-btn--dislike"
>
๐ {{ comment.dislikes }}
</button>
</div>
<div class="comment-menu">
<button
v-if="canReply"
@click="startReply"
class="action-btn"
>
๐ฌ Reply
</button>
<button
v-if="isAuthor && allowEdit"
@click="startEdit"
class="action-btn"
>
โ๏ธ Edit
</button>
<button
v-if="isAuthor && allowDelete"
@click="handleDelete"
class="action-btn action-btn--danger"
>
๐๏ธ Delete
</button>
</div>
</footer>
<!-- ๐ฌ Reply form -->
<div v-if="showReplyForm" class="reply-form">
<textarea
v-model="replyContent"
placeholder="Write a reply..."
class="reply-textarea"
@keydown.ctrl.enter="submitReply"
></textarea>
<div class="reply-actions">
<button @click="submitReply" class="btn btn-primary">
๐ฌ Reply
</button>
<button @click="cancelReply" class="btn btn-secondary">
โ Cancel
</button>
</div>
</div>
<!-- ๐ Nested replies -->
<div
v-if="showReplies && comment.replies?.length"
class="comment-replies"
>
<Comment
v-for="reply in comment.replies"
:key="reply.id"
:comment="reply"
:current-user-id="currentUserId"
:mode="mode"
:level="level + 1"
:max-level="maxLevel"
:show-replies="showReplies"
:allow-edit="allowEdit"
:allow-delete="allowDelete"
@like="$emit('like', $event)"
@dislike="$emit('dislike', $event)"
@reply="$emit('reply', $event)"
@edit="$emit('edit', $event)"
@delete="$emit('delete', $event)"
@report="$emit('report', $event)"
/>
</div>
</article>
</template>
๐ Key Takeaways
Youโve learned so much! Hereโs what you can now do:
- โ Create type-safe Vue props with interfaces and validation ๐ช
- โ Emit typed events from child to parent components ๐ก๏ธ
- โ Avoid common mistakes like prop mutation and missing types ๐ฏ
- โ Build complex component APIs with advanced prop and emit patterns ๐
- โ Create reusable, documented components that are easy to use! ๐
Remember: TypeScript with Vue makes component communication crystal clear! Itโs like having a detailed instruction manual for every component. ๐ค
๐ค Next Steps
Congratulations! ๐ Youโve mastered Vue Props and Emits with TypeScript!
Hereโs what to do next:
- ๐ป Practice with the comment system exercise above
- ๐๏ธ Build a Vue app with multiple communicating components
- ๐ Move on to our next tutorial: Vue Composables with TypeScript
- ๐ Share your type-safe Vue components with the community!
Remember: Every Vue expert was once a beginner. Keep building, keep typing, and most importantly, have fun with Vue and TypeScript! ๐
Happy coding! ๐๐โจ