Votre application lance une TypeError trois heures après le début du vendredi soir. La stack trace pointe vers une propriété profondément imbriquée sur un objet de config. La valeur est undefined. Quelqu’un a poussé un changement .env en production sans mettre à jour la logique de validation.

Ce n’est pas un bug d’exécution. C’est un échec de politique. Vous avez laissé des données non fiables entrer dans votre système sans les inspecter au préalable.

Pourquoi la validation de config à l’exécution arrive trop tard

La plupart des équipes valident la config de manière réactive. Une variable d’environnement manquante provoque un crash. Une chaîne URL invalide se propage jusqu’à ce que fetch() lance. Une clé mal nommée dans un fichier de config JSON reste dormante jusqu’à ce que la fonctionnalité qui la lit s’active enfin, des jours ou des semaines plus tard.

Le problème s’aggrave lorsque vous adoptez des langages spécifiques au domaine au sein de bounded contexts. Chaque context a sa propre forme de config. Un service de paiement se soucie de STRIPE_WEBHOOK_SECRET. Un service de notification se soucie de SNS_TOPIC_ARN. Les formes diffèrent, mais le mode de défaillance est le même : une config invalide entre dans le système, puis le système échoue lorsque les données invalides sont finalement utilisées.

TypeScript ne vous sauve pas ici. process.env renvoie string | undefined. Votre interface Config promet une chaîne, mais l’interface est un mensonge tant que quelque chose ne l’impose pas.

La solution est de valider la config à la limite, avant qu’elle n’atteigne l’exécution.

Validation de schéma à la limite du système

Traitez la config comme une entrée non fiable, car c’en est une. Les variables d’environnement, les fichiers JSON et les services de config distants doivent tous passer par un validateur de schéma avant que votre application ne les utilise.

Voici la forme qui fonctionne :

  1. Définissez un schéma qui correspond aux attentes de votre domaine.
  2. Parsez la config brute à travers ce schéma.
  3. Utilisez le résultat parsé et typé partout ailleurs.

Si le parsing échoue, l’application se termine immédiatement avec une erreur claire. Pas de démarrage partiel. Pas d’objet de config avec des champs manquants qui dérive dans le reste de votre code.

Voici un exemple concret utilisant Zod :

import { z } from "zod";

const PaymentConfig = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().min(20),
  MAX_RETRY_ATTEMPTS: z
    .string()
    .transform((s) => parseInt(s, 10))
    .pipe(z.number().min(1).max(10)),
});

const raw = {
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
  STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
  MAX_RETRY_ATTEMPTS: process.env.MAX_RETRY_ATTEMPTS ?? "3",
};

const config = PaymentConfig.parse(raw);

Si STRIPE_SECRET_KEY est manquante, l’application lance avant de monter des routes. Si MAX_RETRY_ATTEMPTS est "fifteen", le parsing échoue avec une erreur claire. Le type de config après parsing est exactement ce que PaymentConfig décrit, pas string | undefined.

Schémas par contexte, pas un objet global

Lorsque vous modélisez la config par bounded context, vous évitez la tentation de déverser chaque variable d’environnement dans un unique objet Config que chaque module importe. Cet objet global crée un couplage caché. Un changement à la config du service de notification nécessite de toucher un fichier que le service de paiement importe également.

À la place, chaque bounded context définit son propre schéma :

// contexts/payment/config.ts
export const PaymentConfig = z.object({
  STRIPE_SECRET_KEY: z.string(),
  WEBHOOK_ENDPOINT: z.string().url(),
});

// contexts/notification/config.ts
export const NotificationConfig = z.object({
  SNS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:"),
  RATE_LIMIT_PER_MINUTE: z.number().default(100),
});

Chaque context parse uniquement ce dont il a besoin. Le service de paiement ne voit pas les variables de notification, et vice versa. Cela reflète les limites de domaine que vous avez déjà tracées dans votre code.

Validation au moment du build avec des types générés

Le parsing au démarrage attrape la plupart des problèmes, mais vous pouvez aller plus loin. Si votre config réside dans des fichiers statiques, validez-les au moment du build.

Considérez un fichier de config JSON qui définit des feature flags par environnement :

{
  "features": {
    "newCheckout": {
      "enabled": true,
      "rolloutPercentage": 50
    }
  }
}

Définissez un schéma, puis validez le fichier JSON dans le cadre de votre étape de build :

import { z } from "zod";
import featureFlags from "./feature-flags.json";

