Cours JavaScript et TypeScript · Module 10: TypeScript en pratique

TypeScript avec Node.js - patterns pratiques

10 min de lecture
Dans cette leçon5

Quand tu gères l'infrastructure de Jurassic Park, il te faut un backend fiable : un système d'autorisation, une API pour gérer les zones et un middleware de journalisation. Un seul handler qui accepte dangerLevel sous forme de texte au lieu d'un nombre peut fausser le rapport de sécurité de tout le parc. TypeScript, combiné à Node.js et Express, permet de construire des serveurs avec une type safety complète et d'éliminer des classes entières de bugs avant même de lancer le code.

Typer les routes dans Express

Par défaut, dans Express, req.body est de type any, req.query est un dictionnaire générique ParsedQs, et req.params un dictionnaire de chaînes. On les restreint avec le générique Request<Params, ResBody, ReqBody, Query>, dans lequel l'ordre des paramètres compte. On commence par décrire les données de l'API :

1import { Request, Response, NextFunction, Router } from 'express';
2
3// Interfaces pour les données de notre API
4interface Dinosaur {
5  id: string;
6  species: string;
7  diet: 'herbivore' | 'carnivore' | 'omnivore';
8  zone: string;
9  weight: number;
10  dangerLevel: 1 | 2 | 3 | 4 | 5;
11}
12
13// Request typée - params, body, query
14interface GetDinosaurParams {
15  id: string;
16}
17
18interface ListDinosaursQuery {
19  zone?: string;
20  diet?: Dinosaur['diet'];
21  limit?: string;
22  offset?: string;
23}
24
25interface CreateDinosaurBody {
26  species: string;
27  diet: Dinosaur['diet'];
28  zone: string;
29  weight: number;
30  dangerLevel: Dinosaur['dangerLevel'];
31}

Dinosaur['diet'] est un type indexé : il récupère le type d'un champ dans une autre interface, donc la liste des régimes alimentaires n'existe qu'à un seul endroit. limit et offset sont des chaînes, car c'est sous cette forme qu'ils arrivent dans l'URL.

Passons à un handler avec des paramètres de chemin typés. Le premier générique de Request décrit params, et Response<T> veille à la forme de la réponse :

1// Handlers typés
2const router = Router();
3
4// GET /dinosaurs/:id - params typés
5router.get('/dinosaurs/:id',
6  (req: Request<GetDinosaurParams>, res: Response<Dinosaur | { error: string }>) => {
7    const dinoId: string = req.params.id; // typé !
8    // req.params.name; // Erreur de compilation - pas de "name" dans params
9
10    const dinosaur: Dinosaur = {
11      id: dinoId,
12      species: 'Velociraptor',
13      diet: 'carnivore',
14      zone: 'B-7',
15      weight: 80,
16      dangerLevel: 4
17    };
18    res.json(dinosaur);
19  }
20);

Faire référence à req.params.name provoque une erreur de compilation, et res.json() n'acceptera qu'un dinosaure ou un objet d'erreur.

On décrit la query et le body avec les génériques suivants, en plaçant {} et any aux positions dont on n'a pas besoin :

1// GET /dinosaurs - query typée
2router.get('/dinosaurs',
3  (req: Request<{}, any, any, ListDinosaursQuery>, res: Response<Dinosaur[]>) => {
4    const zone = req.query.zone;       // string | undefined
5    const diet = req.query.diet;       // Dinosaur['diet'] | undefined
6    const limit = parseInt(req.query.limit || '10');
7
8    // Filtrage à partir des query params typés
9    res.json([]);
10  }
11);
12
13// POST /dinosaurs - body typé
14router.post('/dinosaurs',
15  (req: Request<{}, any, CreateDinosaurBody>, res: Response<Dinosaur>) => {
16    const body = req.body;
17    // body.species: string - typé !
18    // body.diet: 'herbivore' | 'carnivore' | 'omnivore' - typé !
19    // body.unknownField; // Erreur de compilation
20
21    const newDinosaur: Dinosaur = {
22      id: 'DINO-' + Date.now(),
23      ...body
24    };
25    res.status(201).json(newDinosaur);
26  }
27);

N'oublie pas que c'est une promesse, et non une validation : le client peut envoyer n'importe quel JSON, donc en production, vérifie les données au runtime, par exemple avec la bibliothèque zod. Le type décrit des données correctes, mais ne les garantit pas.

Typer les middlewares

