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 TypeScript : props, emits et composables typés

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.

Des props typées en une ligne

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.

vue
<!-- 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 :

vue
<!-- 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.

vue
<!-- 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>
Le piège de réactivité des props déstructurées

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.

vue
<!-- 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.

useFetch.tstypescript
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 :

UserList.vue (script setup)typescript
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.

vue
<!-- 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.

vue
<!-- 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.

vue
<!-- 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.

theme-key.tstypescript
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')
typescript
// 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 dans watch ou un composable n'est lue qu'une fois — il faut utiliser un getter ou toRef.
  • Comment typer un événement émis sans charge utile ? Avec un tuple vide : defineEmits<{ close: [] }>().
  • Que remplace defineModel ? Le duo prop modelValue et emit update: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 à withDefaults que lorsqu'un objet de valeurs par défaut partagé est plus clair.
  • Typer les événements avec la forme en tuple de defineEmits afin 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-tsc en 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

#Vue 3
#TypeScript
#defineProps
#Composables
#Frontend

Partager

Articles similaires