TypeScript et Vue#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Vue 3 a été réécrit entièrement en TypeScript, et son support du typage statique est de première classe. Là où React reste une bibliothèque JavaScript qui se marie bien avec TypeScript, Vue intègre TypeScript dans sa conception même : les APIs de la Composition API sont conçues pour s’inférer sans annotations redondantes, et <script setup lang="ts"> est le mode de développement recommandé depuis Vue 3.2. Ce chapitre explore comment tirer parti de ce support natif pour écrire des composants Vue robustes, maintenables et pleinement typés.

Mise en place#

La configuration d’un projet Vue 3 + TypeScript repose sur Vite et sur l’outil vue-tsc :

npm create vite@latest mon-app -- --template vue-ts
cd mon-app
npm install
npm run dev

vue-tsc est un wrapper autour du compilateur TypeScript qui comprend les fichiers .vue (les Single File Components, ou SFC). Il est utilisé pour la vérification des types en ligne de commande :

# Vérification des types sans compilation (utilisé dans la CI)
npx vue-tsc --noEmit

Le tsconfig.json généré par Vite comporte quelques options particulièrement importantes pour Vue :

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "verbatimModuleSyntax": true
  }
}

```{prf:definition} verbatimModuleSyntax :label: definition-14-01 L’option verbatimModuleSyntax (introduite dans TypeScript 5.0) exige que les imports de types soient explicitement marqués avec le mot-clé type : import type { Utilisateur } from './types'. Elle garantit que le bundler peut éliminer ces imports à la compilation sans risque d’effets de bord, et qu’aucune valeur runtime ne se retrouve dans un module qui ne devrait contenir que des types. C’est une bonne pratique dans tous les projets TypeScript modernes, pas seulement avec Vue.


## Composition API avec TypeScript

### `defineComponent` — l'ancienne approche

Avant `<script setup>`, la façon d'obtenir du typage dans les options d'un composant était de l'envelopper dans `defineComponent` :

```typescript
import { defineComponent, ref, computed } from 'vue';

export default defineComponent({
  name: 'Compteur',
  props: {
    initial: {
      type: Number,
      default: 0,
    },
  },
  setup(props) {
    // props.initial est inféré comme number
    const compte = ref(props.initial);
    const double = computed(() => compte.value * 2);

    function incrémenter() {
      compte.value++;
    }

    return { compte, double, incrémenter };
  },
});

Cette approche fonctionne mais est verbeuse : la déclaration des props est dupliquée (une fois pour la validation runtime, une fois implicitement pour le typage).

<script setup lang="ts"> — l’approche moderne recommandée#

<script setup> est une syntaxe de compilation introduite dans Vue 3.2. Elle réduit considérablement le code répétitif et offre une meilleure expérience TypeScript :

<script setup lang="ts">
import { ref, computed } from 'vue';

// Les props sont déclarées une seule fois, avec des types TypeScript purs
const props = defineProps<{
  initial?: number;
  titre: string;
}>();

// La valeur par défaut s'applique via withDefaults (voir section suivante)
const compte = ref(props.initial ?? 0);
const double = computed(() => compte.value * 2);

function incrémenter() {
  compte.value++;
}
</script>

<template>
  <div>
    <h2>{{ titre }}</h2>
    <p>Compte : {{ compte }} (double : {{ double }})</p>
    <button @click="incrémenter">+1</button>
  </div>
</template>

Remarque 32

Dans un bloc <script setup>, tout ce qui est déclaré au niveau supérieur — variables réactives, fonctions, imports de composants — est automatiquement exposé au template, sans qu’il soit nécessaire de retourner un objet. Les composants importés sont enregistrés automatiquement. C’est le mode de développement recommandé par la documentation officielle de Vue 3 pour tous les nouveaux projets.

Réactivité typée#

ref<T>() et Ref<T>#

ref crée une valeur réactive encapsulée dans un objet dont la propriété .value contient la donnée :

import { ref } from 'vue';
import type { Ref } from 'vue';

// TypeScript infère Ref<number>
const compteur = ref(0);

// Annotation explicite quand la valeur initiale ne suffit pas
const utilisateur = ref<{ nom: string; email: string } | null>(null);

// Le type Ref<T> est utile pour annoter les props de fonctions
function doubler(val: Ref<number>): void {
  val.value *= 2;
}

reactive<T>() et ses limites#

reactive transforme un objet entier en réactif, sans enveloppe .value :

import { reactive } from 'vue';

interface FormulaireLogin {
  email: string;
  motDePasse: string;
  seRappelerDeMoi: boolean;
}

const formulaire = reactive<FormulaireLogin>({
  email: '',
  motDePasse: '',
  seRappelerDeMoi: false,
});

// Accès direct, sans .value
formulaire.email = 'alice@exemple.com';

Remarque 33

reactive a une limitation importante : il perd sa réactivité si on le déstructure ou si on le passe à une variable primitive. const { email } = formulaire crée une copie non réactive de email. Pour conserver la réactivité à travers la déstructuration, il faut utiliser toRefs(formulaire), qui convertit chaque propriété en Ref<T>. Dans la pratique, ref est souvent préféré à reactive pour sa prévisibilité.

computed<T>()#

computed est généralement inféré, mais on peut lui fournir un type explicite pour les valeurs calculées dont le type de retour est ambigu :

import { computed, ref } from 'vue';

const éléments = ref<string[]>(['pomme', 'banane', 'cerise']);

// Inféré : ComputedRef<number>
const nombre = computed(() => éléments.value.length);

// Annotation explicite pour un getter/setter
const premierÉlément = computed<string>({
  get: () => éléments.value[0] ?? '',
  set: (val) => { éléments.value[0] = val; },
});

Props et émissions#

defineProps<Props>()#

defineProps avec une signature de type générique est la méthode canonique pour déclarer les props dans <script setup> :

<script setup lang="ts">
interface Props {
  titre: string;
  éléments: string[];
  actif?: boolean;
}

const props = defineProps<Props>();
</script>

withDefaults#

defineProps<Props>() seul ne permet pas de spécifier des valeurs par défaut pour les props optionnelles. withDefaults comble ce manque :

<script setup lang="ts">
interface Props {
  titre: string;
  taille?: 'petite' | 'moyenne' | 'grande';
  chargement?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  taille: 'moyenne',
  chargement: false,
});
</script>

defineEmits<{...}>()#

Les émissions de composant sont déclarées avec defineEmits et une signature de type sous forme de type objet (syntaxe recommandée depuis Vue 3.3) :

<script setup lang="ts">
// Syntaxe objet (Vue 3.3+) : plus expressive, supporte plusieurs arguments
const emit = defineEmits<{
  changement: [valeur: string];
  soumission: [données: { email: string; motDePasse: string }];
  fermeture: [];
}>();

// Utilisation : TypeScript vérifie le nombre et le type des arguments
emit('changement', 'nouvelle valeur');
emit('fermeture');
</script>

Exemple 16 (Composant de formulaire complètement typé)

Un composant de saisie email illustre la combinaison de props et d’émissions typées :

<script setup lang="ts">
interface Props {
  modelValue: string;
  libellé?: string;
  erreur?: string;
}

const props = withDefaults(defineProps<Props>(), {
  libellé: 'Email',
});

const emit = defineEmits<{
  'update:modelValue': [valeur: string];
}>();

function handleInput(e: Event) {
  const cible = e.target as HTMLInputElement;
  emit('update:modelValue', cible.value);
}
</script>

<template>
  <div>
    <label>{{ libellé }}</label>
    <input type="email" :value="modelValue" @input="handleInput" />
    <span v-if="erreur">{{ erreur }}</span>
  </div>
</template>

Ce composant implémente le pattern v-model personnalisé : la prop modelValue reçoit la valeur, et l’émission update:modelValue la met à jour.


## Composants génériques

Depuis Vue 3.3, `<script setup>` accepte un attribut `generic` qui permet de déclarer des composants génériques, une fonctionnalité très attendue :