Dans Express, les middlewares sont des fonctions qui traitent la requête avant qu'elle n'atteigne le handler. Ils reçoivent req, res et next, et ils interrompent la chaîne en envoyant une réponse au lieu d'appeler next(). On étend l'interface de la requête avec les données de l'utilisateur connecté :

1// Middleware qui ajoute userId à la requête
2interface AuthenticatedRequest extends Request {
3  userId: string;
4  role: 'visitor' | 'staff' | 'admin' | 'scientist';
5}
6
7function authMiddleware(
8  req: Request,
9  res: Response,
10  next: NextFunction
11): void {
12  const token = req.headers.authorization?.split(' ')[1];
13
14  if (!token) {
15    res.status(401).json({ error: "Token d'autorisation manquant" });
16    return;
17  }
18
19  // Vérification du token (simplifiée)
20  try {
21    const decoded = verifyToken(token); // Ta fonction de vérification
22    (req as AuthenticatedRequest).userId = decoded.userId;
23    (req as AuthenticatedRequest).role = decoded.role;
24    next();
25  } catch (error) {
26    res.status(403).json({ error: 'Token invalide' });
27  }
28}
29
30// Déclaration auxiliaire
31declare function verifyToken(token: string): { userId: string; role: AuthenticatedRequest['role'] };
32
33// Middleware qui vérifie le rôle
34function requireRole(...roles: AuthenticatedRequest['role'][]) {
35  return (req: Request, res: Response, next: NextFunction): void => {
36    const authReq = req as AuthenticatedRequest;
37    if (!roles.includes(authReq.role)) {
38      res.status(403).json({
39        error: `Rôle requis : ${roles.join(' ou ')}`
40      });
41      return;
42    }
43    next();
44  };
45}
46
47// Utilisation dans les routes
48router.delete('/dinosaurs/:id',
49  authMiddleware,
50  requireRole('admin', 'scientist'),
51  (req: Request, res: Response) => {
52    const authReq = req as AuthenticatedRequest;
53    console.log(`L'utilisateur ${authReq.userId} a supprimé le dinosaure ${req.params.id}`);
54    res.status(204).send();
55  }
56);

L'ordre est donc le suivant : la requête arrive au serveur, authMiddleware vérifie le token, requireRole contrôle le rôle, et le handler reçoit la requête une fois le contrôle passé. verifyToken renvoie un rôle de type AuthenticatedRequest['role'], donc l'affectation est sûre, et le cast as AuthenticatedRequest est ta promesse que le middleware s'est exécuté avant.

Typer les variables d'environnement

Les variables d'environnement (process.env) ont par défaut le type string | undefined, car pendant la compilation, personne ne sait si elles seront définies. On les valide donc au démarrage de l'application :

1// Interface qui décrit les variables d'environnement requises
2interface EnvironmentVariables {
3  NODE_ENV: 'development' | 'production' | 'test';
4  PORT: string;
5  DATABASE_URL: string;
6  JWT_SECRET: string;
7  PARK_NAME: string;
8  MAX_VISITORS: string;
9  ALERT_EMAIL: string;
10}
11
12// Fonction qui valide les variables d'environnement
13function validateEnv(): EnvironmentVariables {
14  const required: (keyof EnvironmentVariables)[] = [
15    'NODE_ENV', 'PORT', 'DATABASE_URL', 'JWT_SECRET',
16    'PARK_NAME', 'MAX_VISITORS', 'ALERT_EMAIL'
17  ];
18
19  const missing = required.filter(key => !process.env[key]);
20
21  if (missing.length > 0) {
22    throw new Error(
23      `Variables d'environnement manquantes : ${missing.join(', ')}`
24    );
25  }
26
27  return process.env as unknown as EnvironmentVariables;
28}
29
30// Utilisation - sûre et typée
31const env = validateEnv();
32const port = parseInt(env.PORT);           // string -> number
33const dbUrl: string = env.DATABASE_URL;    // forcément une string, pas undefined
34const maxVisitors = parseInt(env.MAX_VISITORS);

as unknown as est une double assertion par laquelle tu dis au compilateur que tu en sais plus que lui. Le code vérifie la présence des variables, mais pas que NODE_ENV a une valeur autorisée, alors ajoute ce test toi-même. Une application sans secret JWT refusera de démarrer, au lieu de tourner avec une faille de sécurité.

Il est plus pratique d'enfermer la configuration dans une classe, qui convertit une fois pour toutes les chaînes en nombres et en booléens :

