Table of Contents
Introduction
La gestion d’état est au coeur de toute application Vue.js moderne. Que vous utilisiez un store personnalisé avec reactive(), Vuex ou Pinia, le typage de votre état avec TypeScript transforme radicalement la qualité et la maintenabilité de votre code. Sans typage, les erreurs se manifestent à l’exécution, souvent en production. Avec TypeScript, ces erreurs sont détectées dès la compilation, vous offrant une confiance absolue dans la structure de vos données.
Dans cet article, nous allons explorer en profondeur comment définir et typer l’état de votre store Vue avec TypeScript. Nous couvrirons les interfaces d’état, les génériques reactive<T>(), les getters et mutations typés, et l’intégration avec Pinia, le successeur officiel de Vuex.
Pourquoi typer votre store ?
Le typage du store apporte des avantages considérables :
- Autocomplétion IDE : Votre éditeur suggère automatiquement les propriétés disponibles
- Détection d’erreurs précoce : Les fautes de frappe et erreurs de type sont signalées instantanément
- Refactoring sécurisé : Renommer une propriété met à jour toutes les références
- Documentation intégrée : Les interfaces servent de documentation vivante
- Collaboration facilitée : Les nouveaux développeurs comprennent rapidement la structure des données
Les interfaces d’état (State Interface)
La première étape pour typer votre store consiste à définir des interfaces qui décrivent précisément la structure de vos données. Commençons par un exemple concret : un store e-commerce.
Définition des entités
// types/store.ts
// Interface pour un produit
interface Product {
id: string;
name: string;
description: string;
price: number;
image: string;
category: string;
stock: number;
rating: number;
reviews: Review[];
}
// Interface pour un avis
interface Review {
id: string;
userId: string;
userName: string;
rating: number;
comment: string;
createdAt: Date;
}
// Interface pour un article du panier
interface CartItem {
product: Product;
quantity: number;
addedAt: Date;
}
// Interface pour l'utilisateur connecté
interface User {
id: string;
email: string;
name: string;
avatar?: string;
role: 'customer' | 'admin';
createdAt: Date;
}
// Interface principale de l'état
interface StoreState {
// Produits
products: Product[];
selectedProduct: Product | null;
// Panier
cart: CartItem[];
// Utilisateur
user: User | null;
isAuthenticated: boolean;
// UI State
loading: boolean;
error: string | null;
// Filtres
filters: {
category: string | null;
priceRange: [number, number];
sortBy: 'price' | 'rating' | 'name';
sortOrder: 'asc' | 'desc';
};
}
Interfaces pour les locations (exemple original)
Reprenons l’exemple initial avec une interface pour des annonces de location :
interface Listing {
id: string;
title: string;
description: string;
image: string;
address: string;
price: number;
numOfGuests: number;
numOfBeds: number;
numOfBaths: number;
rating: number;
}
interface ListingState {
listings: Listing[];
selectedListing: Listing | null;
loading: boolean;
error: string | null;
}
Ces interfaces documentent clairement la structure attendue de chaque entité et permettent à TypeScript de valider toutes les opérations sur ces données.
Typage avec reactive<T>()
La fonction reactive() de Vue 3 est générique et accepte un paramètre de type. Ce paramètre définit la forme exacte de l’objet réactif créé.
Syntaxe de base
import { reactive } from 'vue';
// Sans typage (à éviter)
const stateUntyped = reactive({
products: [],
loading: false,
});
// Avec typage explicite (recommandé)
const state = reactive<StoreState>({
products: [],
selectedProduct: null,
cart: [],
user: null,
isAuthenticated: false,
loading: false,
error: null,
filters: {
category: null,
priceRange: [0, 1000],
sortBy: 'name',
sortOrder: 'asc',
},
});
Typage inféré vs explicite
TypeScript peut inférer le type à partir de la valeur initiale, mais cette approche a des limites :
// Inférence : TypeScript déduit le type
const stateInferred = reactive({
products: [] as Product[], // Nécessaire pour les tableaux vides
loading: false,
});
// Explicite : Plus clair et plus sûr
const stateExplicit = reactive<{ products: Product[]; loading: boolean }>({
products: [],
loading: false,
});
L’approche explicite avec une interface séparée est préférable car elle :
- Centralise les définitions de types
- Facilite la réutilisation
- Améliore la lisibilité
Store complet typé
// store/index.ts
import { reactive, readonly } from 'vue';
// Export des interfaces pour utilisation externe
export interface Product {
id: string;
name: string;
price: number;
stock: number;
}
export interface CartItem {
product: Product;
quantity: number;
}
export interface State {
products: Product[];
cart: CartItem[];
loading: boolean;
error: string | null;
}
// État privé
const state = reactive<State>({
products: [],
cart: [],
loading: false,
error: null,
});
// Export état en lecture seule
export const useStore = () => ({
state: readonly(state),
// ... mutations et actions
});
Getters typés
Les getters sont des propriétés calculées qui dérivent des valeurs de l’état. Avec TypeScript, nous pouvons typer explicitement leurs valeurs de retour.
Définition des getters
// store/getters.ts
import { computed, ComputedRef } from 'vue';
interface Getters {
// Nombre total d'articles dans le panier
cartItemCount: ComputedRef<number>;
// Total du panier
cartTotal: ComputedRef<number>;
// Panier vide ?
isCartEmpty: ComputedRef<boolean>;
// Produits filtrés
filteredProducts: ComputedRef<Product[]>;
// Produit par ID
getProductById: (id: string) => Product | undefined;
}
const createGetters = (state: State): Getters => ({
cartItemCount: computed(() =>
state.cart.reduce((total, item) => total + item.quantity, 0)
),
cartTotal: computed(() =>
state.cart.reduce(
(total, item) => total + item.product.price * item.quantity,
0
)
),
isCartEmpty: computed(() => state.cart.length === 0),
filteredProducts: computed(() => {
let products = [...state.products];
// Filtrer par catégorie
if (state.filters.category) {
products = products.filter(
p => p.category === state.filters.category
);
}
// Filtrer par prix
products = products.filter(
p => p.price >= state.filters.priceRange[0] &&
p.price <= state.filters.priceRange[1]
);
// Trier
products.sort((a, b) => {
const order = state.filters.sortOrder === 'asc' ? 1 : -1;
if (state.filters.sortBy === 'price') {
return (a.price - b.price) * order;
}
if (state.filters.sortBy === 'rating') {
return (a.rating - b.rating) * order;
}
return a.name.localeCompare(b.name) * order;
});
return products;
}),
getProductById: (id: string) =>
state.products.find(p => p.id === id),
});
Utilisation des getters dans les composants
<script setup lang="ts">
import { useStore } from '@/store';
const { state, getters } = useStore();
// TypeScript connaît les types
const total = getters.cartTotal; // ComputedRef<number>
const count = getters.cartItemCount; // ComputedRef<number>
</script>
<template>
<div class="cart-summary">
<span>{{ count }} articles</span>
<span>Total: {{ total.toFixed(2) }} EUR</span>
</div>
</template>
Actions et mutations typées
Les mutations modifient l’état de manière synchrone, tandis que les actions gèrent la logique asynchrone. Les deux doivent être correctement typées.
Mutations typées
// store/mutations.ts
interface Mutations {
setProducts: (products: Product[]) => void;
addToCart: (product: Product, quantity?: number) => void;
removeFromCart: (productId: string) => void;
updateCartQuantity: (productId: string, quantity: number) => void;
clearCart: () => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
setUser: (user: User | null) => void;
setFilters: (filters: Partial<State['filters']>) => void;
}
const createMutations = (state: State): Mutations => ({
setProducts: (products) => {
state.products = products;
},
addToCart: (product, quantity = 1) => {
const existingItem = state.cart.find(
item => item.product.id === product.id
);
if (existingItem) {
existingItem.quantity += quantity;
} else {
state.cart.push({
product,
quantity,
addedAt: new Date(),
});
}
},
removeFromCart: (productId) => {
const index = state.cart.findIndex(
item => item.product.id === productId
);
if (index !== -1) {
state.cart.splice(index, 1);
}
},
updateCartQuantity: (productId, quantity) => {
const item = state.cart.find(
item => item.product.id === productId
);
if (item) {
if (quantity <= 0) {
mutations.removeFromCart(productId);
} else {
item.quantity = quantity;
}
}
},
clearCart: () => {
state.cart = [];
},
setLoading: (loading) => {
state.loading = loading;
},
setError: (error) => {
state.error = error;
},
setUser: (user) => {
state.user = user;
state.isAuthenticated = user !== null;
},
setFilters: (filters) => {
state.filters = { ...state.filters, ...filters };
},
});
Actions typées
// store/actions.ts
interface Actions {
fetchProducts: () => Promise<void>;
fetchProductById: (id: string) => Promise<Product | null>;
checkout: () => Promise<{ success: boolean; orderId?: string }>;
login: (email: string, password: string) => Promise<boolean>;
logout: () => Promise<void>;
}
const createActions = (
state: State,
mutations: Mutations,
getters: Getters
): Actions => ({
fetchProducts: async () => {
mutations.setLoading(true);
mutations.setError(null);
try {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Erreur lors du chargement des produits');
}
const products: Product[] = await response.json();
mutations.setProducts(products);
} catch (error) {
mutations.setError(
error instanceof Error ? error.message : 'Erreur inconnue'
);
} finally {
mutations.setLoading(false);
}
},
fetchProductById: async (id) => {
mutations.setLoading(true);
try {
const response = await fetch(`/api/products/${id}`);
if (!response.ok) {
return null;
}
const product: Product = await response.json();
state.selectedProduct = product;
return product;
} catch (error) {
mutations.setError('Erreur lors du chargement du produit');
return null;
} finally {
mutations.setLoading(false);
}
},
checkout: async () => {
if (getters.isCartEmpty.value) {
return { success: false };
}
mutations.setLoading(true);
try {
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
items: state.cart,
total: getters.cartTotal.value,
}),
});
if (!response.ok) {
throw new Error('Erreur lors de la commande');
}
const { orderId } = await response.json();
mutations.clearCart();
return { success: true, orderId };
} catch (error) {
mutations.setError('Erreur lors du paiement');
return { success: false };
} finally {
mutations.setLoading(false);
}
},
login: async (email, password) => {
mutations.setLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Identifiants invalides');
}
const user: User = await response.json();
mutations.setUser(user);
return true;
} catch (error) {
mutations.setError('Erreur de connexion');
return false;
} finally {
mutations.setLoading(false);
}
},
logout: async () => {
await fetch('/api/auth/logout', { method: 'POST' });
mutations.setUser(null);
mutations.clearCart();
},
});
Intégration avec Pinia
Pinia est le gestionnaire d’état officiel recommandé pour Vue 3. Il offre une excellente intégration TypeScript native.
Installation
npm install pinia
Store Pinia typé
// stores/product.ts
import { defineStore } from 'pinia';
interface Product {
id: string;
name: string;
price: number;
stock: number;
category: string;
}
interface ProductState {
products: Product[];
selectedProduct: Product | null;
loading: boolean;
error: string | null;
}
export const useProductStore = defineStore('product', {
state: (): ProductState => ({
products: [],
selectedProduct: null,
loading: false,
error: null,
}),
getters: {
// TypeScript infère le type de retour
productCount: (state) => state.products.length,
// Type explicite pour les getters complexes
getProductById: (state) => {
return (id: string): Product | undefined =>
state.products.find(p => p.id === id);
},
productsByCategory: (state) => {
return (category: string): Product[] =>
state.products.filter(p => p.category === category);
},
},
actions: {
async fetchProducts() {
this.loading = true;
this.error = null;
try {
const response = await fetch('/api/products');
this.products = await response.json();
} catch (e) {
this.error = 'Erreur de chargement';
} finally {
this.loading = false;
}
},
selectProduct(product: Product | null) {
this.selectedProduct = product;
},
},
});
Store Pinia avec Composition API
// stores/cart.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
interface CartItem {
productId: string;
name: string;
price: number;
quantity: number;
}
export const useCartStore = defineStore('cart', () => {
// State
const items = ref<CartItem[]>([]);
const couponCode = ref<string | null>(null);
const discount = ref(0);
// Getters
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
);
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
const total = computed(() => subtotal.value - discount.value);
const isEmpty = computed(() => items.value.length === 0);
// Actions
function addItem(item: Omit<CartItem, 'quantity'>, quantity = 1) {
const existing = items.value.find(i => i.productId === item.productId);
if (existing) {
existing.quantity += quantity;
} else {
items.value.push({ ...item, quantity });
}
}
function removeItem(productId: string) {
const index = items.value.findIndex(i => i.productId === productId);
if (index !== -1) {
items.value.splice(index, 1);
}
}
function updateQuantity(productId: string, quantity: number) {
const item = items.value.find(i => i.productId === productId);
if (item) {
item.quantity = Math.max(0, quantity);
if (item.quantity === 0) {
removeItem(productId);
}
}
}
async function applyCoupon(code: string): Promise<boolean> {
try {
const response = await fetch(`/api/coupons/${code}`);
if (response.ok) {
const { discountPercent } = await response.json();
couponCode.value = code;
discount.value = subtotal.value * (discountPercent / 100);
return true;
}
return false;
} catch {
return false;
}
}
function clearCart() {
items.value = [];
couponCode.value = null;
discount.value = 0;
}
return {
// State
items,
couponCode,
discount,
// Getters
itemCount,
subtotal,
total,
isEmpty,
// Actions
addItem,
removeItem,
updateQuantity,
applyCoupon,
clearCart,
};
});
Tableau comparatif : Store non-typé vs typé
| Aspect | Store non-typé | Store typé |
|---|---|---|
| Autocomplétion | Aucune | Complète |
| Erreurs de typage | Runtime (production) | Compilation (développement) |
| Refactoring | Risqué, erreurs silencieuses | Sécurisé, erreurs visibles |
| Documentation | Externe nécessaire | Interfaces = documentation |
| Onboarding | Lent, lecture du code | Rapide, types explicites |
| Maintenance | Difficile à long terme | Facilité par les types |
| Bugs | Fréquents, difficiles à traquer | Rares, détectés tôt |
| Performance IDE | Suggestions limitées | Suggestions intelligentes |
| Tests | Mocks complexes | Mocks typés simples |
| Évolution | Changements risqués | Changements guidés |
Bonnes pratiques
1. Séparez les interfaces dans des fichiers dédiés
// types/index.ts
export interface Product { ... }
export interface User { ... }
export interface CartItem { ... }
// store/index.ts
import type { Product, User, CartItem } from '@/types';
2. Utilisez des types utilitaires TypeScript
// Rendre toutes les propriétés optionnelles pour les mises à jour
type PartialProduct = Partial<Product>;
// Sélectionner certaines propriétés
type ProductPreview = Pick<Product, 'id' | 'name' | 'price' | 'image'>;
// Exclure certaines propriétés
type ProductWithoutId = Omit<Product, 'id'>;
// Rendre certaines propriétés requises
type RequiredProduct = Required<Product>;
3. Typez les réponses API avec des génériques
interface ApiResponse<T> {
data: T;
success: boolean;
message?: string;
}
async function fetchProducts(): Promise<ApiResponse<Product[]>> {
const response = await fetch('/api/products');
return response.json();
}
4. Utilisez readonly pour l’état exposé
import { readonly } from 'vue';
export const useStore = () => ({
state: readonly(state), // Empêche les mutations directes
mutations,
actions,
});
5. Préférez les union types aux enums pour les statuts
// Préféré
type OrderStatus = 'pending' | 'processing' | 'shipped' | 'delivered';
// Moins flexible
enum OrderStatusEnum {
Pending = 'pending',
Processing = 'processing',
Shipped = 'shipped',
Delivered = 'delivered',
}
6. Documentez les interfaces complexes avec JSDoc
/**
* Représente un produit dans le catalogue
* @property id - Identifiant unique UUID
* @property stock - Quantité en stock (0 = rupture)
*/
interface Product {
/** Identifiant unique du produit */
id: string;
/** Nom affiché du produit */
name: string;
/** Prix en centimes (ex: 1999 = 19.99 EUR) */
price: number;
/** Quantité disponible en stock */
stock: number;
}
Pièges courants à éviter
1. Oublier de typer les tableaux vides
// ERREUR : TypeScript infère never[]
const state = reactive({
products: [],
});
// CORRECT : Typage explicite
const state = reactive<{ products: Product[] }>({
products: [],
});
// ALTERNATIVE : Assertion de type
const state = reactive({
products: [] as Product[],
});
2. Mutation directe de l’état exposé
// DANGEREUX : L'état peut être modifié n'importe où
export const state = reactive<State>({ ... });
// SÉCURISÉ : État en lecture seule
const state = reactive<State>({ ... });
export const useStore = () => ({
state: readonly(state),
// mutations pour modifier
});
3. Types any implicites
// ERREUR : response.json() retourne any
const products = await response.json();
// CORRECT : Typage explicite
const products: Product[] = await response.json();
// MIEUX : Validation avec Zod
import { z } from 'zod';
const ProductSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number(),
});
const products = ProductSchema.array().parse(await response.json());
4. Ignorer les cas null/undefined
// ERREUR : Peut lever une exception
const price = state.selectedProduct.price;
// CORRECT : Vérification
const price = state.selectedProduct?.price ?? 0;
// AVEC TYPE GUARD
function hasSelectedProduct(state: State): state is State & { selectedProduct: Product } {
return state.selectedProduct !== null;
}
if (hasSelectedProduct(state)) {
// TypeScript sait que selectedProduct n'est pas null ici
console.log(state.selectedProduct.price);
}
5. Mauvaise gestion des types asynchrones
// ERREUR : Promise non gérée
function fetchData() {
const data = fetch('/api/data'); // C'est une Promise !
return data;
}
// CORRECT : Async/await avec typage
async function fetchData(): Promise<Product[]> {
const response = await fetch('/api/data');
const data: Product[] = await response.json();
return data;
}
Exemple complet : Store e-commerce
Voici un exemple complet assemblant tous les concepts :
// store/ecommerce.ts
import { reactive, readonly, computed } from 'vue';
// ===== TYPES =====
export interface Product {
id: string;
name: string;
description: string;
price: number;
image: string;
category: string;
stock: number;
}
export interface CartItem {
product: Product;
quantity: number;
}
export interface User {
id: string;
email: string;
name: string;
}
interface State {
products: Product[];
cart: CartItem[];
user: User | null;
loading: boolean;
error: string | null;
}
// ===== STATE =====
const state = reactive<State>({
products: [],
cart: [],
user: null,
loading: false,
error: null,
});
// ===== GETTERS =====
const getters = {
cartTotal: computed(() =>
state.cart.reduce((sum, item) => sum + item.product.price * item.quantity, 0)
),
cartCount: computed(() =>
state.cart.reduce((sum, item) => sum + item.quantity, 0)
),
isAuthenticated: computed(() => state.user !== null),
};
// ===== MUTATIONS =====
const mutations = {
setProducts: (products: Product[]) => {
state.products = products;
},
addToCart: (product: Product) => {
const existing = state.cart.find(item => item.product.id === product.id);
if (existing) {
existing.quantity++;
} else {
state.cart.push({ product, quantity: 1 });
}
},
removeFromCart: (productId: string) => {
const index = state.cart.findIndex(item => item.product.id === productId);
if (index !== -1) state.cart.splice(index, 1);
},
setUser: (user: User | null) => {
state.user = user;
},
setLoading: (loading: boolean) => {
state.loading = loading;
},
setError: (error: string | null) => {
state.error = error;
},
};
// ===== ACTIONS =====
const actions = {
async fetchProducts(): Promise<void> {
mutations.setLoading(true);
try {
const res = await fetch('/api/products');
const products: Product[] = await res.json();
mutations.setProducts(products);
} catch (e) {
mutations.setError('Erreur de chargement');
} finally {
mutations.setLoading(false);
}
},
async login(email: string, password: string): Promise<boolean> {
mutations.setLoading(true);
try {
const res = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
if (res.ok) {
const user: User = await res.json();
mutations.setUser(user);
return true;
}
return false;
} finally {
mutations.setLoading(false);
}
},
};
// ===== EXPORT =====
export const useEcommerceStore = () => ({
state: readonly(state),
...getters,
...mutations,
...actions,
});
Conclusion
Le typage de votre store Vue avec TypeScript n’est pas une option mais une nécessité pour les applications professionnelles. Les avantages sont nombreux :
- Productivité accrue grâce à l’autocomplétion et la détection d’erreurs
- Code plus robuste avec moins de bugs en production
- Maintenance facilitée grâce aux interfaces comme documentation
- Refactoring sécurisé sans crainte de casser le code existant
Que vous utilisiez un store personnalisé avec reactive() ou Pinia, les principes restent les mêmes : définissez des interfaces claires, typez vos getters et mutations, et utilisez les génériques de Vue pour une intégration parfaite.
N’hésitez pas à commencer petit : typez d’abord votre état, puis progressivement vos mutations et actions. Chaque ligne de type ajoutée est un bug potentiel évité.
Ressources complémentaires
In-Article Ad
Dev Mode
Mahmoud DEVO
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Typage des props Vue.js avec TypeScript et PropType pour un code robuste
Maitrisez le typage explicite des props dans Vue 3 avec TypeScript. Utilisez PropType et les interfaces pour un code type-safe.
Vue Apollo Composable : useQuery et useMutation pour vos operations GraphQL
Exploitez useQuery et useMutation de Vue Apollo Composable pour gerer vos requetes GraphQL. Guide pratique avec gestion du loading, erreurs, cache et optimistic updates.
Créer une application météo avec Vue.js et Vuex : Une approc
Voici une proposition de meta description qui correspond aux exigences : "Découvrez comment créer une application Vue.js responsive et sécurisée avec un systèm