Vue 3 + TypeScript : typer ses composants

Front-end 08/04/2026 13:00:00 angularforall.com
Vue 3 Typescript Props Defineprops Defineemits
Vue 3 + TypeScript : typer ses composants

Apprenez à typer vos composants Vue 3 avec TypeScript : props, emits, ref, events DOM, generics et erreurs fréquentes expliquées avec solutions.

Pourquoi TypeScript avec Vue 3 ?

Vue 3 a été entièrement réécrit en TypeScript. Ce n'est pas un détail : cela signifie que TypeScript est un citoyen de première classe dans Vue 3, avec une inférence de types complète, des erreurs détectées à la compilation et une autocomplétion excellente dans les éditeurs. Pour un développeur junior, apprendre Vue 3 directement avec TypeScript évite de mauvaises habitudes et prépare au travail en équipe.

Ce que TypeScript apporte concrètement

<!-- ❌ Sans TypeScript — erreur silencieuse en production -->
<script setup>
const props = defineProps(['user', 'onSelect']); // Types inconnus

// Vue accepte n'importe quoi — pas de vérification
// Si le parent passe un number au lieu d'un object, erreur runtime
</script>
<!-- ✅ Avec TypeScript — erreur détectée à la compilation -->
<script setup lang="ts">
interface User {
  id: number;
  name: string;
  email: string;
}

const props = defineProps<{
  user: User;           // TypeScript sait exactement ce qu'il attend
  onSelect?: () => void;
}>();

// Si le parent passe { id: 'abc', name: 42 } → erreur TypeScript immédiate
// L'éditeur propose l'autocomplétion sur props.user.name etc.
</script>

Bénéfices mesurables

Bénéfice Sans TypeScript Avec TypeScript
Bugs props incorrectes Runtime (production) Compilation (dev)
Autocomplétion IDE Limitée Complète (props, emits, store…)
Refactoring Manuel et risqué Sûr (TS détecte les usages cassés)
Documentation implicite JSDoc manuel Types = documentation vivante
Onboarding équipe Lire le code pour comprendre Types expliquent l'intention
Outil indispensable : Installez l'extension Volar (Vue - Official) dans VS Code. Elle fournit la vérification de types dans les templates Vue, l'autocomplétion des props et emits, et le goto-definition depuis le template vers le script.

Configuration TypeScript

# Créer un projet Vue 3 avec TypeScript (recommandé)
npm create vue@latest mon-projet
# → Sélectionner "TypeScript : Yes"
# → Sélectionner "Vue Router : Yes" (optionnel)
# → Sélectionner "Pinia : Yes" (optionnel)

tsconfig.json recommandé pour Vue 3

// tsconfig.json — Configuration optimale Vue 3 + TypeScript
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "moduleResolution": "bundler",

    // Strict mode — active toutes les vérifications strictes
    // Obligatoire pour bénéficier vraiment de TypeScript
    "strict": true,

    // Permet d'importer les fichiers .vue depuis TypeScript
    "jsx": "preserve",

    // Alias d'import — évite les chemins relatifs profonds
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]   // import '@/components/Foo.vue' au lieu de '../../..'
    },

    // Interopérabilité avec les modules CommonJS (lodash, etc.)
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,

    // Vérifications supplémentaires recommandées
    "noUnusedLocals": true,      // Erreur si variable locale inutilisée
    "noUnusedParameters": false, // Trop strict pour les callbacks
    "noImplicitReturns": true,   // Toutes les branches doivent retourner
  },
  "include": ["src/**/*", "env.d.ts"],
  "references": [{ "path": "./tsconfig.node.json" }]
}
// env.d.ts — Déclarations de types globaux
/// <reference types="vite/client" />

// Permet d'importer les .vue comme des modules TypeScript
declare module '*.vue' {
  import type { DefineComponent } from 'vue';
  const component: DefineComponent<{}, {}, unknown>;
  export default component;
}

// Variables d'environnement typées (import.meta.env)
interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_APP_TITLE: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

Typer les props avec defineProps

Les props sont le contrat entre un composant parent et son enfant. Bien les typer est la priorité numéro un : cela documente l'interface du composant et prévient les bugs de communication.

Syntaxe de base avec interface

<!-- components/UserCard.vue -->
<script setup lang="ts">

// Définit le contrat du composant — ce qu'il attend du parent
interface Props {
  // Props obligatoires
  userId: number;
  name: string;

  // Props optionnelles (le ? indique qu'elles peuvent être absentes)
  avatar?: string;
  role?: 'admin' | 'user' | 'moderator'; // Union de littéraux
  tags?: string[];
  metadata?: Record<string, unknown>;   // Objet clé/valeur ouvert

  // Callbacks typés
  onSelect?: (userId: number) => void;
  onDelete?: () => Promise<void>;
}

// defineProps<Interface>() infère les types depuis l'interface TypeScript
const props = defineProps<Props>();

// withDefaults() pour les valeurs par défaut des props optionnelles
// Obligatoire dès qu'une prop optionnelle a une valeur par défaut
const propsWithDefaults = withDefaults(defineProps<Props>(), {
  role: 'user',           // Valeur par défaut scalaire
  tags: () => [],         // TOUJOURS une fonction pour les tableaux/objets
  metadata: () => ({}),  // Évite le partage d'état entre instances
  avatar: '/default-avatar.png',
});
</script>

<template>
  <!-- TypeScript valide les accès aux props dans le template -->
  <div class="user-card">
    <img :src="avatar ?? '/default.png'" :alt="name" />
    <h3>{{ name }}</h3>
    <span :class="`role-${role}`">{{ role }}</span>
    <button @click="onSelect?.(userId)">Sélectionner</button>
  </div>
</template>

Props avec objets imbriqués

<script setup lang="ts">
// Interfaces réutilisables — définies dans src/types/index.ts
interface Address {
  street: string;
  city: string;
  zip: string;
  country: string;
}

interface User {
  id: number;
  firstName: string;
  lastName: string;
  email: string;
  address?: Address;        // Objet imbriqué optionnel
  createdAt: Date | string; // Union de types
}

interface Props {
  user: User;
  showAddress?: boolean;
  highlightField?: keyof User; // keyof = clé valide de User
}

const props = defineProps<Props>();

// TypeScript infère que props.user est de type User
// props.user.firstName → autocomplétion ✅
// props.user.unknownField → erreur TypeScript ✅
</script>

Props booléennes : raccourcis Vue

<script setup lang="ts">
interface Props {
  disabled?: boolean;
  loading?: boolean;
  fullWidth?: boolean;
}

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

<!-- Dans le parent : -->
<!-- <MyButton disabled /> équivaut à <MyButton :disabled="true" /> -->
<!-- <MyButton /> équivaut à <MyButton :disabled="false" /> (valeur par défaut) -->

Typer les emits avec defineEmits

Les emits définissent les événements qu'un composant peut déclencher vers son parent. Les typer garantit que le parent gère les bons arguments et que TypeScript détecte les incohérences dans les deux sens.

<!-- components/DataTable.vue -->
<script setup lang="ts">
import { ref } from 'vue';

interface Row {
  id: number;
  [key: string]: unknown;
}

// defineEmits avec syntaxe TypeScript — la plus précise
const emit = defineEmits<{
  // Syntaxe : nomEvenement: [arg1Type, arg2Type, ...]
  select:    [row: Row];                         // Un seul objet Row
  delete:    [id: number];                       // Un number
  bulkDelete:[ids: number[]];                    // Tableau de numbers
  sort:      [field: string, direction: 'asc' | 'direction']; // 2 args typés
  pageChange:[page: number, perPage: number];    // 2 numbers
  update:    [id: number, data: Partial<Row>];   // Mise à jour partielle
  close:     [];                                 // Aucun argument
}>();

const selectedRows = ref<Row[]>([]);

// TypeScript vérifie que les arguments matchent les types déclarés
const handleRowClick = (row: Row) => {
  emit('select', row);            // ✅ row est bien un Row
  // emit('select', 42);          // ❌ Erreur TypeScript : 42 n'est pas Row
  // emit('select', row, 'extra');// ❌ Trop d'arguments
};

const handleSort = (field: string) => {
  emit('sort', field, 'asc');     // ✅ Les deux arguments sont corrects
};

const handleBulkDelete = () => {
  const ids = selectedRows.value.map(r => r.id as number);
  emit('bulkDelete', ids);        // ✅ ids est un number[]
};
</script>
<!-- Dans le parent — TypeScript valide les handlers -->
<script setup lang="ts">
// Le handler est typé automatiquement depuis les emits du composant enfant
const handleSelect = (row: Row) => {     // TypeScript infère le type ✅
  console.log('Sélectionné :', row.id);
};

const handleSort = (field: string, direction: 'asc' | 'direction') => {
  // TypeScript infère direction comme union littérale ✅
};
</script>

<template>
  <DataTable
    @select="handleSelect"
    @sort="handleSort"
    @close="modalOpen = false"
  />
</template>

Typer ref(), reactive() et computed()

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

// --- ref() ---
// Inférence automatique quand la valeur initiale est claire
const count   = ref(0);        // Ref<number> — inféré
const name    = ref('Alice');  // Ref<string> — inféré
const active  = ref(true);    // Ref<boolean> — inféré

// Type explicite nécessaire pour null, unions, tableaux vides
interface Article { id: number; title: string; published: boolean; }

const article    = ref<Article | null>(null);  // Commence null
const articles   = ref<Article[]>([]);          // Tableau vide typé
const status     = ref<'idle' | 'loading' | 'error' | 'success'>('idle');
const ids        = ref<Set<number>>(new Set()); // Types complexes

// Après assignation, TypeScript sait que article peut être Article ou null
if (article.value !== null) {
  article.value.title; // ✅ TypeScript sait que c'est un Article ici
}

// --- reactive() ---
// L'interface peut être inline ou importée
interface FormState {
  email: string;
  password: string;
  confirmPassword: string;
  agreeToTerms: boolean;
}

const form = reactive<FormState>({
  email: '',
  password: '',
  confirmPassword: '',
  agreeToTerms: false,
});

// form.email → string ✅
// form.unknownField → erreur TypeScript ✅

// --- computed() ---
// TypeScript infère le type de retour depuis la fonction
const fullName = computed(() => `${name.value} Dupont`); // ComputedRef<string>

// Type explicite si nécessaire (rare avec Composition API)
const formattedArticles = computed<string[]>(() =>
  articles.value.map(a => `[${a.id}] ${a.title}`)
);
</script>

Typer les template refs (accès au DOM)

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

// Template ref typée — doit correspondre au type de l'élément HTML cible
const inputRef   = ref<HTMLInputElement | null>(null);
const formRef    = ref<HTMLFormElement | null>(null);
const canvasRef  = ref<HTMLCanvasElement | null>(null);
const buttonRef  = ref<HTMLButtonElement | null>(null);

// Typer une ref vers un composant enfant
import MyModal from './MyModal.vue';
const modalRef = ref<InstanceType<typeof MyModal> | null>(null);
// InstanceType<typeof Composant> donne le type de l'instance exposée

onMounted(() => {
  // Après montage, l'élément existe — vérification null nécessaire
  if (inputRef.value) {
    inputRef.value.focus(); // ✅ HTMLInputElement — focus() disponible
    inputRef.value.select(); // ✅ Méthodes input disponibles
  }

  // Appel d'une méthode exposée par le composant enfant
  if (modalRef.value) {
    modalRef.value.open(); // ✅ Si MyModal expose open() via defineExpose
  }
});
</script>

<template>
  <!-- ref="inputRef" connecte l'élément DOM à la ref TypeScript -->
  <input ref="inputRef" type="text" placeholder="Focus automatique" />
  <MyModal ref="modalRef" />
</template>

Typer les événements du DOM

Les handlers d'événements reçoivent des objets Event natifs du DOM. TypeScript dispose de types précis pour chaque événement — les utiliser donne accès aux propriétés spécifiques (event.target.value pour un input, etc.).

<script setup lang="ts">
// --- Événements de formulaire ---
const handleInput = (event: Event) => {
  // event.target est HTMLElement — pas d'accès à .value directement
  const target = event.target as HTMLInputElement; // Cast nécessaire
  console.log(target.value); // string ✅
};

// Mieux : utiliser InputEvent pour les inputs texte
const handleTextInput = (event: InputEvent) => {
  const input = event.target as HTMLInputElement;
  console.log(input.value, input.selectionStart);
};

// Soumission de formulaire
const handleSubmit = (event: SubmitEvent) => {
  event.preventDefault(); // SubmitEvent a preventDefault() ✅
  const form = event.target as HTMLFormElement;
  const data = new FormData(form); // FormData depuis l'élément form
};

// --- Événements clavier ---
const handleKeydown = (event: KeyboardEvent) => {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();
    // Soumettre le formulaire...
  }
  if (event.ctrlKey && event.key === 's') {
    // Ctrl+S — sauvegarder...
  }
};

