Vue 3 TypeScript : defineProps, defineEmits, defineSlots, defineModel, InjectionKey, useTemplateRef, vue-tsc, Volar et patterns type-safe complets.
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.
defineModel — la révolution v-model de Vue 3.4+
Avant Vue 3.4, implémenter un composant avec v-model exigeait du boilerplate : defineProps + defineEmits + watcher pour le sync. defineModel<T>() (stable depuis 3.4) réduit ça à une ligne.
<!-- ❌ Avant Vue 3.4 — verbeux -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>();
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
const value = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
</script>
<!-- ✓ Vue 3.4+ — defineModel -->
<script setup lang="ts">
const model = defineModel<string>();
// C'est tout — model est un ref bidirectionnel synchronisé avec le parent
</script>
<template>
<input v-model="model" />
</template>
Multiples v-model dans un composant
<!-- Composant FullNameInput -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName');
const lastName = defineModel<string>('lastName');
</script>
<template>
<input v-model="firstName" placeholder="Prénom" />
<input v-model="lastName" placeholder="Nom" />
</template>
<!-- Usage parent -->
<FullNameInput v-model:firstName="user.firstName" v-model:lastName="user.lastName" />
Transformations et validation avec get/set
// Force uppercase + trim au set
const code = defineModel<string>('code', {
get: (v) => v ?? '',
set: (v) => v.trim().toUpperCase(),
});
// Avec validation et default value
const age = defineModel<number>('age', {
required: true,
validator: (v: number) => v >= 0 && v <= 120,
});
defineModel élimine 80 % du boilerplate des composants contrôlés. C'est maintenant la voie standard pour tout nouveau composant Vue 3.4+ qui consomme un v-model.
InjectionKey — provide/inject type-safe
Sans InjectionKey, les valeurs injectées via inject('myKey') sont typées unknown et nécessitent un cast. InjectionKey<T> typé fournit une référence partagée entre fournisseur et consommateur avec inférence automatique.
// types/injection-keys.ts — fichier partagé
import type { InjectionKey, Ref } from 'vue';
export interface UserContext {
user: Ref<User | null>;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
export const USER_CONTEXT: InjectionKey<UserContext> = Symbol('USER_CONTEXT');
export const THEME_KEY: InjectionKey<Ref<'light' | 'dark'>> = Symbol('THEME');
<!-- AppRoot.vue — fournit -->
<script setup lang="ts">
import { provide, ref } from 'vue';
import { USER_CONTEXT, THEME_KEY } from '@/types/injection-keys';
const user = ref<User | null>(null);
const theme = ref<'light' | 'dark'>('light');
provide(USER_CONTEXT, {
user,
login: async (email, password) => { /* ... */ },
logout: () => { user.value = null; },
});
provide(THEME_KEY, theme);
</script>
<!-- DeepChild.vue — consomme avec typage automatique -->
<script setup lang="ts">
import { inject } from 'vue';
import { USER_CONTEXT, THEME_KEY } from '@/types/injection-keys';
const userCtx = inject(USER_CONTEXT); // UserContext | undefined
const theme = inject(THEME_KEY, ref('light')); // fallback = non-undefined
// Pattern strict — throw si pas de provider
function injectStrict<T>(key: InjectionKey<T>): T {
const value = inject(key);
if (value === undefined) throw new Error(`No provider for ${key.toString()}`);
return value;
}
const userCtxStrict = injectStrict(USER_CONTEXT); // UserContext (non-null)
</script>
Pattern utilisé par tous les composants UI Vue 3 sérieux (Element Plus, Naive UI) pour partager le contexte sans Pinia. Plus léger que créer un store pour des données scopées localement (état d'un formulaire multi-étapes, thème d'une section).
useTemplateRef — l'API moderne pour les refs DOM
Vue 3.5+ introduit useTemplateRef qui simplifie la récupération de refs DOM par rapport à l'ancienne syntaxe ref(null).
<!-- ❌ Avant Vue 3.5 -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const input = ref<HTMLInputElement | null>(null);
onMounted(() => input.value?.focus());
</script>
<template><input ref="input" /></template>
<!-- ✓ Vue 3.5+ -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue';
const inputRef = useTemplateRef<HTMLInputElement>('input');
onMounted(() => inputRef.value?.focus());
</script>
<template><input ref="input" /></template>
Ref vers un composant enfant
// Enfant — expose une API via defineExpose
<script setup lang="ts">
const count = ref(0);
function reset() { count.value = 0; }
function increment() { count.value++; }
defineExpose({ reset, increment, count });
</script>
// Parent — typage automatique via InstanceType
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import Counter from './Counter.vue';
const counterRef = useTemplateRef<InstanceType<typeof Counter>>('counter');
function handleReset() {
counterRef.value?.reset();
console.log('Compteur :', counterRef.value?.count); // typé Ref<number>
}
</script>
<template>
<Counter ref="counter" />
<button @click="handleReset">Reset</button>
</template>
Avantage de useTemplateRef sur ref(null) : nom de la ref passé en paramètre (plus explicite), inférence du type via le générique, et meilleure intégration avec les futures évolutions du compilateur Vue.
Mini-projet appliqué — composant DataTable générique typé
Cas réel : un composant <DataTable> réutilisable et entièrement typé. Affiche n'importe quel type de données (users, products, orders) avec des colonnes configurables, tri, slot custom, et émission d'événements. Le pattern utilisé par PrimeVue, Vuetify, et toutes les libs UI Vue 3 modernes.
1. Composant générique avec props typées
Pour les concepts de génériques TypeScript, voir le guide complet des génériques.
<script setup lang="ts" generic="T extends { id: string | number }">
import { ref, computed } from 'vue';
interface Column<Row> {
key: keyof Row;
label: string;
sortable?: boolean;
formatter?: (value: Row[keyof Row], row: Row) => string;
width?: string;
}
interface Props {
rows: T[];
columns: Column<T>[];
selectable?: boolean;
loading?: boolean;
emptyMessage?: string;
}
const props = withDefaults(defineProps<Props>(), {
selectable: false,
loading: false,
emptyMessage: 'Aucune donnée',
});
const emit = defineEmits<{
rowClick: [row: T];
selectionChange: [selected: T[]];
sortChange: [column: keyof T, direction: 'asc' | 'desc'];
}>();
const sortBy = ref<keyof T | null>(null);
const sortDir = ref<'asc' | 'desc'>('asc');
const selectedIds = ref<Set<T['id']>>(new Set());
const sortedRows = computed(() => {
if (!sortBy.value) return props.rows;
const key = sortBy.value;
return [...props.rows].sort((a, b) => {
const va = a[key];
const vb = b[key];
if (va < vb) return sortDir.value === 'asc' ? -1 : 1;
if (va > vb) return sortDir.value === 'asc' ? 1 : -1;
return 0;
});
});
function toggleSort(col: Column<T>) {
if (!col.sortable) return;
if (sortBy.value === col.key) {
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
} else {
sortBy.value = col.key;
sortDir.value = 'asc';
}
emit('sortChange', col.key, sortDir.value);
}
function toggleSelection(row: T) {
const next = new Set(selectedIds.value);
if (next.has(row.id)) next.delete(row.id);
else next.add(row.id);
selectedIds.value = next;
emit('selectionChange', props.rows.filter(r => selectedIds.value.has(r.id)));
}
</script>
<template>
<table class="data-table">
<thead>
<tr>
<th v-if="selectable"></th>
<th
v-for="col in columns"
:key="String(col.key)"
:style="{ width: col.width }"
:class="{ sortable: col.sortable }"
@click="toggleSort(col)"
>
{{ col.label }}
<span v-if="sortBy === col.key" class="sort-indicator">
{{ sortDir === 'asc' ? '↑' : '↓' }}
</span>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading"><td :colspan="columns.length + (selectable ? 1 : 0)">Chargement…</td></tr>
<tr v-else-if="!rows.length"><td :colspan="columns.length + (selectable ? 1 : 0)">{{ emptyMessage }}</td></tr>
<tr
v-for="row in sortedRows"
:key="row.id"
:class="{ selected: selectedIds.has(row.id) }"
@click="emit('rowClick', row)"
>
<td v-if="selectable">
<input
type="checkbox"
:checked="selectedIds.has(row.id)"
@click.stop="toggleSelection(row)"
/>
</td>
<td v-for="col in columns" :key="String(col.key)">
<slot
:name="`cell-${String(col.key)}`"
:value="row[col.key]"
:row="row"
>
{{ col.formatter ? col.formatter(row[col.key], row) : row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
2. Consommation côté User — typage complet
<script setup lang="ts">
import DataTable from '@/components/DataTable.vue';
import type { Column } from '@/components/DataTable.vue';
interface User {
id: string;
email: string;
fullName: string;
role: 'admin' | 'member';
createdAt: Date;
}
const users = ref<User[]>([
{ id: 'u1', email: 'alice@example.com', fullName: 'Alice Dupont', role: 'admin', createdAt: new Date() },
]);
// Colonnes typées — autocompletion sur les keys de User
const columns: Column<User>[] = [
{ key: 'fullName', label: 'Nom', sortable: true, width: '30%' },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Rôle', formatter: (v) => v === 'admin' ? '👑 Admin' : '👤 Membre' },
{ key: 'createdAt', label: 'Créé', formatter: (v) => (v as Date).toLocaleDateString('fr') },
];
function handleRowClick(user: User) {
// user est typé User ici grâce au generic
console.log('Clicked:', user.email);
}
function handleSelection(selected: User[]) {
console.log(`${selected.length} users sélectionnés`);
}
</script>
<template>
<DataTable
:rows="users"
:columns="columns"
:selectable="true"
empty-message="Aucun utilisateur"
@row-click="handleRowClick"
@selection-change="handleSelection"
>
<!-- Slot custom typé pour la cellule "role" -->
<template #cell-role="{ value, row }">
<span :class="`badge badge-${value}`">{{ value }}</span>
<small v-if="value === 'admin'"> (super-admin)</small>
</template>
</DataTable>
</template>
- generic="T extends { id: string | number }" dans
<script setup>(Vue 3.3+) — un composant Vue générique sans hack - Column<T>[] contraint
keyà être une clé EXISTANTE de T — autocompletion + erreur compile si typo - Slots typés via les types des props (
row: T,value: T[keyof T]) — chaque slot consommateur a l'autocomplete complète - defineEmits<{...}> avec syntaxe tuple — émission type-safe (TypeScript bloque si on émet le mauvais type)
- Réutilisable pour User, Product, Order — un seul composant DataTable, 10 usages typés différemment
Pour pousser le pattern (composants polymorphes, slot scoped, defineModel multiples), lire le guide des slots nommés et scoped slots qui complète celui-ci. Pour la version React équivalente du DataTable générique, voir le mini-projet design system Button + TextField React.