Kurs JavaScript i TypeScript · Moduł 10: TypeScript w praktyce
TypeScript z Node.js - praktyczne wzorce
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. Jak typować parametry żądania (params, body, query) w Express z TypeScript?
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: