Apprenez à typer vos composants Vue 3 avec TypeScript : props, emits, ref, events DOM, generics et erreurs fréquentes expliquées avec solutions.
Pourquoi TypeScript avec Vue 3 ?
Vue 3 a été entièrement réécrit en TypeScript. Ce n'est pas un détail : cela signifie que TypeScript est un citoyen de première classe dans Vue 3, avec une inférence de types complète, des erreurs détectées à la compilation et une autocomplétion excellente dans les éditeurs. Pour un développeur junior, apprendre Vue 3 directement avec TypeScript évite de mauvaises habitudes et prépare au travail en équipe.
Ce que TypeScript apporte concrètement
<!-- ❌ Sans TypeScript — erreur silencieuse en production -->
<script setup>
const props = defineProps(['user', 'onSelect']); // Types inconnus
// Vue accepte n'importe quoi — pas de vérification
// Si le parent passe un number au lieu d'un object, erreur runtime
</script>
<!-- ✅ Avec TypeScript — erreur détectée à la compilation -->
<script setup lang="ts">
interface User {
id: number;
name: string;
email: string;
}
const props = defineProps<{
user: User; // TypeScript sait exactement ce qu'il attend
onSelect?: () => void;
}>();
// Si le parent passe { id: 'abc', name: 42 } → erreur TypeScript immédiate
// L'éditeur propose l'autocomplétion sur props.user.name etc.
</script>
Bénéfices mesurables
| Bénéfice | Sans TypeScript | Avec TypeScript |
|---|---|---|
| Bugs props incorrectes | Runtime (production) | Compilation (dev) |
| Autocomplétion IDE | Limitée | Complète (props, emits, store…) |
| Refactoring | Manuel et risqué | Sûr (TS détecte les usages cassés) |
| Documentation implicite | JSDoc manuel | Types = documentation vivante |
| Onboarding équipe | Lire le code pour comprendre | Types expliquent l'intention |
Configuration TypeScript
# Créer un projet Vue 3 avec TypeScript (recommandé)
npm create vue@latest mon-projet
# → Sélectionner "TypeScript : Yes"
# → Sélectionner "Vue Router : Yes" (optionnel)
# → Sélectionner "Pinia : Yes" (optionnel)
tsconfig.json recommandé pour Vue 3
// tsconfig.json — Configuration optimale Vue 3 + TypeScript
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
// Strict mode — active toutes les vérifications strictes
// Obligatoire pour bénéficier vraiment de TypeScript
"strict": true,
// Permet d'importer les fichiers .vue depuis TypeScript
"jsx": "preserve",
// Alias d'import — évite les chemins relatifs profonds
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"] // import '@/components/Foo.vue' au lieu de '../../..'
},
// Interopérabilité avec les modules CommonJS (lodash, etc.)
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
// Vérifications supplémentaires recommandées
"noUnusedLocals": true, // Erreur si variable locale inutilisée
"noUnusedParameters": false, // Trop strict pour les callbacks
"noImplicitReturns": true, // Toutes les branches doivent retourner
},
"include": ["src/**/*", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}
// env.d.ts — Déclarations de types globaux
/// <reference types="vite/client" />
// Permet d'importer les .vue comme des modules TypeScript
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, unknown>;
export default component;
}
// Variables d'environnement typées (import.meta.env)
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_APP_TITLE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
Typer les props avec defineProps
Les props sont le contrat entre un composant parent et son enfant. Bien les typer est la priorité numéro un : cela documente l'interface du composant et prévient les bugs de communication.
Syntaxe de base avec interface
<!-- components/UserCard.vue -->
<script setup lang="ts">
// Définit le contrat du composant — ce qu'il attend du parent
interface Props {
// Props obligatoires
userId: number;
name: string;
// Props optionnelles (le ? indique qu'elles peuvent être absentes)
avatar?: string;
role?: 'admin' | 'user' | 'moderator'; // Union de littéraux
tags?: string[];
metadata?: Record<string, unknown>; // Objet clé/valeur ouvert
// Callbacks typés
onSelect?: (userId: number) => void;
onDelete?: () => Promise<void>;
}
// defineProps<Interface>() infère les types depuis l'interface TypeScript
const props = defineProps<Props>();
// withDefaults() pour les valeurs par défaut des props optionnelles
// Obligatoire dès qu'une prop optionnelle a une valeur par défaut
const propsWithDefaults = withDefaults(defineProps<Props>(), {
role: 'user', // Valeur par défaut scalaire
tags: () => [], // TOUJOURS une fonction pour les tableaux/objets
metadata: () => ({}), // Évite le partage d'état entre instances
avatar: '/default-avatar.png',
});
</script>
<template>
<!-- TypeScript valide les accès aux props dans le template -->
<div class="user-card">
<img :src="avatar ?? '/default.png'" :alt="name" />
<h3>{{ name }}</h3>
<span :class="`role-${role}`">{{ role }}</span>
<button @click="onSelect?.(userId)">Sélectionner</button>
</div>
</template>
Props avec objets imbriqués
<script setup lang="ts">
// Interfaces réutilisables — définies dans src/types/index.ts
interface Address {
street: string;
city: string;
zip: string;
country: string;
}
interface User {
id: number;
firstName: string;
lastName: string;
email: string;
address?: Address; // Objet imbriqué optionnel
createdAt: Date | string; // Union de types
}
interface Props {
user: User;
showAddress?: boolean;
highlightField?: keyof User; // keyof = clé valide de User
}
const props = defineProps<Props>();
// TypeScript infère que props.user est de type User
// props.user.firstName → autocomplétion ✅
// props.user.unknownField → erreur TypeScript ✅
</script>
Props booléennes : raccourcis Vue
<script setup lang="ts">
interface Props {
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
}
const props = defineProps<Props>();
</script>
<!-- Dans le parent : -->
<!-- <MyButton disabled /> équivaut à <MyButton :disabled="true" /> -->
<!-- <MyButton /> équivaut à <MyButton :disabled="false" /> (valeur par défaut) -->
Typer les emits avec defineEmits
Les emits définissent les événements qu'un composant peut déclencher vers son parent. Les typer garantit que le parent gère les bons arguments et que TypeScript détecte les incohérences dans les deux sens.
<!-- components/DataTable.vue -->
<script setup lang="ts">
import { ref } from 'vue';
interface Row {
id: number;
[key: string]: unknown;
}
// defineEmits avec syntaxe TypeScript — la plus précise
const emit = defineEmits<{
// Syntaxe : nomEvenement: [arg1Type, arg2Type, ...]
select: [row: Row]; // Un seul objet Row
delete: [id: number]; // Un number
bulkDelete:[ids: number[]]; // Tableau de numbers
sort: [field: string, direction: 'asc' | 'direction']; // 2 args typés
pageChange:[page: number, perPage: number]; // 2 numbers
update: [id: number, data: Partial<Row>]; // Mise à jour partielle
close: []; // Aucun argument
}>();
const selectedRows = ref<Row[]>([]);
// TypeScript vérifie que les arguments matchent les types déclarés
const handleRowClick = (row: Row) => {
emit('select', row); // ✅ row est bien un Row
// emit('select', 42); // ❌ Erreur TypeScript : 42 n'est pas Row
// emit('select', row, 'extra');// ❌ Trop d'arguments
};
const handleSort = (field: string) => {
emit('sort', field, 'asc'); // ✅ Les deux arguments sont corrects
};
const handleBulkDelete = () => {
const ids = selectedRows.value.map(r => r.id as number);
emit('bulkDelete', ids); // ✅ ids est un number[]
};
</script>
<!-- Dans le parent — TypeScript valide les handlers -->
<script setup lang="ts">
// Le handler est typé automatiquement depuis les emits du composant enfant
const handleSelect = (row: Row) => { // TypeScript infère le type ✅
console.log('Sélectionné :', row.id);
};
const handleSort = (field: string, direction: 'asc' | 'direction') => {
// TypeScript infère direction comme union littérale ✅
};
</script>
<template>
<DataTable
@select="handleSelect"
@sort="handleSort"
@close="modalOpen = false"
/>
</template>
Typer ref(), reactive() et computed()
<script setup lang="ts">
import { ref, reactive, computed } from 'vue';
// --- ref() ---
// Inférence automatique quand la valeur initiale est claire
const count = ref(0); // Ref<number> — inféré
const name = ref('Alice'); // Ref<string> — inféré
const active = ref(true); // Ref<boolean> — inféré
// Type explicite nécessaire pour null, unions, tableaux vides
interface Article { id: number; title: string; published: boolean; }
const article = ref<Article | null>(null); // Commence null
const articles = ref<Article[]>([]); // Tableau vide typé
const status = ref<'idle' | 'loading' | 'error' | 'success'>('idle');
const ids = ref<Set<number>>(new Set()); // Types complexes
// Après assignation, TypeScript sait que article peut être Article ou null
if (article.value !== null) {
article.value.title; // ✅ TypeScript sait que c'est un Article ici
}
// --- reactive() ---
// L'interface peut être inline ou importée
interface FormState {
email: string;
password: string;
confirmPassword: string;
agreeToTerms: boolean;
}
const form = reactive<FormState>({
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false,
});
// form.email → string ✅
// form.unknownField → erreur TypeScript ✅
// --- computed() ---
// TypeScript infère le type de retour depuis la fonction
const fullName = computed(() => `${name.value} Dupont`); // ComputedRef<string>
// Type explicite si nécessaire (rare avec Composition API)
const formattedArticles = computed<string[]>(() =>
articles.value.map(a => `[${a.id}] ${a.title}`)
);
</script>
Typer les template refs (accès au DOM)
<script setup lang="ts">
import { ref, onMounted } from 'vue';
// Template ref typée — doit correspondre au type de l'élément HTML cible
const inputRef = ref<HTMLInputElement | null>(null);
const formRef = ref<HTMLFormElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const buttonRef = ref<HTMLButtonElement | null>(null);
// Typer une ref vers un composant enfant
import MyModal from './MyModal.vue';
const modalRef = ref<InstanceType<typeof MyModal> | null>(null);
// InstanceType<typeof Composant> donne le type de l'instance exposée
onMounted(() => {
// Après montage, l'élément existe — vérification null nécessaire
if (inputRef.value) {
inputRef.value.focus(); // ✅ HTMLInputElement — focus() disponible
inputRef.value.select(); // ✅ Méthodes input disponibles
}
// Appel d'une méthode exposée par le composant enfant
if (modalRef.value) {
modalRef.value.open(); // ✅ Si MyModal expose open() via defineExpose
}
});
</script>
<template>
<!-- ref="inputRef" connecte l'élément DOM à la ref TypeScript -->
<input ref="inputRef" type="text" placeholder="Focus automatique" />
<MyModal ref="modalRef" />
</template>
Typer les événements du DOM
Les handlers d'événements reçoivent des objets Event natifs du DOM. TypeScript dispose de types précis pour chaque événement — les utiliser donne accès aux propriétés spécifiques (event.target.value pour un input, etc.).
<script setup lang="ts">
// --- Événements de formulaire ---
const handleInput = (event: Event) => {
// event.target est HTMLElement — pas d'accès à .value directement
const target = event.target as HTMLInputElement; // Cast nécessaire
console.log(target.value); // string ✅
};
// Mieux : utiliser InputEvent pour les inputs texte
const handleTextInput = (event: InputEvent) => {
const input = event.target as HTMLInputElement;
console.log(input.value, input.selectionStart);
};
// Soumission de formulaire
const handleSubmit = (event: SubmitEvent) => {
event.preventDefault(); // SubmitEvent a preventDefault() ✅
const form = event.target as HTMLFormElement;
const data = new FormData(form); // FormData depuis l'élément form
};
// --- Événements clavier ---
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
// Soumettre le formulaire...
}
if (event.ctrlKey && event.key === 's') {
// Ctrl+S — sauvegarder...
}
};
// --- Événements souris ---
const handleClick = (event: MouseEvent) => {
console.log(event.clientX, event.clientY); // Coordonnées ✅
console.log(event.button); // 0=gauche, 1=molette, 2=droite
};
// --- Événements drag & drop ---
const handleDrop = (event: DragEvent) => {
event.preventDefault();
const files = event.dataTransfer?.files; // FileList | undefined
if (files?.[0]) {
console.log(files[0].name, files[0].size);
}
};
// --- v-model et les inputs Vue ---
// Pour un composant avec v-model, le handler reçoit la valeur directe
const handleModelUpdate = (value: string) => {
// Pas d'Event ici — Vue passe la valeur directement
console.log(value);
};
</script>
<template>
<form @submit="handleSubmit" @keydown="handleKeydown">
<input @input="handleInput" type="text" />
<button @click="handleClick" @drop.prevent="handleDrop">
Soumettre
</button>
</form>
</template>
as HTMLInputElement est
parfois inévitable car Vue passe Event générique. Une alternative plus
sûre est le type guard : if (event.target instanceof HTMLInputElement)
— TypeScript affine le type automatiquement sans cast explicite.
Composants génériques avec TypeScript
Vue 3.3+ supporte les composants génériques : un composant qui accepte un type paramétrable, comme les composants de liste ou de sélection. C'est une fonctionnalité avancée mais très puissante pour les composants réutilisables.
<!-- components/SelectInput.vue — Composant générique -->
<script setup lang="ts" generic="T extends { id: number; label: string }">
// "generic" est une syntaxe Vue 3.3+ — définit le paramètre de type T
// T doit au minimum avoir id: number et label: string
interface Props {
options: T[]; // Tableau du type générique
modelValue: T | null; // Valeur sélectionnée (v-model)
placeholder?: string;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: T | null]; // Pour v-model
}>();
const handleChange = (event: Event) => {
const id = Number((event.target as HTMLSelectElement).value);
const selected = props.options.find(o => o.id === id) ?? null;
emit('update:modelValue', selected); // T | null
};
</script>
<template>
<select @change="handleChange" :value="modelValue?.id ?? ''">
<option value="">{{ placeholder ?? 'Sélectionner...' }}</option>
<option v-for="opt in options" :key="opt.id" :value="opt.id">
{{ opt.label }}
</option>
</select>
</template>
<!-- Utilisation avec des types concrets -->
<script setup lang="ts">
import { ref } from 'vue';
import SelectInput from '@/components/SelectInput.vue';
interface Country { id: number; label: string; code: string; }
interface Category { id: number; label: string; slug: string; }
const countries: Country[] = [
{ id: 1, label: 'France', code: 'FR' },
{ id: 2, label: 'Belgique', code: 'BE' },
];
const categories: Category[] = [
{ id: 1, label: 'Frontend', slug: 'frontend' },
{ id: 2, label: 'Backend', slug: 'backend' },
];
const selectedCountry = ref<Country | null>(null);
const selectedCategory = ref<Category | null>(null);
</script>
<template>
<!-- TypeScript sait que selectedCountry est Country | null ✅ -->
<SelectInput v-model="selectedCountry" :options="countries" />
<SelectInput v-model="selectedCategory" :options="categories" />
<p v-if="selectedCountry">Code : {{ selectedCountry.code }}</p>
</template>
defineExpose : exposer des méthodes au parent
<!-- components/VideoPlayer.vue -->
<script setup lang="ts">
import { ref } from 'vue';
const videoRef = ref<HTMLVideoElement | null>(null);
const isPlaying = ref(false);
const play = () => {
videoRef.value?.play();
isPlaying.value = true;
};
const pause = () => {
videoRef.value?.pause();
isPlaying.value = false;
};
const seekTo = (seconds: number) => {
if (videoRef.value) videoRef.value.currentTime = seconds;
};
// defineExpose rend ces méthodes accessibles au parent via template ref
// Sans defineExpose, le parent ne peut rien appeler sur le composant
defineExpose({ play, pause, seekTo, isPlaying });
</script>
<!-- Parent qui utilise les méthodes exposées -->
<script setup lang="ts">
import { ref } from 'vue';
import VideoPlayer from './VideoPlayer.vue';
// InstanceType<typeof VideoPlayer> donne le type des méthodes exposées
const playerRef = ref<InstanceType<typeof VideoPlayer> | null>(null);
const handlePlay = () => {
playerRef.value?.play(); // ✅ TypeScript connaît la méthode play()
playerRef.value?.seekTo(30); // ✅ Type: (seconds: number) => void
};
</script>
Erreurs fréquentes et solutions
Erreur 1 : Type 'null' is not assignable to type 'string'
// ❌ Problème : ref initialisé à null sans type explicite
const user = ref(null); // Inféré comme Ref<null> — ne peut plus changer !
user.value = { name: 'Alice' }; // ❌ Erreur TypeScript
// ✅ Solution : typer explicitement avec l'union
const user = ref<{ name: string } | null>(null);
user.value = { name: 'Alice' }; // ✅
Erreur 2 : Property does not exist on type 'never'
// ❌ Problème : accès sans vérification de null
const user = ref<User | null>(null);
console.log(user.value.name); // ❌ user.value pourrait être null !
// ✅ Solution 1 : optional chaining
console.log(user.value?.name); // string | undefined
// ✅ Solution 2 : narrowing (type guard)
if (user.value !== null) {
console.log(user.value.name); // ✅ TypeScript sait que c'est User
}
// ✅ Solution 3 : non-null assertion (à utiliser avec parcimonie)
console.log(user.value!.name); // Force TS à ignorer le null — risqué
Erreur 3 : Argument of type X is not assignable to parameter
// ❌ Problème : event.target mal typé
const handleInput = (event: Event) => {
console.log(event.target.value); // ❌ .value n'existe pas sur EventTarget
};
// ✅ Solution : type guard avec instanceof
const handleInput = (event: Event) => {
if (event.target instanceof HTMLInputElement) {
console.log(event.target.value); // ✅ TypeScript sait que c'est HTMLInputElement
}
};
Erreur 4 : Type 'string' is not assignable to union
// ❌ Problème : valeur reçue de l'API non validée
const status = ref<'active' | 'inactive' | 'pending'>('active');
const apiData = await fetch('/api/user').then(r => r.json());
status.value = apiData.status; // ❌ apiData.status est 'any' ou 'string'
// ✅ Solution 1 : assertion de type (si vous faites confiance à l'API)
status.value = apiData.status as 'active' | 'inactive' | 'pending';
// ✅ Solution 2 : validation runtime avec type guard
const isValidStatus = (s: string): s is 'active' | 'inactive' | 'pending' =>
['active', 'inactive', 'pending'].includes(s);
if (isValidStatus(apiData.status)) {
status.value = apiData.status; // ✅ Type affiné par le type guard
}
as (cast) autant que possible
— c'est désactiver TypeScript localement. Préférez les type guards avec
instanceof ou des fonctions prédicates (is). Si vous
utilisez beaucoup de casts, c'est souvent le signe d'une architecture à revoir.
Checklist Vue 3 + TypeScript
Configuration
lang="ts"sur tous les<script setup>- Extension Volar (Vue - Official) installée dans VS Code
"strict": truedans tsconfig.json- Alias
@/*configuré pour les imports propres - Types partagés dans
src/types/index.ts
Props et emits
defineProps<Interface>()pour toutes les propswithDefaults()pour les valeurs par défaut (fonction pour objets/tableaux)defineEmits<{...}>()avec les types de chaque argumentdefineExpose()pour les méthodes accessibles depuis le parent
Réactivité et événements
- Type explicite sur
ref<T | null>(null)et tableaux vides InstanceType<typeof Composant>pour les template refs- Type guard
instanceofpour les events DOM - Éviter les casts
as— préférer les type guards - Type guard personnalisé (
is) pour valider les données API
TypeScript transforme Vue 3 d'un framework agréable en un environnement de développement de premier ordre. La combinaison Vue 3 + TypeScript + Volar représente aujourd'hui l'une des meilleures expériences de développement frontend : les erreurs sont détectées avant même d'ouvrir le navigateur, le refactoring devient sûr, et chaque composant est auto-documenté par ses types. L'investissement initial dans la configuration et l'apprentissage se rentabilise très rapidement sur tout projet de taille réelle.