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 בתיקייה משותפת. אותה סכמה מאמתת את הטופס בדפדפן ואת הבקשה בשרת, ושני הצדדים מקבלים את אותו טיפוס. שינוי בשדה אחד מסמן שגיאה בשני הצדדים יחד.

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. למה לא מספיק לכתוב טיפוס ל-req.body?

    1. כי Express לא תומך
    2. כי זו רק הבטחה: הלקוח יכול לשלוח כל דבר, וחייבים בדיקה בזמן ריצה
    3. זה מספיק
    4. כי זה איטי
    הצגת התשובה

    תשובה ב. Zod בודק את הקלט ובאותה הזדמנות נותן טיפוס עם z.infer.

  2. מה היתרון של אימות משתני סביבה עם סכמה בעליית השרת?

    1. משתנה חסר מתגלה מיד בעלייה עם הודעה ברורה, ולא באמצע בקשה
    2. אין
    3. השרת עולה מהר יותר
    4. זה מצפין אותם
    הצגת התשובה

    תשובה א. Fail fast: עדיף שהשרת לא יעלה מאשר יקרוס אחר כך.