TypeScript strict en pratique : interfaces hiérarchiques sur une vraie API

La plupart des tutoriels TypeScript montrent des exemples triviaux : interface User { name: string; age: number }. En production, la réalité est plus complexe. Les APIs externes renvoient des structures imbriquées, des champs optionnels selon le contexte, des unions de types selon l'état. Voici comment structurer tout ça proprement, à partir d'un cas réel.

Le point de départ : une API avec une structure hiérarchique

Prenons une API de gestion de certificats qui renvoie des structures de ce type :

{
  "account": {
    "id": "ACC-001",
    "name": "EDF Production",
    "type": "PRODUCER"
  },
  "certificates": [
    {
      "id": "GO-2024-001",
      "volume": 1500.5,
      "unit": "MWh",
      "period": { "from": "2024-01-01", "to": "2024-01-31" },
      "status": "ACTIVE" | "CANCELLED" | "TRANSFERRED",
      "metadata": {
        "technology": "WIND",
        "country": "FR",
        "installation_id": "INS-042"
      }
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 50,
    "total": 312
  }
}

Modéliser les types de base

On commence par les types atomiques — unions littérales et enums :

// types/api.ts

export type AccountType = "PRODUCER" | "TRADER" | "CONSUMER"
export type CertificateStatus = "ACTIVE" | "CANCELLED" | "TRANSFERRED"
export type EnergyTechnology = "WIND" | "SOLAR" | "HYDRO" | "BIOMASS"
export type CountryCode = "FR" | "DE" | "ES" | "IT" | "BE"

export interface DateRange {
  from: string // ISO 8601
  to: string
}

Les unions littérales plutôt que des string nus : TypeScript t'alertera immédiatement si tu passes 'WIND_OFFSHORE' là où EnergyTechnology est attendu.

Interfaces hiérarchiques

export interface Account {
  id: string
  name: string
  type: AccountType
}

export interface CertificateMetadata {
  technology: EnergyTechnology
  country: CountryCode
  installation_id: string
}

export interface Certificate {
  id: string
  volume: number
  unit: "MWh" | "kWh"
  period: DateRange
  status: CertificateStatus
  metadata: CertificateMetadata
}

export interface Pagination {
  page: number
  per_page: number
  total: number
}

export interface CertificatesResponse {
  account: Account
  certificates: Certificate[]
  pagination: Pagination
}

Types utilitaires pour les cas d'usage

L'API renvoie toujours la structure complète, mais l'application n'en a pas toujours besoin en entier. Les types utilitaires TypeScript permettent de dériver des types adaptés à chaque contexte sans dupliquer les interfaces :

// Pour l'affichage dans une liste — on n'a pas besoin des métadonnées
export type CertificateSummary = Pick<
  Certificate,
  "id" | "volume" | "unit" | "status" | "period"
>

// Pour la mise à jour — seul le statut est modifiable
export type CertificateUpdate = Pick<Certificate, "id"> & {
  status: CertificateStatus
}

// Pour les filtres de recherche — tous les champs sont optionnels
export type CertificateFilters = Partial<Pick<Certificate, "status">> & {
  period?: Partial<DateRange>
  technology?: EnergyTechnology
  country?: CountryCode
}

// Pour les formulaires — on exclut les champs générés par l'API
export type CertificateForm = Omit<Certificate, "id" | "status">

Génériques sur les composables Vue

Un composable générique pour les appels API évite de réécrire la même logique de chargement/erreur pour chaque endpoint :

// composables/useApiQuery.ts
import { ref, Ref } from "vue"

interface ApiQueryState<T> {
  data: Ref<T | null>
  loading: Ref<boolean>
  error: Ref<string | null>
  execute: () => Promise<void>
}

export function useApiQuery<T>(
  fetcher: () => Promise<T>,
  options?: { immediate?: boolean },
): ApiQueryState<T> {
  const data = ref<T | null>(null) as Ref<T | null>
  const loading = ref(false)
  const error = ref<string | null>(null)

  const execute = async () => {
    loading.value = true
    error.value = null
    try {
      data.value = await fetcher()
    } catch (e) {
      error.value = e instanceof Error ? e.message : "Erreur inconnue"
    } finally {
      loading.value = false
    }
  }

  if (options?.immediate) execute()

  return { data, loading, error, execute }
}

Usage dans un composant Vue :

// Dans un composant
const {
  data: certificates,
  loading,
  error,
  execute,
} = useApiQuery<CertificatesResponse>(
  () => $fetch("/api/certificates", { params: filters.value }),
  { immediate: true },
)

TypeScript infère automatiquement le type de data comme Ref<CertificatesResponse | null> — pas de casting manuel, pas d'as any.

Typer les props Vue avec les interfaces API

Une erreur fréquente : définir des props Vue avec des types locaux qui dupliquent les interfaces API. La bonne approche :

// components/CertificateCard.vue
<script setup lang="ts">
import type { CertificateSummary } from '@/types/api'

const props = defineProps<{
  certificate: CertificateSummary
  selected?: boolean
}>()

const emit = defineEmits<{
  select: [id: string]
  statusChange: [id: string, status: CertificateStatus]
}>()
</script>

defineProps<T>() et defineEmits<T>() avec des types génériques : pas de PropType<T> à importer, TypeScript valide les props à la compilation et dans le template.

Guards de type pour les réponses d'API

Les APIs réelles ne sont pas toujours conformes à leur contrat. Un guard de type permet de valider à runtime sans sacrifier le typage statique :

function isCertificate(value: unknown): value is Certificate {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "volume" in value &&
    "status" in value &&
    ["ACTIVE", "CANCELLED", "TRANSFERRED"].includes(
      (value as Certificate).status,
    )
  )
}

// Usage
const raw = await $fetch("/api/certificates/GO-2024-001")
if (!isCertificate(raw)) {
  throw new Error("Réponse API invalide")
}
// Ici, TypeScript sait que raw est de type Certificate
console.log(raw.volume)

Ce que ça change en pratique

Investir dans une modélisation TypeScript rigoureuse dès le début du projet, c'est :

  • Autocomplétion fiable dans l'IDE sur tous les objets API
  • Erreurs détectées à la compilation plutôt qu'en production
  • Refactoring sûr — renommer un champ dans l'interface propage l'erreur partout où il est utilisé
  • Documentation implicite — les types sont la source de vérité sur la forme des données

Le coût d'entrée est réel, surtout sur un projet existant avec du JavaScript à migrer. Mais sur un nouveau projet Vue.js + API tierce, partir en TypeScript strict depuis le premier commit est toujours la décision la plus rentable à moyen terme.