03 - Variables d'environnement type-safe
Ce que tu vas apprendre
- Pourquoi
process.env.XXXest toujoursstring | undefinedet pourquoi c'est un problème - Comment typer
process.envavecdeclare 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 :
- Il valide que chaque variable existe et a le bon format
- Il convertit les types (
PORTdevient unnumbergrâce àz.coerce) - 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.envest toujoursstring | undefined— TypeScript ne connaît pas tes variablesdeclare globaltype 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