1// Autre solution - une classe de configuration
2class AppConfig {
3  readonly port: number;
4  readonly databaseUrl: string;
5  readonly jwtSecret: string;
6  readonly parkName: string;
7  readonly maxVisitors: number;
8  readonly isDevelopment: boolean;
9
10  constructor() {
11    const env = validateEnv();
12    this.port = parseInt(env.PORT);
13    this.databaseUrl = env.DATABASE_URL;
14    this.jwtSecret = env.JWT_SECRET;
15    this.parkName = env.PARK_NAME;
16    this.maxVisitors = parseInt(env.MAX_VISITORS);
17    this.isDevelopment = env.NODE_ENV === 'development';
18  }
19}
20
21// Singleton - une seule instance de configuration
22const config = new AppConfig();
23console.log(`${config.parkName} démarre sur le port ${config.port}`);

Tu ne peux pas écraser les champs readonly après la création de l'objet. Le type mappé Readonly<T>, qui s'écrit { readonly [K in keyof T]: T[K] }, fait la même chose pour tous les champs d'un coup.

Typer les objets de configuration

Les configurations complexes demandent un typage en profondeur. Chaque section reçoit sa propre interface, et AppConfiguration réunit le tout :

1// Configuration de la base de données
2interface DatabaseConfig {
3  host: string;
4  port: number;
5  name: string;
6  credentials: {
7    username: string;
8    password: string;
9  };
10  pool: {
11    min: number;
12    max: number;
13    idleTimeout: number;
14  };
15  ssl: boolean;
16}
17
18// Configuration du serveur
19interface ServerConfig {
20  port: number;
21  host: string;
22  cors: {
23    origins: string[];
24    methods: ('GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH')[];
25    credentials: boolean;
26  };
27  rateLimit: {
28    windowMs: number;
29    max: number;
30  };
31}
32
33// Configuration du parc
34interface ParkConfig {
35  zones: Record<string, {
36    name: string;
37    maxCapacity: number;
38    dangerLevel: number;
39    fenceVoltage: number;
40  }>;
41  alertThresholds: {
42    temperature: { min: number; max: number };
43    humidity: { min: number; max: number };
44    windSpeed: number;
45  };
46}
47
48// Configuration complète de l'application
49interface AppConfiguration {
50  server: ServerConfig;
51  database: DatabaseConfig;
52  park: ParkConfig;
53}
54
55// Fonction qui charge la configuration avec validation
56function loadConfiguration(): AppConfiguration {
57  return {
58    server: {
59      port: parseInt(process.env.PORT || '4000'),
60      host: process.env.HOST || '0.0.0.0',
61      cors: {
62        origins: (process.env.CORS_ORIGINS || 'http://localhost:3000').split(','),
63        methods: ['GET', 'POST', 'PUT', 'DELETE'],
64        credentials: true
65      },
66      rateLimit: {
67        windowMs: 15 * 60 * 1000,
68        max: 100
69      }
70    },
71    database: {
72      host: process.env.DB_HOST || 'localhost',
73      port: parseInt(process.env.DB_PORT || '27017'),
74      name: process.env.DB_NAME || 'jurassic_park',
75      credentials: {
76        username: process.env.DB_USER || '',
77        password: process.env.DB_PASS || ''
78      },
79      pool: { min: 2, max: 10, idleTimeout: 30000 },
80      ssl: process.env.NODE_ENV === 'production'
81    },
82    park: {
83      zones: {
84        'zone-a': { name: 'Zone des herbivores', maxCapacity: 200, dangerLevel: 1, fenceVoltage: 5000 },
85        'zone-b': { name: 'Zone des raptors', maxCapacity: 50, dangerLevel: 5, fenceVoltage: 15000 },
86        'zone-c': { name: 'Zone du T-Rex', maxCapacity: 30, dangerLevel: 5, fenceVoltage: 20000 }
87      },
88      alertThresholds: {
89        temperature: { min: -5, max: 45 },
90        humidity: { min: 20, max: 95 },
91        windSpeed: 120
92      }
93    }
94  };
95}
96
97// Utilisation
98const appConfig = loadConfiguration();
99console.log(`Zones du parc : ${Object.keys(appConfig.park.zones).length}`);
100console.log(`Max raptor capacity: ${appConfig.park.zones['zone-b'].maxCapacity}`);

Une faute de frappe dans le nom d'un champ ou une chaîne à la place d'un nombre dans fenceVoltage provoque une erreur de compilation, et non une panne de clôture à trois heures du matin. Record<string, {...}> décrit des zones aux noms quelconques, mais de forme identique.

