TypeScript עם Node.js ו-Express
בצד השרת TypeScript שווה במיוחד: הנתונים עוברים בין מסד נתונים, לוגיקה ו-API, וטעות בשם שדה אחד יכולה לשבור הכול בשקט. השיעור מקים שרת Express ב-TypeScript, בסגנון שמתאים לקורס Express.
הקמת הפרויקט
mkdir api && cd api
npm init -y
npm pkg set type=module
npm install express zod
npm install --save-dev typescript @types/node @types/express
{
"type": "module",
"scripts": {
"dev": "node --watch --env-file=.env src/server.ts",
"start": "node src/server.ts",
"typecheck": "tsc --noEmit"
}
}
Node.js מריץ את קובצי ה-.ts ישירות, ו-tsc רק בודק. את tsconfig.json לקחו מהשיעור מודולים ו-tsconfig.
שרת בסיסי
// src/server.ts
import express from 'express';
import type { Request, Response } from 'express';
const app = express();
app.use(express.json());
app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok' });
});
const port = Number(process.env.PORT) || 3000;
app.listen(port, () => console.log(`http://localhost:${port}`));
בנתיב שכתוב ישירות בתוך app.get, הטיפוסים של req ו-res מוסקים לבד. כותבים אותם כשה-handler מוגדר בקובץ נפרד.
פרמטרים וגוף עם טיפוס
type Todo = { id: number; title: string; done: boolean };
// Request<Params, ResBody, ReqBody>
app.get('/api/todos/:id', (req: Request<{ id: string }>, res: Response<Todo | { error: string }>) => {
const todo = todos.find((item) => item.id === Number(req.params.id));
if (!todo) return res.status(404).json({ error: 'לא נמצא' });
res.json(todo);
});
הבעיה: req.body הוא הבטחה, לא בדיקה
אפשר לכתוב Request<{}, {}, { title: string }>, אבל זו רק הבטחה. הלקוח יכול לשלוח כל דבר. בגבולות של המערכת (קלט מבחוץ) חייבים לבדוק בזמן ריצה. Zod בודק, ובאותה הזדמנות נותן את הטיפוס:
import { z } from 'zod';
const createTodoSchema = z.object({
title: z.string().trim().min(1).max(200),
done: z.boolean().default(false),
});
// הטיפוס נגזר מהסכמה: אין שתי הגדרות שיכולות להתרחק זו מזו
type CreateTodoInput = z.infer<typeof createTodoSchema>;
app.post('/api/todos', (req, res) => {
const parsed = createTodoSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'קלט לא תקין', details: parsed.error.issues });
}
const input: CreateTodoInput = parsed.data; // מכאן והלאה: נתונים בדוקים ומוקלדים
const todo = createTodo(input);
res.status(201).json(todo);
});
משתני סביבה בדוקים
// src/env.ts
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
// אם משהו חסר, השרת נכשל מיד בעלייה, עם הודעה ברורה
export const env = envSchema.parse(process.env);
import { env } from './env.ts';
app.listen(env.PORT); // number, לא string | undefined
להוסיף שדה ל-req
אחרי Middleware של התחברות (כמו בשיעור ההתחברות), רוצים req.userId עם טיפוס:
// src/types/express.d.ts
declare global {
namespace Express {
interface Request {
userId?: number;
}
}
}
export {};
זה אחד המקרים הבודדים שבהם interface נחוץ: רק הוא יודע "להתמזג" עם הגדרה קיימת של ספרייה.
טיפוסים משותפים לשרת ולקוח
כש-React והשרת באותו מאגר (monorepo), אפשר לשים את סכמות ה-Zod בתיקייה משותפת. אותה סכמה מאמתת את הטופס בדפדפן ואת הבקשה בשרת, ושני הצדדים מקבלים את אותו טיפוס. שינוי בשדה אחד מסמן שגיאה בשני הצדדים יחד.
בדקו את עצמכם
נסו לענות לבד לפני שאתם פותחים את התשובה.
-
למה לא מספיק לכתוב טיפוס ל-
req.body?הצגת התשובה
תשובה ב. Zod בודק את הקלט ובאותה הזדמנות נותן טיפוס עם z.infer.
-
מה היתרון של אימות משתני סביבה עם סכמה בעליית השרת?
הצגת התשובה
תשובה א. Fail fast: עדיף שהשרת לא יעלה מאשר יקרוס אחר כך.