// --- Événements souris ---
const handleClick = (event: MouseEvent) => {
  console.log(event.clientX, event.clientY); // Coordonnées ✅
  console.log(event.button); // 0=gauche, 1=molette, 2=droite
};

// --- Événements drag & drop ---
const handleDrop = (event: DragEvent) => {
  event.preventDefault();
  const files = event.dataTransfer?.files; // FileList | undefined
  if (files?.[0]) {
    console.log(files[0].name, files[0].size);
  }
};

// --- v-model et les inputs Vue ---
// Pour un composant avec v-model, le handler reçoit la valeur directe
const handleModelUpdate = (value: string) => {
  // Pas d'Event ici — Vue passe la valeur directement
  console.log(value);
};
</script>

<template>
  <form @submit="handleSubmit" @keydown="handleKeydown">
    <input @input="handleInput" type="text" />
    <button @click="handleClick" @drop.prevent="handleDrop">
      Soumettre
    </button>
  </form>
</template>
Cast vs type guard : Le cast as HTMLInputElement est parfois inévitable car Vue passe Event générique. Une alternative plus sûre est le type guard : if (event.target instanceof HTMLInputElement) — TypeScript affine le type automatiquement sans cast explicite.

Composants génériques avec TypeScript

Vue 3.3+ supporte les composants génériques : un composant qui accepte un type paramétrable, comme les composants de liste ou de sélection. C'est une fonctionnalité avancée mais très puissante pour les composants réutilisables.

<!-- components/SelectInput.vue — Composant générique -->
<script setup lang="ts" generic="T extends { id: number; label: string }">
// "generic" est une syntaxe Vue 3.3+ — définit le paramètre de type T
// T doit au minimum avoir id: number et label: string

interface Props {
  options: T[];            // Tableau du type générique
  modelValue: T | null;   // Valeur sélectionnée (v-model)
  placeholder?: string;
}

const props = defineProps<Props>();

const emit = defineEmits<{
  'update:modelValue': [value: T | null]; // Pour v-model
}>();

const handleChange = (event: Event) => {
  const id = Number((event.target as HTMLSelectElement).value);
  const selected = props.options.find(o => o.id === id) ?? null;
  emit('update:modelValue', selected); // T | null
};
</script>

<template>
  <select @change="handleChange" :value="modelValue?.id ?? ''">
    <option value="">{{ placeholder ?? 'Sélectionner...' }}</option>
    <option v-for="opt in options" :key="opt.id" :value="opt.id">
      {{ opt.label }}
    </option>
  </select>
</template>
<!-- Utilisation avec des types concrets -->
<script setup lang="ts">
import { ref } from 'vue';
import SelectInput from '@/components/SelectInput.vue';

interface Country { id: number; label: string; code: string; }
interface Category { id: number; label: string; slug: string; }

const countries: Country[] = [
  { id: 1, label: 'France',   code: 'FR' },
  { id: 2, label: 'Belgique', code: 'BE' },
];

const categories: Category[] = [
  { id: 1, label: 'Frontend', slug: 'frontend' },
  { id: 2, label: 'Backend',  slug: 'backend' },
];

const selectedCountry  = ref<Country | null>(null);
const selectedCategory = ref<Category | null>(null);
</script>

<template>
  <!-- TypeScript sait que selectedCountry est Country | null ✅ -->
  <SelectInput v-model="selectedCountry" :options="countries" />
  <SelectInput v-model="selectedCategory" :options="categories" />
  <p v-if="selectedCountry">Code : {{ selectedCountry.code }}</p>
</template>

defineExpose : exposer des méthodes au parent

<!-- components/VideoPlayer.vue -->
<script setup lang="ts">
import { ref } from 'vue';

const videoRef  = ref<HTMLVideoElement | null>(null);
const isPlaying = ref(false);

const play = () => {
  videoRef.value?.play();
  isPlaying.value = true;
};

const pause = () => {
  videoRef.value?.pause();
  isPlaying.value = false;
};

const seekTo = (seconds: number) => {
  if (videoRef.value) videoRef.value.currentTime = seconds;
};

// defineExpose rend ces méthodes accessibles au parent via template ref
// Sans defineExpose, le parent ne peut rien appeler sur le composant
defineExpose({ play, pause, seekTo, isPlaying });
</script>
<!-- Parent qui utilise les méthodes exposées -->
<script setup lang="ts">
import { ref } from 'vue';
import VideoPlayer from './VideoPlayer.vue';