Typer les réponses de l'API (response wrappers)

Pour finir, un format de réponse commun. Le champ success, qui vaut true ou false, crée une union discriminée (discriminated union) :

1// Réponse standard de l'API
2interface ApiSuccessResponse<T> {
3  success: true;
4  data: T;
5  meta?: {
6    total: number;
7    page: number;
8    limit: number;
9  };
10}
11
12interface ApiErrorResponse {
13  success: false;
14  error: {
15    code: string;
16    message: string;
17    details?: Record<string, string>;
18  };
19}
20
21type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
22
23// Helper functions
24function sendSuccess<T>(res: Response, data: T, meta?: ApiSuccessResponse<T>['meta']): void {
25  const response: ApiSuccessResponse<T> = { success: true, data };
26  if (meta) response.meta = meta;
27  res.json(response);
28}
29
30function sendError(res: Response, code: string, message: string, status: number = 400): void {
31  const response: ApiErrorResponse = {
32    success: false,
33    error: { code, message }
34  };
35  res.status(status).json(response);
36}
37
38// Utilisation dans une route
39router.get('/zones/:zoneId/dinosaurs',
40  (req: Request<{ zoneId: string }, any, any, { page?: string; limit?: string }>, res: Response) => {
41    const zoneId = req.params.zoneId;
42    const page = parseInt(req.query.page || '1');
43    const limit = parseInt(req.query.limit || '20');
44
45    // Simulation de données
46    const dinosaurs: Dinosaur[] = [];
47    sendSuccess(res, dinosaurs, { total: 0, page, limit });
48  }
49);

Un client qui vérifie if (response.success) a accès à data dans cette branche, et à error dans l'autre, car le compilateur restreint le type à partir d'un seul champ.

TypeScript avec Node.js forme un duo puissant : des routes, des middlewares, une configuration et des réponses d'API typés rendent le backend du parc aussi sûr qu'une clôture sous 20 000 volts, et le compilateur attrape les erreurs avant même que tu lances le serveur. Mon conseil : valide les variables d'environnement au démarrage, et les données du client à l'entrée du handler, car les types disparaissent après la compilation. Dans l'éditeur ci-dessous, tu construiras l'API typée du parc, et dans la leçon suivante, tu verras comment étendre les types des autres grâce au module augmentation.

Retiens : dans un backend, les types sont le plan des clôtures, et la validation au runtime est le courant dans les fils, donc un parc sûr a besoin des deux.

