Kurs JavaScript i TypeScript · Moduł 10: TypeScript w praktyce

TypeScript z Node.js - praktyczne wzorce

9 min czytania
W tej lekcji5

Kiedy zarządzasz infrastrukturą Parku Jurajskiego, potrzebujesz niezawodnego backendu: systemu autoryzacji, API do zarządzania strefami i middleware do logowania. Jeden handler, który przyjmie dangerLevel jako tekst zamiast liczby, potrafi zepsuć raport bezpieczeństwa całego parku. TypeScript w połączeniu z Node.js i Express pozwala budować serwery z pełnym type safety i eliminować całe klasy błędów, zanim uruchomisz kod.

Typowanie routes w Express

Domyślnie req.body ma w Expressie typ any, req.query to ogólny słownik ParsedQs, a req.params to słownik napisów. Zawężamy je generykiem Request<Params, ResBody, ReqBody, Query>, w którym kolejność parametrów ma znaczenie. Najpierw opisujemy dane API:

1import { Request, Response, NextFunction, Router } from 'express';
2
3// Interfejsy dla danych naszego 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// Typowany Request - 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'] to typ indeksowany: pobiera typ pola z innego interfejsu, więc lista diet istnieje tylko w jednym miejscu. limit i offset są napisami, bo tak przychodzą w adresie URL.

Teraz handler z typowanymi parametrami ścieżki. Pierwszy generyk Request opisuje params, a Response<T> pilnuje kształtu odpowiedzi:

1// Typowane handlery
2const router = Router();
3
4// GET /dinosaurs/:id - typowane params
5router.get('/dinosaurs/:id',
6  (req: Request<GetDinosaurParams>, res: Response<Dinosaur | { error: string }>) => {
7    const dinoId: string = req.params.id; // typowane!
8    // req.params.name; // Błąd kompilacji - nie ma "name" w 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);

Odwołanie do req.params.name to błąd kompilacji, a res.json() przyjmie tylko dinozaura albo obiekt błędu.

Query i body opisujemy kolejnymi generykami, wstawiając {} i any w pozycje, których nie potrzebujemy:

1// GET /dinosaurs - typowane query
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    // Filtrowanie na podstawie typowanych query params
9    res.json([]);
10  }
11);
12
13// POST /dinosaurs - typowane body
14router.post('/dinosaurs',
15  (req: Request<{}, any, CreateDinosaurBody>, res: Response<Dinosaur>) => {
16    const body = req.body;
17    // body.species: string - typowane!
18    // body.diet: 'herbivore' | 'carnivore' | 'omnivore' - typowane!
19    // body.unknownField; // Błąd kompilacji
20
21    const newDinosaur: Dinosaur = {
22      id: 'DINO-' + Date.now(),
23      ...body
24    };
25    res.status(201).json(newDinosaur);
26  }
27);

Pamiętaj, że to obietnica, a nie walidacja: klient może wysłać dowolny JSON, więc w produkcji sprawdzaj dane w runtime, na przykład biblioteką zod. Typ opisuje poprawne dane, ale ich nie gwarantuje.

Typowanie middleware

Middleware w Express to funkcje przetwarzające żądanie przed dotarciem do handlera. Dostają req, res i next, a przerywają łańcuch, wysyłając odpowiedź zamiast wywołać next(). Rozszerzamy interfejs żądania o dane zalogowanego użytkownika:

1// Middleware dodające userId do request
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: 'Brak tokenu autoryzacji' });
16    return;
17  }
18
19  // Weryfikacja tokenu (uproszczona)
20  try {
21    const decoded = verifyToken(token); // Twoja funkcja weryfikacji
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: 'Nieprawidłowy token' });
27  }
28}
29
30// Deklaracja pomocnicza
31declare function verifyToken(token: string): { userId: string; role: AuthenticatedRequest['role'] };
32
33// Middleware sprawdzające rolę
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: `Wymagana rola: ${roles.join(' lub ')}`
40      });
41      return;
42    }
43    next();
44  };
45}
46
47// Użycie w routach
48router.delete('/dinosaurs/:id',
49  authMiddleware,
50  requireRole('admin', 'scientist'),
51  (req: Request, res: Response) => {
52    const authReq = req as AuthenticatedRequest;
53    console.log(`User ${authReq.userId} usunął dinozaura ${req.params.id}`);
54    res.status(204).send();
55  }
56);

Kolejność jest więc taka: żądanie trafia do serwera, authMiddleware sprawdza token, requireRole weryfikuje rolę, a handler dostaje żądanie już po kontroli. verifyToken zwraca rolę typu AuthenticatedRequest['role'], więc przypisanie jest bezpieczne, a rzutowanie as AuthenticatedRequest to Twoja obietnica, że middleware wykonał się wcześniej.

Typowanie zmiennych środowiskowych

Zmienne środowiskowe (process.env) domyślnie mają typ string | undefined, bo podczas kompilacji nikt nie wie, czy zostaną ustawione. Walidujemy je więc przy starcie aplikacji:

1// Interfejs opisujący wymagane zmienne środowiskowe
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// Funkcja walidująca zmienne środowiskowe
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      `Brakujące zmienne środowiskowe: ${missing.join(', ')}`
24    );
25  }
26
27  return process.env as unknown as EnvironmentVariables;
28}
29
30// Użycie - bezpieczne i typowane
31const env = validateEnv();
32const port = parseInt(env.PORT);           // string -> number
33const dbUrl: string = env.DATABASE_URL;    // na pewno string, nie undefined
34const maxVisitors = parseInt(env.MAX_VISITORS);

