Vue 3 avec TypeScript en 2026 : props, emits et composables typés
Les patterns Vue 3 et TypeScript en 2026 : defineProps, defineEmits, composables, defineModel et provide/inject typés, avec exemples concrets et questions d'entretien.

Vue 3 avec TypeScript en 2026 offre aux composants une vérification statique complète des types, aussi bien sur les props que sur les événements ou la logique réutilisable. La syntaxe <script setup>, combinée aux macros du compilateur comme defineProps et defineEmits, transforme des définitions autrefois validées uniquement à l'exécution en contrats vérifiés à la compilation. Ce guide couvre les patterns typés que tout développeur Vue doit maîtriser pour le code de production comme pour les entretiens techniques.
Depuis Vue 3.5+, la forme générique defineProps<T>() infère les types des props directement à partir d'une interface TypeScript — sans champ type à l'exécution, sans cast PropType. Associée à la déstructuration réactive des props, les valeurs par défaut tiennent en ligne : const { size = 'md' } = defineProps<Props>().
Typer les props avec defineProps et les interfaces TypeScript
La déclaration des props à l'exécution (props: { title: String }) fonctionne, mais elle duplique l'information de type et perd en précision. Les types union, les objets imbriqués et les signatures de fonction ne peuvent pas y être exprimés proprement. La forme générique de defineProps résout ce problème en prenant la forme depuis un type TypeScript, que le compilateur Vue convertit ensuite automatiquement en déclaration d'exécution correcte.
<!-- UserCard.vue -->
<script setup lang="ts">
interface Props {
userId: number // required primitive
name: string
role: 'admin' | 'member' // union type, impossible with runtime props
tags?: string[] // optional array
onSelect?: (id: number) => void // typed callback prop
}
const props = defineProps<Props>()
</script>
<template>
<article @click="props.onSelect?.(props.userId)">
<h3>{{ props.name }}</h3>
<span>{{ props.role }}</span>
</article>
</template>La prop role est désormais contrainte à deux chaînes littérales : passer role="guest" fait échouer le build. C'est la raison principale de recourir à defineProps avec TypeScript. Le guide TypeScript officiel de Vue considère d'ailleurs la forme générique comme celle par défaut pour les projets en <script setup>.
Valeurs par défaut : withDefaults ou déstructuration réactive
Les props purement typées n'ont pas de mécanisme de valeur par défaut à l'exécution. Historiquement, la réponse était withDefaults, qui enveloppe la macro et fusionne un objet de valeurs par défaut :
<!-- Button.vue -->
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
}
// withDefaults keeps reactivity and applies fallback values
const props = withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
})
</script>Vue 3.5 a stabilisé la déstructuration réactive des props, aujourd'hui le pattern le plus concis. Déstructurer le résultat de defineProps en assignant une valeur par défaut dans la même instruction reste pleinement réactif : le compilateur réécrit chaque accès vers props.x en coulisses.
<!-- Badge.vue -->
<script setup lang="ts">
interface Props {
label: string
color?: 'green' | 'red'
outlined?: boolean
}
// Defaults are declared inline; each variable stays reactive
const { label, color = 'green', outlined = false } = defineProps<Props>()
</script>
<template>
<span :class="[color, { outlined }]">{{ label }}</span>
</template>Les props déstructurées restent réactives dans le template et dans un computed, mais passer une valeur déstructurée directement dans watch ou dans un composable la lit une seule fois et rompt le lien réactif. Il faut l'envelopper dans une fonction getter — watch(() => color, ...) — ou la convertir avec toRef(props, 'color') quand une ref est nécessaire plus loin.
C'est un piège classique d'entretien : les candidats supposent que la variable déstructurée est une valeur simple, alors que le compilateur a en réalité redirigé chaque lecture vers props.color.
Des emits typés avec defineEmits
Les événements méritent la même rigueur que les props. La forme générique de defineEmits décrit chaque nom d'événement et sa charge utile sous forme de tuple, offrant au composant parent l'autocomplétion et au composant enfant la garantie, à la compilation, que les bons arguments sont émis.
<!-- SearchInput.vue -->
<script setup lang="ts">
// Tuple syntax: event name -> [ ...payload types ]
const emit = defineEmits<{
search: [query: string]
clear: [] // no payload
select: [id: number, label: string] // multiple args
}>()
function onSubmit(value: string) {
emit('search', value) // ✅ typed
// emit('search', 42) // ❌ compile error: number not assignable to string
}
</script>Cette forme en tuple a remplacé l'ancienne syntaxe par signature d'appel ((e: 'search', q: string): void) parce qu'elle se lit mieux et gère plusieurs événements sans surcharge. Lorsqu'un événement ne transporte aucune donnée, un tuple vide [] le documente explicitement. Associer des emits typés à des props typées produit des composants dont toute l'interface publique est vérifiable avant l'exécution — la même discipline que celle abordée dans le guide de l'API de composition Vue.
Typer les composables pour la logique réutilisable
Les composables sont de simples fonctions : ils suivent donc les règles ordinaires de TypeScript, mais quelques conventions les rendent plus ergonomiques. Il faut annoter explicitement les types Ref quand l'inférence n'est pas évidente, et utiliser des génériques lorsqu'un composable enveloppe des données arbitraires comme une réponse d'API.
import { ref, type Ref } from 'vue'
interface UseFetchReturn<T> {
data: Ref<T | null>
error: Ref<Error | null>
loading: Ref<boolean>
}
// Generic <T> flows through to the caller's typed data
export function useFetch<T>(url: string): UseFetchReturn<T> {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(true)
fetch(url)
.then((r) => r.json())
.then((json: T) => { data.value = json })
.catch((e: Error) => { error.value = e })
.finally(() => { loading.value = false })
return { data, error, loading }
}Du côté de l'appel, le paramètre générique rend data entièrement typé sans aucune annotation supplémentaire :
interface User { id: number; name: string }
// data is Ref<User[] | null> — inferred from the generic
const { data: users, loading } = useFetch<User[]>('/api/users')Annoter explicitement l'objet de retour (UseFetchReturn<T>) vaut ces quelques lignes supplémentaires : cela documente le contrat, empêche les fuites accidentelles de refs internes et fournit aux consommateurs un type unique à importer. Pour des patterns de composables plus avancés comme les surcharges d'arguments et le nettoyage lié au cycle de vie, voir le guide avancé des composables Vue.
Prêt à réussir tes entretiens Vue.js / Nuxt.js ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
Typer defineModel pour la liaison bidirectionnelle
defineModel, stable depuis Vue 3.4, condense l'ancien duo prop modelValue plus emit update:modelValue en une seule ref inscriptible. Son paramètre générique type les deux sens de la liaison en même temps.
<!-- CurrencyInput.vue -->
<script setup lang="ts">
// model is Ref<number>; parent v-model is type-checked as number
const model = defineModel<number>({ required: true })
function increment() {
model.value++ // writing back updates the parent
}
</script>
<template>
<input :value="model" type="number" @input="model = Number(($event.target as HTMLInputElement).value)" />
<button @click="increment">+1</button>
</template>Les modèles nommés — defineModel<string>('title') — correspondent à v-model:title et reçoivent le même typage. Cela élimine toute une catégorie de bugs de charge utile incohérente que le pattern manuel prop-et-emit avait l'habitude de masquer.
Typer les refs de template et les instances de composant
Accéder à un nœud du DOM ou à un composant enfant via une ref est l'endroit où le code Vue non typé retombe le plus souvent sur any. La solution consiste à paramétrer useTemplateRef (Vue 3.5+) ou la ref elle-même avec le type de l'élément, afin que l'accès aux propriétés soit vérifié contre la vraie interface du DOM.
<!-- FocusField.vue -->
<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'
// The ref is typed as HTMLInputElement | null
const inputRef = useTemplateRef<HTMLInputElement>('field')
onMounted(() => {
// .focus() and .select() are known because the element type is explicit
inputRef.value?.focus()
})
</script>
<template>
<input ref="field" placeholder="Type to search" />
</template>Pour une référence vers un composant enfant, InstanceType<typeof Child> extrait le type public du composant, exposant tout ce que l'enfant a déclaré via defineExpose. Les appels de méthode du parent vers l'enfant restent ainsi entièrement vérifiés, plutôt que devinés.
<!-- Parent.vue -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import Modal from './Modal.vue'
// Ref is typed to Modal's exposed API (e.g. open / close methods)
const modal = useTemplateRef<InstanceType<typeof Modal>>('modal')
function launch() {
modal.value?.open() // ✅ checked against Modal's defineExpose
}
</script>provide et inject typés avec InjectionKey
L'injection de dépendances à travers l'arbre de composants perd son information de type à moins que la clé ne la transporte. InjectionKey<T> est un symbole typé qui lie le type d'une valeur à sa clé, de sorte que provide et inject restent synchronisés sans cast manuel.
import type { InjectionKey, Ref } from 'vue'
export interface ThemeContext {
mode: Ref<'light' | 'dark'>
toggle: () => void
}
// The key permanently associates the ThemeContext type with this symbol
export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')// provider (script setup) — value must match ThemeContext or it fails to compile
provide(ThemeKey, { mode, toggle })
// consumer — theme is inferred as ThemeContext | undefined
const theme = inject(ThemeKey)
theme?.toggle()Fournir une valeur dont la forme ne correspond pas à ThemeContext provoque une erreur de compilation au point d'injection, capturant tout écart entre des composants éloignés avant sa mise en production.
Questions d'entretien fréquentes sur Vue et TypeScript
Les recruteurs cherchent à savoir si le candidat comprend la frontière entre les types de la compilation et le comportement à l'exécution. Quelques questions récurrentes et leurs réponses tranchantes :
- Pourquoi préférer
defineProps<T>()à la forme objet d'exécution ? Les props génériques expriment des types union, des signatures de fonction et des formes imbriquées que les déclarations d'exécution ne peuvent pas capturer, et elles suppriment la duplication entre définition de type et définition d'exécution. - Les props déstructurées sont-elles réactives ? Oui depuis Vue 3.5+, car le compilateur réécrit l'accès vers
props.x. Mais une valeur déstructurée passée danswatchou un composable n'est lue qu'une fois — il faut utiliser un getter outoRef. - Comment typer un événement émis sans charge utile ? Avec un tuple vide :
defineEmits<{ close: [] }>(). - Que remplace
defineModel? Le duo propmodelValueet emitupdate:modelValue, unifié en une seule ref inscriptible typée.
S'entraîner sur une véritable banque de questions aiguise les réflexes que les entretiens récompensent — le module d'entretien sur les composables Vue travaille précisément ces patterns. L'outillage compte aussi : exécuter vue-tsc en CI pour que les erreurs de type bloquent les merges, et s'appuyer sur le manuel TypeScript quand les génériques de composables entrent en jeu. L'expérience dans l'éditeur repose sur l'outillage officiel Volar de Vue, qui lit ces macros pour signaler les erreurs en ligne.
Conclusion
- Utiliser la forme générique
defineProps<Props>()pour exprimer des types union, des champs optionnels et des props callback que les déclarations d'exécution ne peuvent pas capturer. - Préférer la déstructuration réactive des props avec valeurs par défaut en ligne dès Vue 3.5+, et ne recourir à
withDefaultsque lorsqu'un objet de valeurs par défaut partagé est plus clair. - Typer les événements avec la forme en tuple de
defineEmitsafin que les charges utiles soient vérifiées à la compilation dans l'enfant comme dans le parent. - Annoter explicitement les types de retour des composables et utiliser des génériques pour transmettre de bout en bout les types de données fournis par l'appelant.
- Adopter
defineModel<T>()pour la liaison bidirectionnelle afin de condenser le boilerplate prop-plus-emit en une seule ref typée. - Exécuter
vue-tscen intégration continue pour que les régressions de type fassent échouer le build plutôt que d'atteindre la production.
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Vue 3 Composition API : Guide complet pour maîtriser la réactivité
Découvrez Vue 3 Composition API avec ce guide pratique. Apprenez ref, reactive, computed, watch et les composables pour créer des applications Vue performantes.

Composables Vue 3 Avances : Patterns Reutilisables et Questions d'Entretien 2026
Guide complet des composables Vue 3 avances : patterns reutilisables, gestion asynchrone, injection de dependances, validation de formulaires et questions d'entretien technique 2026.

Nuxt 4 en 2026 : nouvelle structure de répertoires et migration depuis Nuxt 3
Guide complet de migration vers Nuxt 4 : structure de répertoires app/, couche de récupération de données singleton, améliorations TypeScript et instructions de mise à jour pas à pas avec exemples de code.