Définition de l'état de magasin avec TypeScript et Vue.js

Apprenez à définir et typer l'état de votre store Vue avec TypeScript. Interfaces, génériques reactive() et mutations typées pour un code robuste.

Mahmoud DEVO
Mahmoud DEVO
December 28, 2025 9 min read
Définition de l'état de magasin avec TypeScript et Vue.js

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é

AspectStore non-typéStore typé
AutocomplétionAucuneComplète
Erreurs de typageRuntime (production)Compilation (développement)
RefactoringRisqué, erreurs silencieusesSécurisé, erreurs visibles
DocumentationExterne nécessaireInterfaces = documentation
OnboardingLent, lecture du codeRapide, types explicites
MaintenanceDifficile à long termeFacilité par les types
BugsFréquents, difficiles à traquerRares, détectés tôt
Performance IDESuggestions limitéesSuggestions intelligentes
TestsMocks complexesMocks typés simples
ÉvolutionChangements risquésChangements 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

Advertisement

In-Article Ad

Dev Mode

Share this article

Mahmoud DEVO

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