as unknown as to podwójna asercja, którą mówisz kompilatorowi, że wiesz lepiej. Kod sprawdza obecność zmiennych, ale nie to, czy NODE_ENV ma dozwoloną wartość, więc taki test dopisz sam. Aplikacja bez sekretu JWT nie wstanie, zamiast działać z dziurą w zabezpieczeniach.

Wygodniej zamknąć konfigurację w klasie, która raz zamienia napisy na liczby i wartości logiczne:

1// Alternatywnie - klasa konfiguracji
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 - jedna instancja konfiguracji
22const config = new AppConfig();
23console.log(`${config.parkName} startuje na porcie ${config.port}`);

Pól readonly nie nadpiszesz po utworzeniu obiektu. Typ mapowany Readonly<T>, zapisany jako { readonly [K in keyof T]: T[K] }, robi to samo dla wszystkich pól naraz.

Typowanie obiektów konfiguracyjnych

Złożone konfiguracje wymagają głębokiego typowania. Każda sekcja dostaje własny interfejs, a całość łączy AppConfiguration:

1// Konfiguracja bazy danych
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// Konfiguracja serwera
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// Konfiguracja parku
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// Pełna konfiguracja aplikacji
49interface AppConfiguration {
50  server: ServerConfig;
51  database: DatabaseConfig;
52  park: ParkConfig;
53}
54
55// Funkcja ładująca konfigurację z walidacją
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: 'Strefa Herbivore', maxCapacity: 200, dangerLevel: 1, fenceVoltage: 5000 },
85        'zone-b': { name: 'Strefa Raptor', maxCapacity: 50, dangerLevel: 5, fenceVoltage: 15000 },
86        'zone-c': { name: 'Strefa 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// Użycie
98const appConfig = loadConfiguration();
99console.log(`Strefy parku: ${Object.keys(appConfig.park.zones).length}`);
100console.log(`Max raptor capacity: ${appConfig.park.zones['zone-b'].maxCapacity}`);

Literówka w nazwie pola albo napis zamiast liczby w fenceVoltage to błąd kompilacji, a nie awaria ogrodzenia o trzeciej w nocy. Record<string, {...}> opisuje strefy o dowolnych nazwach, ale jednakowym kształcie.

Typowanie odpowiedzi API (response wrappers)

Na koniec wspólny format odpowiedzi. Pole success o wartości true albo false tworzy unię dyskryminowaną (discriminated union):

1// Standardowa odpowiedź 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// Użycie w 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    // Symulacja danych
46    const dinosaurs: Dinosaur[] = [];
47    sendSuccess(res, dinosaurs, { total: 0, page, limit });
48  }
49);

Klient, który sprawdzi if (response.success), ma w tej gałęzi dostęp do data, a w drugiej do error, bo kompilator zawęża typ na podstawie jednego pola.

TypeScript z Node.js to potężne połączenie: typowane route'y, middleware, konfiguracja i odpowiedzi API sprawiają, że backend parku jest bezpieczny jak ogrodzenie pod napięciem 20 000 woltów, a kompilator wyłapie błędy, zanim uruchomisz serwer. Moja rada: zmienne środowiskowe waliduj przy starcie, a dane od klienta na wejściu do handlera, bo typy znikają po kompilacji. W edytorze poniżej zbudujesz typowane API parku, a w kolejnej lekcji zobaczysz, jak rozszerzać cudze typy przez module augmentation.

Pamiętaj: typy w backendzie to plan ogrodzeń, a walidacja w runtime to prąd w drutach, więc bezpieczny park potrzebuje obu.

Kod do tej lekcji: index.ts
1// TypeScript z Node.js - praktyczne wzorce
2// Park Jurajski - Backend API
3
4// 1. Typowane interfejsy dla 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. Typowanie zmiennych środowiskowych
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    // W prawdziwym projekcie: throw new Error(...)
42    console.log(`Uwaga: brakuje zmiennych: ${missing.join(', ')}`);
43  }
44  return process.env as Record<string, string>;
45}
46
47// 3. Typowane obiekty konfiguracyjne
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. Typowane odpowiedzi API
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. Symulacja serwera
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(`=== ${config.database.name.toUpperCase()} API ===`);
132console.log(`Server: 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// Symulacja 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// Symulacja 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// Symulacja GET /dinosaurs/unknown
157console.log("\nGET /dinosaurs/D999");
158const notFound = createError('NOT_FOUND', 'Dinozaur o id D999 nie istnieje');
159console.log(JSON.stringify(notFound, null, 2));
160
161// Zone info
162console.log("\n=== Strefy Parku ===");
163for (const [id, zone] of Object.entries(config.zones)) {
164  console.log(`${id}: ${zone.name} (danger: ${zone.dangerLevel}, fence: ${zone.fenceVoltage}V)`);
165}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jak typować parametry żądania (params, body, query) w Express z TypeScript?

  2. 2. Jaki typ mają domyślnie zmienne z process.env w TypeScript?

Zadania praktyczne w grze

  • Edytor kodu

    Zdefiniuj interfejsy konfiguracji serwera i bazy danych, oraz funkcję ładującą konfigurację.

  • Układanie w poziomie

    Ułóż elementy typowanego route handlera Express:

  • Układanie w pionie

    Ułóż kolejność przetwarzania żądania HTTP w typowanym Express:

  • Układanie w poziomie

    Ułóż elementy mapped type w odpowiedniej kolejności:

Przydatne artykuły