// InstanceType<typeof VideoPlayer> donne le type des méthodes exposées
const playerRef = ref<InstanceType<typeof VideoPlayer> | null>(null);

const handlePlay = () => {
  playerRef.value?.play(); // ✅ TypeScript connaît la méthode play()
  playerRef.value?.seekTo(30); // ✅ Type: (seconds: number) => void
};
</script>

Erreurs fréquentes et solutions

Erreur 1 : Type 'null' is not assignable to type 'string'

// ❌ Problème : ref initialisé à null sans type explicite
const user = ref(null); // Inféré comme Ref<null> — ne peut plus changer !
user.value = { name: 'Alice' }; // ❌ Erreur TypeScript

// ✅ Solution : typer explicitement avec l'union
const user = ref<{ name: string } | null>(null);
user.value = { name: 'Alice' }; // ✅

Erreur 2 : Property does not exist on type 'never'

// ❌ Problème : accès sans vérification de null
const user = ref<User | null>(null);
console.log(user.value.name); // ❌ user.value pourrait être null !

// ✅ Solution 1 : optional chaining
console.log(user.value?.name); // string | undefined

// ✅ Solution 2 : narrowing (type guard)
if (user.value !== null) {
  console.log(user.value.name); // ✅ TypeScript sait que c'est User
}

// ✅ Solution 3 : non-null assertion (à utiliser avec parcimonie)
console.log(user.value!.name); // Force TS à ignorer le null — risqué

Erreur 3 : Argument of type X is not assignable to parameter

// ❌ Problème : event.target mal typé
const handleInput = (event: Event) => {
  console.log(event.target.value); // ❌ .value n'existe pas sur EventTarget
};

// ✅ Solution : type guard avec instanceof
const handleInput = (event: Event) => {
  if (event.target instanceof HTMLInputElement) {
    console.log(event.target.value); // ✅ TypeScript sait que c'est HTMLInputElement
  }
};

Erreur 4 : Type 'string' is not assignable to union

// ❌ Problème : valeur reçue de l'API non validée
const status = ref<'active' | 'inactive' | 'pending'>('active');
const apiData = await fetch('/api/user').then(r => r.json());
status.value = apiData.status; // ❌ apiData.status est 'any' ou 'string'

// ✅ Solution 1 : assertion de type (si vous faites confiance à l'API)
status.value = apiData.status as 'active' | 'inactive' | 'pending';

// ✅ Solution 2 : validation runtime avec type guard
const isValidStatus = (s: string): s is 'active' | 'inactive' | 'pending' =>
  ['active', 'inactive', 'pending'].includes(s);

if (isValidStatus(apiData.status)) {
  status.value = apiData.status; // ✅ Type affiné par le type guard
}
Conseil général : Évitez as (cast) autant que possible — c'est désactiver TypeScript localement. Préférez les type guards avec instanceof ou des fonctions prédicates (is). Si vous utilisez beaucoup de casts, c'est souvent le signe d'une architecture à revoir.

Checklist Vue 3 + TypeScript

Configuration

  • lang="ts" sur tous les <script setup>
  • Extension Volar (Vue - Official) installée dans VS Code
  • "strict": true dans tsconfig.json
  • Alias @/* configuré pour les imports propres
  • Types partagés dans src/types/index.ts

Props et emits

  • defineProps<Interface>() pour toutes les props
  • withDefaults() pour les valeurs par défaut (fonction pour objets/tableaux)
  • defineEmits<{...}>() avec les types de chaque argument
  • defineExpose() pour les méthodes accessibles depuis le parent

Réactivité et événements

  • Type explicite sur ref<T | null>(null) et tableaux vides
  • InstanceType<typeof Composant> pour les template refs
  • Type guard instanceof pour les events DOM
  • Éviter les casts as — préférer les type guards
  • Type guard personnalisé (is) pour valider les données API
Ressources : Le guide TypeScript officiel Vue 3 couvre tous les cas d'usage. Le cheatsheet TypeScript est aussi un compagnon utile à garder sous la main.

TypeScript transforme Vue 3 d'un framework agréable en un environnement de développement de premier ordre. La combinaison Vue 3 + TypeScript + Volar représente aujourd'hui l'une des meilleures expériences de développement frontend : les erreurs sont détectées avant même d'ouvrir le navigateur, le refactoring devient sûr, et chaque composant est auto-documenté par ses types. L'investissement initial dans la configuration et l'apprentissage se rentabilise très rapidement sur tout projet de taille réelle.

Partager