Retour au cours

frontend / vuejs

TypeScript avec Vue

Leçon 201 exercice

Explication

Ce que vous allez apprendre

  • Typer précisément les props et les emits d'un composant avec <script setup lang="ts">
  • Créer des composables génériques réutilisables sur plusieurs types de données
  • Typer un store Pinia de bout en bout : état, getters et actions
  • Sécuriser provide/inject à la compilation avec InjectionKey<T>
  • Typer une référence de template pour accéder à un élément DOM en toute sécurité

Dans quel contexte ?

Une équipe qui maintient une application Vue en JavaScript pur découvre régulièrement des bugs en production causés par des props mal nommées ou des événements émis avec le mauvais nombre d'arguments — des erreurs qu'un simple typage aurait détectées à la compilation, avant même que le code n'atteigne un navigateur. Migrer vers TypeScript n'est pas qu'une question de style : c'est un filet de sécurité qui déplace la détection des erreurs du runtime vers l'écriture du code.

D'abord, un fait important à connaître

Vue 3 a été entièrement réécrit en TypeScript, contrairement à Vue 2 où le typage était ajouté après coup. Cette réécriture rend l'intégration native et particulièrement fluide avec <script setup lang="ts">, qui permet une inférence de types quasiment complète sans configuration additionnelle.

Prérequis

Cette leçon suppose une bonne connaissance de <script setup>, des props et des emits en JavaScript, vus dans les leçons précédentes. Une familiarité de base avec les types TypeScript (interfaces, génériques) aide grandement.

Fonctionnalité VueSyntaxe typéeBénéfice
PropsdefineProps<Props>()Autocomplétion et erreurs de typo détectées à la compilation
EmitsdefineEmits<{ evt: [args] }>()Signature stricte de chaque événement émis
Provide/InjectInjectionKey<T>Empêche d'injecter une valeur du mauvais type
Template refuseTemplateRef<HTMLInputElement>Autocomplétion des méthodes DOM (.focus(), etc.)

Une fois les props et emits typés, un autre besoin apparaît vite : les composables génériques

Un composable comme useFetch ne devrait pas être limité à un seul type de donnée. En le rendant générique avec <T>, la même fonction peut typer correctement un tableau de produits, un objet utilisateur, ou n'importe quelle autre forme de donnée — l'IDE propose alors une autocomplétion précise sur donnees.value selon le type demandé à l'appel.

Il reste un point souvent négligé : le typage de provide/inject

Sans typage, inject('theme') renvoie unknown et ne garantit rien sur la forme réelle de la donnée injectée — une source classique d'erreurs si un composant ancêtre change la forme de ce qu'il fournit. InjectionKey<T> résout ce problème en liant explicitement une clé à un type précis : toute tentative de fournir ou d'injecter une valeur incompatible est détectée par le compilateur, avant même l'exécution.

Piège courant

inject() peut toujours renvoyer undefined si aucun ancêtre n'a appelé provide() avec cette clé — même typé, TypeScript ne peut pas garantir qu'un fournisseur existe réellement dans l'arbre de composants. Vérifie toujours ce cas avec une condition explicite plutôt que d'utiliser directement la valeur.

Enfin, Pinia s'intègre nativement avec TypeScript

En typant l'état via une fonction state: (): EtatPanier => (...), les getters et les actions héritent automatiquement des types déclarés, sans configuration supplémentaire ni annotation répétée à chaque utilisation du store.

Bonne pratique

Privilégie toujours defineProps<Interface>() plutôt que la syntaxe runtime (defineProps({ nom: String })) dès qu'un projet utilise TypeScript : l'inférence est plus précise et les erreurs de typo sur les noms de props sont détectées immédiatement par l'éditeur.

Maintenant que les composants sont solidement typés, la prochaine leçon change de dimension en abordant le rendu côté serveur (SSR) et le framework Nuxt, pour des enjeux de SEO et de performance au chargement.

Commandes & code

TypeScript avec Vue

Vue 3 a été réécrit en TypeScript ; l'intégration est native et l'inférence de types est excellente avec <script setup lang="ts">.

vue
<!-- components/CarteProduit.vue -->
<script setup lang="ts">
import { computed } from 'vue'

// Types explicites pour les props : meilleure inférence qu'avec l'objet runtime
interface Produit {
  id: number
  nom: string
  prix: number
  stock: number
}

interface Props {
  produit: Produit
  enPromotion?: boolean // ? = optionnel
}

// withDefaults pour typer les valeurs par défaut proprement
const props = withDefaults(defineProps<Props>(), {
  enPromotion: false,
})