```vue
<script setup lang="ts" generic="T extends { id: number }">
interface Props {
  éléments: T[];
  renduÉlément: (élément: T) => string;
}

defineProps<Props>();
</script>

<template>
  <ul>
    <li v-for="élément in éléments" :key="élément.id">
      {{ renduÉlément(élément) }}
    </li>
  </ul>
</template>

Le type T est contraint pour avoir un id: number, ce qui permet d’utiliser élément.id comme clé dans le template. À l’usage, Vue infère automatiquement T depuis le tableau passé en prop.

Provide/Inject typé#

Le mécanisme provide/inject de Vue permet de partager des données entre un ancêtre et ses descendants sans passer par les props. Sans TypeScript, il est facile de se tromper sur le type de la valeur injectée. La solution passe par InjectionKey<T> :

// symboles.ts — fichier partagé entre le fournisseur et les consommateurs
import type { InjectionKey, Ref } from 'vue';

export interface ServiceAuthentification {
  estConnecté: Ref<boolean>;
  connecter: (email: string, motDePasse: string) => Promise<void>;
  déconnecter: () => void;
}

export const CléAuthentification: InjectionKey<ServiceAuthentification> =
  Symbol('authentification');
// Dans le composant fournisseur
import { provide, ref } from 'vue';
import { CléAuthentification } from './symboles';

const estConnecté = ref(false);

provide(CléAuthentification, {
  estConnecté,
  async connecter(email, motDePasse) {
    // Logique de connexion
    estConnecté.value = true;
  },
  déconnecter() {
    estConnecté.value = false;
  },
});
// Dans un composant descendant
import { inject } from 'vue';
import { CléAuthentification } from './symboles';

const auth = inject(CléAuthentification);
// auth est ServiceAuthentification | undefined
// On peut lever une erreur si absent :
if (!auth) throw new Error('CléAuthentification non fournie');
auth.connecter('alice@exemple.com', 'secret');

Remarque 34

L’utilisation de Symbol comme clé garantit que deux fournisseurs différents n’entrent jamais en collision, même s’ils utilisent accidentellement la même chaîne de caractères comme clé. InjectionKey<T> encode le type attendu directement dans le symbole, ce qui permet à TypeScript d’inférer correctement le type retourné par inject(clé).

Pinia avec TypeScript#

Pinia est le gestionnaire d’état officiel pour Vue 3. Il est conçu pour TypeScript dès le départ et offre une inférence de types quasi automatique.

defineStore avec l’API Options#

import { defineStore } from 'pinia';

interface ÉtatPanier {
  articles: Array<{ id: number; nom: string; prix: number; quantité: number }>;
  codePromo: string | null;
}

export const usePanierStore = defineStore('panier', {
  state: (): ÉtatPanier => ({
    articles: [],
    codePromo: null,
  }),
  getters: {
    total(état): number {
      return état.articles.reduce(
        (somme, article) => somme + article.prix * article.quantité,
        0
      );
    },
    nombreArticles(état): number {
      return état.articles.reduce((n, a) => n + a.quantité, 0);
    },
  },
  actions: {
    ajouterArticle(article: { id: number; nom: string; prix: number }) {
      const existant = this.articles.find(a => a.id === article.id);
      if (existant) {
        existant.quantité++;
      } else {
        this.articles.push({ ...article, quantité: 1 });
      }
    },
    appliquerPromo(code: string) {
      this.codePromo = code;
    },
  },
});

defineStore avec l’API Setup (recommandée)#

L’API Setup de Pinia tire pleinement parti de l’inférence de TypeScript et est plus flexible :

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const usePanierStore = defineStore('panier', () => {
  // État
  const articles = ref<Array<{ id: number; nom: string; prix: number; quantité: number }>>([]);
  const codePromo = ref<string | null>(null);

  // Getters
  const total = computed(() =>
    articles.value.reduce((s, a) => s + a.prix * a.quantité, 0)
  );

  // Actions
  function ajouterArticle(article: { id: number; nom: string; prix: number }) {
    const existant = articles.value.find(a => a.id === article.id);
    if (existant) {
      existant.quantité++;
    } else {
      articles.value.push({ ...article, quantité: 1 });
    }
  }

  return { articles, codePromo, total, ajouterArticle };
});

Hide code cell source

fig, ax = plt.subplots(figsize=(14, 9))
ax.set_xlim(-0.5, 14.5)
ax.set_ylim(-0.5, 10.0)
ax.axis('off')
ax.set_title(
    'Architecture Vue 3 + TypeScript : composants, Pinia et Provide/Inject',
    fontsize=14, fontweight='bold', pad=16
)

c_compo   = '#2980b9'
c_pinia   = '#e67e22'
c_inject  = '#27ae60'
c_arrow   = '#2c3e50'
c_back    = '#c0392b'
c_router  = '#8e44ad'

def boite(ax, x, y, w, h, couleur, titre, sous='', alpha=0.13):
    for fc, fa in [(couleur, alpha), ('none', 1)]:
        rect = patches.FancyBboxPatch(
            (x, y), w, h,
            boxstyle='round,pad=0.15', linewidth=2,
            edgecolor=couleur, facecolor=fc, alpha=fa
        )
        ax.add_patch(rect)
    ax.text(x + w / 2, y + h - 0.42, titre,
            ha='center', va='center', fontsize=10,
            fontweight='bold', color=couleur)
    if sous:
        ax.text(x + w / 2, y + 0.42, sous,
                ha='center', va='center', fontsize=7.5,
                color='#555555', style='italic')

def fleche(ax, x1, y1, x2, y2, couleur, label='', tiret=False, label_offset=(0,0)):
    style = 'dashed' if tiret else 'solid'
    ax.annotate('',
        xy=(x2, y2), xytext=(x1, y1),
        arrowprops=dict(arrowstyle='->', color=couleur, lw=1.8,
                        linestyle=style))
    if label:
        mx = (x1 + x2) / 2 + label_offset[0]
        my = (y1 + y2) / 2 + label_offset[1]
        ax.text(mx, my, label, ha='center', va='center',
                fontsize=7.5, color=couleur, fontweight='bold',
                bbox=dict(boxstyle='round,pad=0.18', facecolor='white',
                          edgecolor=couleur, alpha=0.88))

# Composant racine App.vue
boite(ax, 4.5, 7.8, 5.5, 1.8, c_compo,
      'App.vue (racine)',
      'provide(CléAuth, service) · <RouterView />')

# Composant page
boite(ax, 0.5, 4.8, 5.5, 2.2, c_compo,
      'PageAccueil.vue',
      'inject(CléAuth) · usePanierStore()')

# Composant enfant
boite(ax, 0.5, 1.5, 5.5, 2.5, c_compo,
      'CarteProduit.vue',
      'defineProps<Props>() · defineEmits<{...}>()')

# Store Pinia
boite(ax, 8.0, 4.8, 5.5, 2.2, c_pinia,
      'usePanierStore (Pinia)',
      'ref<Article[]> · computed total · actions typées')

# Provide/Inject key
boite(ax, 8.0, 1.5, 5.5, 2.5, c_inject,
      'InjectionKey<ServiceAuth>',
      'Symbol(\'auth\') · Ref<boolean> · connecter()')

# Flèches App → Page (provide)
fleche(ax, 7.25, 8.4, 3.75, 7.0,
       c_inject, 'provide(clé, valeur)', label_offset=(-0.5, 0.3))

# Flèche App → Store (accès direct)
fleche(ax, 9.75, 7.8, 9.75, 7.0,
       c_pinia, 'usePanierStore()')

# Flèche Page → Composant enfant (props)
fleche(ax, 3.25, 4.8, 3.25, 4.0,
       c_compo, 'props typées', label_offset=(1.0, 0))

# Flèche Composant enfant → Page (emit)
fleche(ax, 2.0, 4.0, 2.0, 4.8,
       c_back, 'emit typé', tiret=True, label_offset=(-1.2, 0))

# Flèche Page → Store
fleche(ax, 6.0, 5.8, 8.0, 5.8,
       c_pinia, 'dispatch action')

# Flèche Store → Page (état réactif)
fleche(ax, 8.0, 5.3, 6.0, 5.3,
       c_pinia, 'état réactif', tiret=True)

# Flèche Page → InjectionKey
fleche(ax, 3.25, 4.8, 9.5, 4.0,
       c_inject, 'inject(CléAuth)', label_offset=(0, 0.3))

# Flèches InjectionKey → retour
fleche(ax, 10.0, 1.5, 10.0, 0.7,
       c_inject, 'TypeScript infère\nServiceAuth', label_offset=(2.2, 0))

plt.tight_layout()
plt.show()
_images/15a47dcd88082a7b34b1497578caf58e37bf23fd0e58dcd914dddcd2d280634c.png

Résumé#

Ce chapitre a présenté l’intégration de TypeScript dans un projet Vue 3 :

  • Mise en place : Vite + Vue 3 + TypeScript est la stack standard ; vue-tsc --noEmit vérifie les types des fichiers .vue en CI ; verbatimModuleSyntax encourage la distinction claire des imports de types.

  • Composition API : <script setup lang="ts"> est l’approche recommandée, réduisant le code répétitif et offrant une meilleure inférence. defineComponent reste utile pour les cas spéciaux.

  • Réactivité typée : ref<T>() s’infère bien pour les primitives ; reactive<T>() est pratique pour les objets mais perd la réactivité à la déstructuration. computed est quasi toujours inféré automatiquement.

  • Props et émissions : defineProps<Props>() et withDefaults séparent la déclaration de type des valeurs par défaut ; defineEmits<{...}>() type précisément les événements émis.

  • Composants génériques : l’attribut generic="T" de <script setup> (Vue 3.3+) permet des composants réutilisables et typés.

  • Provide/Inject : InjectionKey<T> encode le type dans le symbole, garantissant que les consommateurs obtiennent le bon type sans assertion.

  • Pinia : l’API Setup de Pinia offre la meilleure expérience TypeScript, avec une inférence complète des états, getters et actions.

Le chapitre suivant se concentre sur la qualité du code : ESLint, Prettier, tests avec Vitest, options strictes du compilateur et validation à l’exécution avec Zod.