Code de cette leçon : index.ts
1// TypeScript avec Node.js - patterns pratiques
2// Jurassic Park - API backend
3
4// 1. Interfaces typées pour l'API
5interface Dinosaur {
6  id: string;
7  species: string;
8  diet: 'herbivore' | 'carnivore' | 'omnivore';
9  zone: string;
10  weight: number;
11  dangerLevel: 1 | 2 | 3 | 4 | 5;
12}
13
14interface ListDinosaursQuery {
15  zone?: string;
16  diet?: Dinosaur['diet'];
17  limit?: number;
18  offset?: number;
19}
20
21interface CreateDinosaurBody {
22  species: string;
23  diet: Dinosaur['diet'];
24  zone: string;
25  weight: number;
26  dangerLevel: Dinosaur['dangerLevel'];
27}
28
29// 2. Typer les variables d'environnement
30interface EnvironmentVariables {
31  NODE_ENV: 'development' | 'production' | 'test';
32  PORT: string;
33  DATABASE_URL: string;
34  PARK_NAME: string;
35  MAX_VISITORS: string;
36}
37
38function validateEnv(required: string[]): Record<string, string> {
39  const missing = required.filter(key => !process.env[key]);
40  if (missing.length > 0) {
41    // Dans un vrai projet : throw new Error(...)
42    console.log(`Note : variables manquantes : ${missing.join(', ')}`);
43  }
44  return process.env as Record<string, string>;
45}
46
47// 3. Objets de configuration typés
48interface DatabaseConfig {
49  host: string;
50  port: number;
51  name: string;
52  pool: { min: number; max: number };
53}
54
55interface ServerConfig {
56  port: number;
57  cors: {
58    origins: string[];
59    methods: ('GET' | 'POST' | 'PUT' | 'DELETE')[];
60  };
61}
62
63interface ParkZoneConfig {
64  name: string;
65  maxCapacity: number;
66  dangerLevel: number;
67  fenceVoltage: number;
68}
69
70interface AppConfig {
71  server: ServerConfig;
72  database: DatabaseConfig;
73  zones: Record<string, ParkZoneConfig>;
74}
75
76function loadConfig(): AppConfig {
77  return {
78    server: {
79      port: 4000,
80      cors: {
81        origins: ['http://localhost:3000'],
82        methods: ['GET', 'POST', 'PUT', 'DELETE']
83      }
84    },
85    database: {
86      host: 'localhost',
87      port: 27017,
88      name: 'jurassic_park',
89      pool: { min: 2, max: 10 }
90    },
91    zones: {
92      'zone-a': { name: 'Herbivore Valley', maxCapacity: 200, dangerLevel: 1, fenceVoltage: 5000 },
93      'zone-b': { name: 'Raptor Paddock', maxCapacity: 50, dangerLevel: 5, fenceVoltage: 15000 },
94      'zone-c': { name: 'T-Rex Kingdom', maxCapacity: 30, dangerLevel: 5, fenceVoltage: 20000 }
95    }
96  };
97}
98
99// 4. Réponses d'API typées
100interface ApiSuccess<T> {
101  success: true;
102  data: T;
103  meta?: { total: number; page: number; limit: number };
104}
105
106interface ApiError {
107  success: false;
108  error: { code: string; message: string };
109}
110
111type ApiResponse<T> = ApiSuccess<T> | ApiError;
112
113function createSuccess<T>(data: T, meta?: ApiSuccess<T>['meta']): ApiSuccess<T> {
114  const response: ApiSuccess<T> = { success: true, data };
115  if (meta) response.meta = meta;
116  return response;
117}
118
119function createError(code: string, message: string): ApiError {
120  return { success: false, error: { code, message } };
121}
122
123// 5. Simulation du serveur
124const config = loadConfig();
125const dinosaurs: Dinosaur[] = [
126  { id: 'D001', species: 'T-Rex', diet: 'carnivore', zone: 'zone-c', weight: 7000, dangerLevel: 5 },
127  { id: 'D002', species: 'Triceratops', diet: 'herbivore', zone: 'zone-a', weight: 6000, dangerLevel: 2 },
128  { id: 'D003', species: 'Velociraptor', diet: 'carnivore', zone: 'zone-b', weight: 80, dangerLevel: 4 },
129];
130
131console.log(`=== API ${config.database.name.toUpperCase()} ===`);
132console.log(`Serveur : port ${config.server.port}`);
133console.log(`Database: ${config.database.host}:${config.database.port}/${config.database.name}`);
134console.log(`Zones : ${Object.keys(config.zones).length}\n`);
135
136// Simulation GET /dinosaurs
137console.log("GET /dinosaurs?zone=zone-b");
138const query: ListDinosaursQuery = { zone: 'zone-b' };
139const filtered = dinosaurs.filter(d => !query.zone || d.zone === query.zone);
140const response1 = createSuccess(filtered, { total: filtered.length, page: 1, limit: 20 });
141console.log(JSON.stringify(response1, null, 2));
142
143// Simulation POST /dinosaurs
144console.log("\nPOST /dinosaurs");
145const body: CreateDinosaurBody = {
146  species: 'Stegosaurus',
147  diet: 'herbivore',
148  zone: 'zone-a',
149  weight: 3500,
150  dangerLevel: 1
151};
152const newDino: Dinosaur = { id: 'D004', ...body };
153const response2 = createSuccess(newDino);
154console.log(JSON.stringify(response2, null, 2));
155
156// Simulation GET /dinosaurs/unknown
157console.log("\nGET /dinosaurs/D999");
158const notFound = createError('NOT_FOUND', 'Dinosaure D999 introuvable');
159console.log(JSON.stringify(notFound, null, 2));
160
161// Infos sur les zones
162console.log("\n=== Zones du parc ===");
163for (const [id, zone] of Object.entries(config.zones)) {
164  console.log(`${id}: ${zone.name} (danger: ${zone.dangerLevel}, fence: ${zone.fenceVoltage}V)`);
165}

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Comment type-t-on les paramètres d'une requête (params, body, query) dans Express avec TypeScript ?

  2. 2. Quel type ont par défaut les variables process.env en TypeScript ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Définis les interfaces de configuration du serveur et de la base de données, ainsi qu'une fonction qui charge la configuration.

  • Tri horizontal

    Ordonne les éléments d'un route handler Express typé :

  • Tri vertical

    Ordonne l'ordre de traitement d'une requête HTTP dans Express typé :

  • Tri horizontal

    Ordonne dans le bon ordre les éléments d'un mapped type :

Articles utiles