// defineEmits typé : chaque événement a une signature stricte de payload
const emit = defineEmits<{
  ajouterPanier: [id: number, quantite: number]
  survol: [id: number]
}>()

const disponible = computed<boolean>(() => props.produit.stock > 0)

function ajouter(quantite: number): void {
  if (!disponible.value) return
  emit('ajouterPanier', props.produit.id, quantite)
}
</script>

<template>
  <article @mouseenter="emit('survol', produit.id)">
    <h3>{{ produit.nom }}</h3>
    <button :disabled="!disponible" @click="ajouter(1)">Ajouter</button>
  </article>
</template>
ts
// composables/useFetch.ts — composable générique, typé sur la forme des données attendues
import { ref, type Ref } from 'vue'

interface UseFetchResult<T> {
  donnees: Ref<T | null>
  erreur: Ref<Error | null>
  enChargement: Ref<boolean>
}

export function useFetch<T>(url: string): UseFetchResult<T> {
  const donnees = ref<T | null>(null) as Ref<T | null>
  const erreur = ref<Error | null>(null)
  const enChargement = ref(true)

  fetch(url)
    .then((r) => {
      if (!r.ok) throw new Error(`HTTP ${r.status}`)
      return r.json()
    })
    .then((d: T) => { donnees.value = d })
    .catch((e: Error) => { erreur.value = e })
    .finally(() => { enChargement.value = false })

  return { donnees, erreur, enChargement }
}

// Utilisation avec inférence complète :
// const { donnees } = useFetch<Produit[]>('/api/produits')
// donnees.value?.[0].prix -> typé number, autocomplété par l'IDE
ts
// stores/panier.ts — Pinia typé de bout en bout (état, getters, actions)
import { defineStore } from 'pinia'

interface ArticlePanier {
  id: number
  nom: string
  prix: number
  quantite: number
}

interface EtatPanier {
  articles: ArticlePanier[]
}

export const usePanierStore = defineStore('panier', {
  state: (): EtatPanier => ({ articles: [] }),
  getters: {
    total: (state): number => state.articles.reduce((s, a) => s + a.prix * a.quantite, 0),
  },
  actions: {
    ajouter(article: ArticlePanier): void {
      this.articles.push(article)
    },
  },
})
ts
// InjectionKey typé pour provide/inject sûr
import { type InjectionKey, provide, inject, ref } from 'vue'

interface ContexteTheme {
  theme: 'clair' | 'sombre'
  basculer: () => void
}

const ThemeKey: InjectionKey<ContexteTheme> = Symbol('theme')

// provide('themeInvalide', {}) déclencherait maintenant une erreur TypeScript à la compilation
function fournirTheme() {
  const theme = ref<'clair' | 'sombre'>('clair')
  provide(ThemeKey, {
    theme: theme.value,
    basculer: () => { theme.value = theme.value === 'clair' ? 'sombre' : 'clair' },
  })
}

function useTheme(): ContexteTheme {
  const contexte = inject(ThemeKey)
  if (!contexte) throw new Error('ThemeKey non fourni par un ancêtre')
  return contexte
}
ts
// Typer un template ref (référence directe à un élément DOM ou un composant enfant)
import { ref, onMounted, useTemplateRef } from 'vue'

const champRecherche = useTemplateRef<HTMLInputElement>('champRecherche')

onMounted(() => {
  champRecherche.value?.focus() // optional chaining car peut être null avant montage
})

Résumé

  • <script setup lang="ts"> + defineProps<Interface>() donnent une inférence de types complète sans runtime overhead.
  • defineEmits<{ evenement: [args] }>() type précisément la signature de chaque événement émis.
  • InjectionKey<T> sécurise provide/inject à la compilation, évitant les erreurs de clé ou de type.
  • Pinia est typé nativement : state/getters/actions héritent automatiquement des types déclarés.

Exercices pratiques

1 disponible
1

Mission : un thème typé qui ne se met jamais à jour visuellement

Objectif : Diagnostiquer une perte de réactivité dans un provide typé avec InjectionKey, puis corriger le typage tout en gardant la sécurité à la compilation.

Contexte

Le code de fournirTheme() de cette leçon type correctement ThemeKey: InjectionKey<ContexteTheme> et compile sans erreur, mais dans l'application réelle, cliquer sur le bouton "basculer" change bien la variable interne theme du composable (vérifié en console.log), sans jamais que l'attribut :class="theme-${theme}" d'un composant descendant ne change à l'écran.

Résoudre l’exercice →