TypeScript en pratique - 03 - Variables d'environnement type-safe

Comment typer process.env avec Zod, t3-env et déclaré global pour ne plus jamais avoir de variable manquante en production.

03 - Variables d'environnement type-safe

Ce que tu vas apprendre

  • Pourquoi process.env.XXX est toujours string | undefined et pourquoi c'est un problème
  • Comment typer process.env avec declare global
  • Valider les variables d'environnement au démarrage avec Zod
  • Le pattern t3-env pour un typage complet
  • Gerer les environnements dev/prod/test proprement

Prerequisites

Avoir lu l'article sur Zod pour comprendre les schemas de validation.


Le problème

Par défaut, process.env a ce type dans Node.js :

typescriptinterface ProcessEnv {
  [key: string]: string | undefined
}

Chaque acces retourne string | undefined. TypeScript ne sait pas quelles variables existent. Et il ne peut pas savoir — les variables d'environnement sont injectees au runtime.

typescript// ❌ Pas d'erreur TypeScript — mais crash en prod si DATABASE_URL manque
const db = new Database(process.env.DATABASE_URL)
// type: string | undefined — Database attend string

J'ai vu des bugs en production causes par une variable manquante dans le .env du serveur de déploiement. Le serveur demarrait, recevait du trafic, puis crashait au premier acces a la base de donnees. Le fix prend 30 secondes — trouver le problème a pris une heure.

La solution naive : déclaré global

On peut redefinir le type de process.env :

typescript// env.d.ts
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      DATABASE_URL: string
      API_KEY: string
      NODE_ENV: "development" | "production" | "test"
      PORT: string
    }
  }
}

export {}

Maintenant process.env.DATABASE_URL a le type string au lieu de string | undefined. TypeScript signale aussi les fautes de frappe : process.env.DATABSE_URL produit une erreur.

Le problème : c'est un mensonge. Tu as dit a TypeScript que ces variables existent, mais rien ne le vérifié. Si DATABASE_URL manque, le type dit string mais la valeur est undefined.

La vraie solution : validation au démarrage avec Zod

On combine Zod avec un fichier de configuration :

typescript// src/env.ts
import { z } from "zod"

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
  PORT: z.coerce.number().default(3000),
  REDIS_URL: z.string().url().optional(),
})

export const env = envSchema.parse(process.env)
export type Env = z.infer<typeof envSchema>

Ce fichier fait trois choses :

  1. Il valide que chaque variable existe et a le bon format
  2. Il convertit les types (PORT devient un number grâce à z.coerce)
  3. Il exporte un objet type avec les bons types

Si une variable manque ou est invalide, l'application crashe immédiatement au démarrage — pas 3 heures plus tard quand un utilisateur touche la bonne route.

typescript// Utilisation
import { env } from "./env"

const db = new Database(env.DATABASE_URL)  // type: string ✅
const port = env.PORT                       // type: number ✅
const redis = env.REDIS_URL                 // type: string | undefined ✅

Le pattern t3-env

Le package @t3-oss/env-core pousse le concept plus loin en separant les variables serveur et client :

typescript// src/env.ts
import { createEnv } from "@t3-oss/env-core"
import { z } from "zod"

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    API_KEY: z.string().min(1),
    STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1),
  },
  clientPrefix: "NEXT_PUBLIC_",
  runtimeEnv: process.env,
})

L'interet est double. Les variables server ne sont jamais exposees cote client — t3-env lance une erreur si tu essaies d'y acceder dans du code client. Et la validation reste centralisee.

Sur paltemps.fr, j'utilise ce pattern pour séparer les clés d'API de paiement (serveur uniquement) des clés publiques (analytics, URLs).

Gerer dev/prod/test

Les variables changent selon l'environnement. Voici un pattern qui gere ca :

typescript// src/env.ts
import { z } from "zod"

const baseSchema = z.object({
  NODE_ENV: z.enum(["development", "production", "test"]),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
})

const devSchema = baseSchema.extend({
  DATABASE_URL: z.string().default("postgres://localhost:5432/myapp_dev"),
  API_KEY: z.string().default("dev-key-not-secret"),
})

const prodSchema = baseSchema.extend({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(20),
  SENTRY_DSN: z.string().url(),
})

const testSchema = baseSchema.extend({
  DATABASE_URL: z.string().default("postgres://localhost:5432/myapp_test"),
  API_KEY: z.string().default("test-key"),
})

function validateEnv() {
  const nodeEnv = process.env.NODE_ENV ?? "development"

  switch (nodeEnv) {
    case "production":
      return prodSchema.parse(process.env)
    case "test":
      return testSchema.parse(process.env)
    default:
      return devSchema.parse(process.env)
  }
}

export const env = validateEnv()

En dev, les valeurs par défaut evitent de remplir un .env a la main. En production, chaque variable est obligatoire et validee avec des contraintes strictes (longueur minimale de l'API key, format URL).

Erreurs lisibles

Quand la validation echoue, Zod donne les détails. On peut les formatter pour le dev :

typescriptimport { z } from "zod"

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
  PORT: z.coerce.number(),
})

const result = envSchema.safeParse(process.env)

if (!result.success) {
  const formatted = result.error.issues
    .map(issue => `  ${issue.path.join(".")}: ${issue.message}`)
    .join("\n")

  console.error("Invalid environment variables:\n" + formatted)
  process.exit(1)
}

export const env = result.data

Ca donne un message comme :

Invalid environment variables:
  DATABASE_URL: Invalid url
  API_KEY: String must contain at least 1 character(s)

Bien plus utile qu'un TypeError: Cannot read properties of undefined 200 lignes plus bas dans le stack.

Variables d'environnement et les tests

Pour les tests, on veut souvent surcharger certaines variables. Un pattern simple :

typescript// test/setup.ts
process.env.NODE_ENV = "test"
process.env.DATABASE_URL = "postgres://localhost:5432/myapp_test"

// Importe env apres avoir set les variables
const { env } = await import("../src/env")

L'ordre d'import est important. Le module env.ts valide process.env a l'import. Si tu l'importes avant de définir les variables de test, la validation echoue.


Résumé

  • process.env est toujours string | undefined — TypeScript ne connaît pas tes variables
  • declare global type les variables mais ne les valide pas — c'est un mensonge potentiel
  • Un schema Zod valide et transforme les variables au démarrage de l'application
  • Le pattern t3-env séparé les variables serveur/client et empeche les fuites de secrets
  • Les environnements dev/prod/test ont des schemas différents avec des défauts adaptes

Article précédent : 02 - Zod : validation runtime et inference

Article suivant : 04 - Organiser ses types dans un projet

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.