const FeatureFlagConfig = z.object({
  features: z.record(
    z.object({
      enabled: z.boolean(),
      rolloutPercentage: z.number().min(0).max(100),
    })
  ),
});

// This runs during `tsc` or `vite build` if you import the result
export const validatedFlags = FeatureFlagConfig.parse(featureFlags);

TypeScript refusera de compiler si le fichier JSON est mal formé. L’erreur apparaît en CI, pas en production.

Pour les variables d’environnement, qui sont intrinsèquement des données d’exécution, vous ne pouvez pas valider entièrement au moment du build. Mais vous pouvez générer un accessor type-safe qui échoue rapidement au démarrage :

function env(name: string, schema: z.ZodTypeAny): z.infer<typeof schema> {
  const value = process.env[name];
  const result = schema.safeParse(value);
  if (!result.success) {
    console.error(`Invalid env var ${name}:`, result.error.flatten());
    process.exit(1);
  }
  return result.data;
}

const dbUrl = env("DATABASE_URL", z.string().url());

Ce pattern centralise l’échec. Il y a exactement un endroit où une variable d’environnement manquante ou invalide provoque un crash, et cela se produit avant que votre serveur ne commence à accepter du trafic.

Compromis : rigueur versus friction de déploiement

La validation de schéma n’est pas gratuite. Le coût principal est la friction de déploiement.

Si votre schéma est trop strict, une nouvelle variable d’environnement ajoutée au staging mais pas encore à la production empêchera les déploiements. Un coéquipier ajoute FEATURE_X_ENABLED=false au .env de staging et pousse du code qui la lit. La production plante au démarrage parce que la variable y est manquante.

Vous avez deux façons de gérer cela.

Premièrement, utilisez des valeurs par défaut explicites pour les variables qui ont des fallbacks sûrs :

const config = z.object({
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});

Deuxièmement, séparez clairement la config requise de la config optionnelle. Les champs requis devraient représenter des choses sans lesquelles l’application ne peut véritablement pas fonctionner. Les champs optionnels devraient avoir des valeurs par défaut ou être traités comme des fonctionnalités désactivées lorsqu’ils sont absents.

Un autre compromis est la taille de la librairie de schémas. Zod ajoute environ 10KB à votre bundle. Si vous êtes dans un environnement contraint, envisagez Valibot ou ArkType, qui offrent des API similaires avec une empreinte plus petite. Le pattern compte plus que la librairie.

Quand les types générés ne suffisent pas

Certaines équipes génèrent des types TypeScript à partir de leurs fichiers de config et appellent cela validé. Une définition de type n’impose rien à l’exécution. Si un fichier JSON change de forme après que les types ont été générés, TypeScript ne l’attrapera pas à moins que le fichier soit importé et vérifié pendant la compilation.

Le parsing à l’exécution comble le fossé. Le schéma est à la fois le validateur et la source de vérité pour les types. Il n’y a pas de définition de type séparée qui puisse se désynchroniser.

Assemblage dans un bounded context

Voici un pattern complet pour un contexte unique :

// contexts/billing/config.ts
import { z } from "zod";

const RawBillingConfig = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string(),
  INVOICE_GRACE_PERIOD_DAYS: z
    .string()
    .transform(Number)
    .pipe(z.number().min(0)),
});

export type BillingConfig = z.infer<typeof RawBillingConfig>;

export function loadBillingConfig(): BillingConfig {
  const raw = {
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
    INVOICE_GRACE_PERIOD_DAYS: process.env.INVOICE_GRACE_PERIOD_DAYS,
  };

  const result = RawBillingConfig.safeParse(raw);
  if (!result.success) {
    const issues = result.error.issues
      .map((i) => `${i.path.join(".")}: ${i.message}`)
      .join("\n");
    throw new Error(`Billing config invalid:\n${issues}`);
  }

  return result.data;
}

Importez loadBillingConfig() dans votre point d’entrée, appelez-le une fois et passez le résultat à l’initialisation de votre context. Aucun module en dehors de ce context ne devrait lire process.env directement.

Que faire ensuite

Auditez votre codebase pour les lectures directes de process.env en dehors des fichiers de config. Chacune est un échec d’exécution potentiel attendant le bon déploiement pour se déclencher.

Remplacez-les par des schémas par contexte. Choisissez une librairie, définissez les formes et échouez rapidement au démarrage. La première fois qu’une variable d’environnement manquante termine proprement votre application en CI au lieu de lancer en production, vous saurez que la limite fonctionne.