frontend / vuejs
TypeScript avec Vue
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 avecInjectionKey<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é Vue | Syntaxe typée | Bénéfice |
|---|---|---|
| Props | defineProps<Props>() | Autocomplétion et erreurs de typo détectées à la compilation |
| Emits | defineEmits<{ evt: [args] }>() | Signature stricte de chaque événement émis |
| Provide/Inject | InjectionKey<T> | Empêche d'injecter une valeur du mauvais type |
| Template ref | useTemplateRef<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">.
<!-- 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>// 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// 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)
},
},
})// 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
}// 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écuriseprovide/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
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.