در یکی از پروژه‌های Node.js که برای یک استارتاپ نوشته بودم، با مورد عجیبی روبرو شدم: بیلد محلی بی‌خطا بود، اما در production، سرور بعد از حدود دو روز اجرا، به‌طور تصادفی خطا می‌داد. لاگ‌ها چیز خاصی نشان نمی‌دادند، پروفایلر هم نکته‌ای را برجسته نمی‌کرد. مشکل بعد از یک هفته جست‌وجو پیدا شد: یکی از ماژول‌های پروژه، از یک پکیج CommonJS استفاده می‌کرد که در `require` دینامیک، بعضی اوقات مقادیر `undefined` برمی‌گرداند و TS هم این را نمی‌دید چون آن ماژول با `any` تایپ شده بود. همان پروژه، نگاه من به ترکیب TypeScript و Node.js را تغییر داد: TS در backend یک لایه‌ی محافظتی است، اما فقط وقتی که از جزئیات محیط Node.js آگاه باشد.

Node.js و TypeScript یک ترکیب قدرتمند برای ساخت backend هستند، اما تفاوت‌های ظریفی با ترکیب TS و React دارند. در React، تمام کد شما در مرورگر اجرا می‌شود و بسیاری از پیچیدگی‌ها پشت کامپایلر پنهان است. در Node.js، کد شما مستقیماً با سیستم‌عامل، فایل‌ها، پایگاه‌داده و شبکه تعامل دارد و همین تعاملات، تله‌های جدیدی می‌سازند که بدون درک دقیق TS، در production ظاهر می‌شوند. اگر در مسیر آموزش تایپ اسکریپت از صفر هستید و مقالات تنظیمات tsconfig و ماژول ها در تایپ اسکریپت را خوانده‌اید، این نوشته تمام نقاط تماس TS با محیط اجرای Node.js را باز می‌کند.

چرا ترکیب TypeScript و Node.js یک انتخاب معماری است؟

Node.js در سال‌های اخیر به یکی از پرکاربردترین محیط‌های اجرای backend تبدیل شده. TypeScript در این محیط، سه مزیت اصلی را اضافه می‌کند که در پروژه‌های بزرگ تفاوت را می‌سازند:

  • محافظت از قراردادهای API: در یک API، هر endpoint یک قرارداد مشخص با کلاینت دارد. TS می‌تواند این قرارداد را در سطح تایپ تضمین کند و از تغییرات ناخواسته جلوگیری کند. مطالعه‌ی موازی در اینترفیس در تایپ اسکریپت.
  • محافظت از منطق دامنه: منطق کسب‌وکار معمولاً پیچیده‌تر از لایه‌ی HTTP است. TypeScript این لایه را با تایپ‌های دقیق محافظت می‌کند و از باگ‌های ظریف در محاسبات یا تبدیل‌ها جلوگیری می‌کند. مطالعه‌ی این لایه در کلاس در تایپ اسکریپت آمده است.
  • محافظت از تعامل با پایگاه‌داده: در پروژه‌های backend، تعامل با پایگاه‌داده یکی از پرخطرترین لایه‌هاست. TypeScript با ابزارهایی مثل Prisma و TypeORM می‌تواند تایپ دقیق مدل‌های داده را تضمین کند. مطالعه‌ی موازی در آموزش تایپ اسکریپت از صفر.

در پروژه‌ای که یک سیستم پرداخت داشتیم، بعد از مهاجرت از JS خالص به TS، تعداد باگ‌های مربوط به تایپ نادرست در لایه‌ی دامنه به‌طور محسوس کاهش پیدا کرد. مشکل قبلی این بود که در بعضی از محاسبات مالی، مقدار به‌جای number به‌صورت string پاس می‌شد و در عملیات ضرب، نتیجه NaN می‌شد. TS این نوع خطاها را در کامپایل می‌گیرد و به همین دلیل، سرمایه‌گذاری روی TS در backend، همیشه جواب می‌دهد.

TypeScript در Node.js، تفاوت بین یک اسکریپت اجرایی و یک سرویس پایدار است — و این تفاوت، در روزهای اول پروژه دیده نمی‌شود اما در ماه دوم به بعد، همه‌جا حاضر است.

راه‌اندازی اولین پروژه Node.js + TypeScript

راه‌اندازی یک پروژه‌ی Node.js با TypeScript چند مسیر دارد. سریع‌ترین روش با ابزارهای مدرن:

روش اول: tsx (سریع‌ترین)

mkdir my-app
cd my-app
npm init -y
npm install --save-dev typescript tsx @types/node
npx tsc --init

سپس در package.json:

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

روش tsx سریع‌ترین راه‌اندازی است چون نیازی به تنظیمات پیچیده ندارد و با esbuild زیر کاپوت، زمان startup را کوتاه می‌کند. مزیت: احتیاجی به `ts-node` و تنظیمات پیچیده‌ی ESM/CJS ندارید.

روش دوم: ts-node (سنتی)

npm install --save-dev ts-node typescript @types/node

این روش در پروژه‌های قدیمی‌تر کاربرد دارد اما در پروژه‌های جدید، tsx گزینه‌ی بهتری است.

روش سوم: بسته‌ی کامل با Fastify و Vitest

npm install fastify
npm install --save-dev typescript tsx vitest @types/node @vitest/coverage-v8

این ترکیب در پروژه‌های backend مدرن، به‌طور محسوسی بهره‌وری را بالا می‌برد.

در پروژه‌های جدید، من از ترکیب tsx + tsc + Vitest استفاده می‌کنم. tsx برای توسعه، tsc برای build نهایی، و Vitest برای تست. این سه ابزار، به‌طور طبیعی با هم کار می‌کنند و پیکربندی کمی می‌خواهند.

tsconfig مناسب برای Node.js

تنظیمات tsconfig برای Node.js با تنظیمات پروژه‌های React متفاوت است. یک tsconfig پایه برای Node.js مدرن:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "nodenext",
    "lib": ["ES2022"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,
    "declaration": true,
    "incremental": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

سه تنظیم که برای Node.js حیاتی هستند و در React کاربرد ندارند:

  • module: "NodeNext": این تنظیم به TS می‌گوید که از الگوریتم جدید Node.js برای مدیریت ماژول‌ها استفاده کند. این یعنی هم ESM و هم CommonJS کار می‌کنند، اما با قواعد دقیق‌تر. مطالعه‌ی بیشتر در ماژول ها در تایپ اسکریپت.
  • resolveJsonModule: true: این تنظیم اجازه می‌دهد که فایل‌های JSON را با import استفاده کنید. در Node.js، معمولاً برای بارگذاری تنظیمات یا داده‌های ثابت کاربرد دارد.
  • verbatimModuleSyntax: true: این تنظیم کامپایلر را مجبور می‌کند که تفاوت import و import type را دقیق رعایت کند. در Node.js، این تفاوت حیاتی است چون بعضی از importها side effect دارند. مطالعه‌ی موازی در تنظیمات tsconfig.

یک نکته‌ی ظریف: اگر از module: "NodeNext" استفاده می‌کنید، باید در فایل‌های ESM، پسوند .js در importها بنویسید حتی اگر فایل .ts باشد. این رفتار عجیب، یکی از بیشترین سردرگمی‌های توسعه‌دهنده‌ها در Node.js مدرن است:

// در فایل .ts، باید .js بنویسید
import { helper } from "./helper.js";

دلیلش این است که TS پس از کامپایل، فایل‌ها را به .js تبدیل می‌کند و import نهایی به .js اشاره دارد. اگر .js ننویسید، خروجی کامپایل شده به مسیر اشتباه اشاره می‌کند.

تایپ‌های @types/node و مدیریت ماژول‌ها

Node.js خودش با TS نوشته نشده و تایپ‌هایش را از پکیج @types/node می‌گیریم:

npm install --save-dev @types/node

این پکیج شامل تایپ‌های تمام APIهای Node.js است: fs، path، http، crypto، events و غیره. بدون این پکیج، TS خطاهای زیادی روی استفاده از APIهای Node.js می‌دهد.

مدیریت تایپ‌های کتابخانه‌های خارجی

در پروژه‌های واقعی، برای هر کتابخانه‌ی خارجی که تایپ رسمی ندارد، باید پکیج @types/... نصب کنید:

npm install --save-dev @types/express @types/jsonwebtoken @types/bcrypt

اگر کتابخانه‌ای هم تایپ رسمی و هم @types دارد، همیشه نسخه‌ی داخلی اولویت دارد چون با نسخه‌ی کتابخانه هم‌زمان به‌روزرسانی می‌شود. برای کتابخانه‌های قدیمی که تایپ ندارند، باید یک فایل .d.ts دستی بنویسید:

// types/legacy-lib.d.ts
declare module "legacy-lib" {
  export function doSomething(input: string): number;
  export const VERSION: string;
}

این الگو در پروژه‌هایی که با کتابخانه‌های قدیمی کار می‌کنند، پرکاربرد است. مطالعه‌ی موازی در تایپ ها در تایپ اسکریپت.

تایپ‌دهی به Express و Middlewareها

Express یکی از پرکاربردترین فریم‌ورک‌های Node.js است. تایپ‌دهی آن چند نکته‌ی ظریف دارد:

تایپ Request و Response

import { Request, Response, NextFunction } from "express";

app.get("/users/:id", (req: Request, res: Response) => {
  const { id } = req.params;
  res.json({ userId: id });
});

در این ساختار پایه، req.params به‌طور پیش‌فرض از نوع ParamsDictionary است که ارزش کلیدی‌هایش string | undefined است. اگر می‌خواهید تایپ دقیق‌تری داشته باشید، از Generic استفاده کنید:

interface UserRouteParams {
  id: string;
}

app.get("/users/:id", (req: Request<UserRouteParams>, res: Response) => {
  const { id } = req.params;  // id: string دقیقاً
});

تایپ Body در POST

interface CreateUserBody {
  name: string;
  email: string;
}

app.post("/users", (req: Request<{}, {}, CreateUserBody>, res: Response) => {
  const { name, email } = req.body;
  // ...
});

ترتیب پارامترهای Generic در Express: Request<Params, ResBody, ReqBody, ReqQuery>. اگر بخشی را نمی‌خواهید مشخص کنید، از {} استفاده کنید.

گسترش Request با فیلدهای سفارشی

یکی از پرکاربردترین الگوها، اضافه کردن فیلد user به Request بعد از احراز هویت است:

// types/express.d.ts
import { User } from "../models/User";

declare global {
  namespace Express {
    interface Request {
      user?: User;
    }
  }
}

// در middleware
app.use((req, res, next) => {
  const token = req.headers.authorization;
  req.user = verifyToken(token);
  next();
});

// در route
app.get("/me", (req, res) => {
  if (!req.user) return res.status(401).json({ error: "Unauthorized" });
  res.json({ user: req.user });
});

این الگو با Declaration Merging کار می‌کند که در اینترفیس در تایپ اسکریپت به‌تفصیل توضیح داده‌ام.

خطاهای Express با async/await

Express به‌طور پیش‌فرض خطاهای async را مدیریت نمی‌کند. یکی از الگوهای مفید، استفاده از یک wrapper است:

type AsyncHandler = (
  req: Request,
  res: Response,
  next: NextFunction
) => Promise<any>;

function asyncHandler(fn: AsyncHandler) {
  return (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);
}));

این الگو در پروژه‌های واقعی از باگ‌های پنهان جلوگیری می‌کند، چون بدون آن، یک خطای async در middleware می‌تواند سرور را در وضعیت ناپایدار قرار دهد. مطالعه‌ی موازی در مدیریت خطا در جاوااسکریپت.

Prisma، Mongoose و پایگاه‌داده در TS

یکی از بزرگ‌ترین مزایای TS در backend، تایپ‌دهی به لایه‌ی پایگاه‌داده است. دو ابزار اصلی در این حوزه:

Prisma

Prisma از schema خودش، تایپ‌های TypeScript را خودکار تولید می‌کند:

// schema.prisma
model User {
  id    Int    @id @default(autoincrement())
  name  String
  email String @unique
  posts Post[]
}

// در کد TypeScript - تایپ‌ها خودکار تولید شده‌اند
const user = await prisma.user.findUnique({
  where: { id: 1 },
  include: { posts: true }
});
// user: (User & { posts: Post[] }) | null

مزیت Prisma: تایپ دقیق تمام queryها، includeها و whereها. اگر یک فیلد اشتباه در query بنویسید، کامپایلر در همان لحظه خطا می‌دهد. این یک لایه‌ی محافظت عالی برای لایه‌ی داده است.

Mongoose

برای MongoDB، Mongoose گزینه‌ی استاندارد است اما تایپ‌دهی آن پیچیده‌تر است چون schema در runtime تعریف می‌شود. الگوی درست:

interface IUser {
  name: string;
  email: string;
}

const userSchema = new Schema<IUser>({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true }
});

const User = model<IUser>("User", userSchema);

// استفاده
const user = await User.findOne({ email: "ali@example.com" });
// user: (IUser & Document) | null

نکته‌ی ظریف در Mongoose: تایپ `IUser` فقط ساختار داده را تعریف می‌کند، اما Mongoose به فیلدهای داخلی مثل `_id`، `save()` و `populate()` هم دسترسی می‌دهد که در قالب `Document` می‌آیند. اگر از این ترکیب به‌درستی استفاده نکنید، ممکن است در بعضی موارد auto-complete ناقص کار کند.

در پروژه‌های جدید، Prisma انتخاب اول من است چون تایپ‌های خودکار آن، بار نگهداری را به‌طور محسوس کم می‌کند. Mongoose گزینه‌ی خوبی برای MongoDB است اما تایپ‌دهی دستی بیشتری می‌خواهد. مطالعه‌ی موازی در اتصال به پایگاه‌داده.

در پروژه‌های backend، بیشترین ارزش TypeScript در لایه‌ی داده ظاهر می‌شود؛ چون همان لایه، بیشترین باگ‌های runtime را در پروژه‌های JS تولید می‌کند.

مدیریت خطا در async/await و Error Handling

مدیریت خطا در Node.js با TypeScript ظرافت‌های خاص خودش را دارد. مشکل اصلی: در catch، تایپ خطا `unknown` است نه `Error`:

try {
  await fetchData();
} catch (error) {
  // error: unknown (با strict: true)
  console.log(error.message);  // خطای کامپایل!
}

الگوی درست:

try {
  await fetchData();
} catch (error) {
  if (error instanceof Error) {
    console.log(error.message);
  } else {
    console.log("Unexpected error:", error);
  }
}

در پروژه‌های بزرگ، من از یک helper برای یکدستی استفاده می‌کنم:

function toError(value: unknown): Error {
  if (value instanceof Error) return value;
  if (typeof value === "string") return new Error(value);
  return new Error(String(value));
}

try {
  await fetchData();
} catch (error) {
  const err = toError(error);
  logger.error(err.message);
}

Custom Error Classes

در پروژه‌های backend، معمولاً چند نوع خطا دارید (خطای اعتبارسنجی، خطای احراز هویت، خطای پایگاه‌داده). TypeScript با Custom Error Classها این تفکیک را در تایپ‌ها ممکن می‌کند:

class ValidationError extends Error {
  constructor(
    public field: string,
    message: string
  ) {
    super(message);
    this.name = "ValidationError";
  }
}

class AuthError extends Error {
  constructor(message: string, public statusCode = 401) {
    super(message);
    this.name = "AuthError";
  }
}

// استفاده
try {
  await createUser(data);
} catch (error) {
  if (error instanceof ValidationError) {
    res.status(400).json({ field: error.field, message: error.message });
  } else if (error instanceof AuthError) {
    res.status(error.statusCode).json({ message: error.message });
  } else {
    res.status(500).json({ message: "Internal error" });
  }
}

این الگو، خطاهای سرور شما را قابل‌فهم‌تر و قابل‌دیباگ‌تر می‌کند. در پروژه‌ای که یک سیستم پرداخت داشتیم، استفاده از Custom Error Classها زمان دیباگ را حدود ۴۰٪ کاهش داد چون logها مستقیماً نوع خطا و context را نشان می‌دادند. مطالعه‌ی موازی در مدیریت خطا در تایپ اسکریپت.

متغیرهای محیطی و Config با Type Safety

یکی از پرتکرارترین باگ‌های production در Node.js، مربوط به متغیرهای محیطی است. مثلاً یک endpoint به API خارجی نیاز دارد که URL آن از environment خوانده می‌شود و اگر آن متغیر تعریف نشده باشد، در runtime خطا می‌دهد:

const apiUrl = process.env.API_URL;  // string | undefined
fetch(`${apiUrl}/users`);            // خطای runtime اگر undefined باشد

الگوی درست: اعتبارسنجی environment در startup:

function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    throw new Error(`Missing environment variable: ${key}`);
  }
  return value;
}

const config = {
  apiUrl: requireEnv("API_URL"),
  port: Number(requireEnv("PORT")),
  databaseUrl: requireEnv("DATABASE_URL"),
  jwtSecret: requireEnv("JWT_SECRET")
} as const;

export type Config = typeof config;

در پروژه‌های بزرگتر، از کتابخانه‌هایی مثل zod یا envalid استفاده می‌کنم:

import { z } from "zod";

const envSchema = z.object({
  API_URL: z.string().url(),
  PORT: z.coerce.number().int().positive(),
  DATABASE_URL: z.string().min(1),
  JWT_SECRET: z.string().min(32)
});

const env = envSchema.parse(process.env);
// env: { API_URL: string; PORT: number; DATABASE_URL: string; JWT_SECRET: string }

با این الگو، اگر کسی متغیر محیطی را فراموش کند، سرور در startup خطا می‌دهد نه در runtime و وسط یک درخواست کاربر. این یک لایه‌ی محافظت ساده اما بسیار مؤثر است.

اعتبارسنجی ورودی با Zod و Type Guards

در Node.js، داده‌ی ورودی از منابع مختلف (body، query، params، external API) می‌آید و در TS، تایپ‌ها در runtime وجود ندارند. این یعنی حتی اگر `req.body` را به‌عنوان یک تایپ مشخص علامت بزنید، در runtime نمی‌توانید مطمئن باشید که داده واقعاً همان تایپ را دارد.

راه‌حل: استفاده از کتابخانه‌های اعتبارسنجی که هم validation و هم type guard را با هم انجام می‌دهند:

import { z } from "zod";

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  age: z.number().int().min(0).max(120).optional()
});

type CreateUserInput = z.infer<typeof createUserSchema>;
// { name: string; email: string; age?: number }

app.post("/users", (req, res) => {
  const result = createUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ errors: result.error.errors });
  }

  const data: CreateUserInput = result.data;
  // حالا داده در TS و runtime یکسان است
});

مزیت این الگو: یک منبع واحد برای تایپ و validation. اگر schema را تغییر دهید، تایپ‌ها خودکار به‌روز می‌شوند. این یک لایه‌ی محافظت قوی در لایه‌ی HTTP است که بدون آن، باگ‌های runtime بسیار رایج هستند. مطالعه‌ی موازی در تایپ ها در تایپ اسکریپت.

Build و استقرار در production

در Node.js، دو روش اصلی برای اجرای TypeScript در production وجود دارد:

روش اول: کامپایل با tsc

npm run build    # tsc
npm start        # node dist/index.js

مزیت: کد نهایی JavaScript استاندارد است و در سرور نیازی به TS ندارد. عیب: زمان build اضافه، و در بعضی موارد خطاهای runtime که در توسعه با tsx دیده نشده‌اند.

روش دوم: اجرای مستقیم با tsx یا ts-node

tsx src/index.ts

مزیت: بدون build، ساده‌تر. عیب: مصرف حافظه بیشتر، زمان startup کندتر، و در بعضی سناریوها، عملکرد پایین‌تر.

روش پیشنهادی من: build در CI، اجرا در production

{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc --noEmit false",
    "start": "node --enable-source-maps dist/index.js",
    "typecheck": "tsc --noEmit"
  }
}

در این الگو، در زمان توسعه از tsx استفاده می‌کنید و در production، کد کامپایل شده‌ی نهایی را با node اجرا می‌کنید. --enable-source-maps هم باعث می‌شود که stack traceها به کد TypeScript اشاره کنند، نه به JavaScript کامپایل شده. این یک قابلیت مهم برای debug production است.

Docker و deployment

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "--enable-source-maps", "dist/index.js"]

این الگوی multi-stage، حجم image نهایی را کاهش می‌دهد چون devDependencies و کدهای TypeScript در image production نیستند. مطالعه‌ی موازی در ابزارهای CI/CD.

الگوهای واقعی در پروژه‌ها

سه الگویی که در پروژه‌های Node.js + TS خودم بیشترین استفاده را داشته‌اند:

  1. Service Layer با تایپ‌های دقیق: در یک پروژه‌ی فروشگاهی، تمام منطق دامنه در Serviceها متمرکز بود و هر Service یک interface مشخص داشت. این الگو امکان می‌داد که در تست‌ها، Service را با mock جایگزین کنیم. مطالعه‌ی بیشتر در کلاس در تایپ اسکریپت.
  2. Result Type برای مدیریت خطا: به‌جای استفاده از throw/catch در همه‌جا، در Serviceها از یک Result Type الهام‌گرفته از Rust استفاده می‌کردیم:
type Result<T, E = Error> =
  | { ok: true; value: T }
  | { ok: false; error: E };

async function findUser(id: string): Promise<Result<User>> {
  try {
    const user = await db.user.findUnique({ where: { id } });
    if (!user) {
      return { ok: false, error: new Error("User not found") };
    }
    return { ok: true, value: user };
  } catch (error) {
    return { ok: false, error: toError(error) };
  }
}

// استفاده
const result = await findUser("1");
if (result.ok) {
  res.json(result.value);
} else {
  res.status(404).json({ message: result.error.message });
}

این الگو، جریان خطا را در سطح تایپ نمایان می‌کند و از فراموش‌کردن try/catch جلوگیری می‌کند.

  1. Repository Pattern با Generics: برای لایه‌ی دسترسی به داده، از یک Repository Generic استفاده می‌کردیم که در تمام entityها یکسان بود:
interface Repository<T, ID = string> {
  findById(id: ID): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(data: Omit<T, "id">): Promise<T>;
  update(id: ID, data: Partial<T>): Promise<T | null>;
  delete(id: ID): Promise<void>;
}

class PrismaUserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    return prisma.user.findUnique({ where: { id } });
  }
  // ...
}

این الگو، حجم کد لایه‌ی داده را کاهش می‌دهد و تست‌پذیری را بالا می‌برد. مطالعه‌ی موازی در جنریک در تایپ اسکریپت و اینترفیس در تایپ اسکریپت.

اشتباهاتی که در پروژه‌ها دیدم

  • استفاده از any برای process.env: process.env.API_URL به‌طور پیش‌فرض string | undefined است. اگر بدون چک کردن از آن استفاده کنید، خطا در runtime می‌گیرید. راه‌حل: از یک الگوی requireEnv یا Zod استفاده کنید.
  • عدم استفاده از source maps در production: اگر --enable-source-maps را فعال نکنید، stack traceها به فایل‌های کامپایل‌شده اشاره می‌کنند و دیباگ production تقریباً غیرممکن می‌شود.
  • مخلوط کردن CommonJS و ES Modules: در پروژه‌ای که نیمی از کد با require و نیمی با import بود، رفتار در runtime غیرقابل پیش‌بینی شد. از یک سیستم ماژول یکدست استفاده کنید. مطالعه‌ی بیشتر در ماژول ها در تایپ اسکریپت.
  • نادیده گرفتن strict در tsconfig: در backend، بعضی تیم‌ها strict را غیرفعال می‌کنند چون فکر می‌کنند پیچیدگی اضافه می‌کند. این یک اشتباه پرهزینه است چون backend معمولاً با داده‌های حساس کار می‌کند. مطالعه‌ی بیشتر در تنظیمات tsconfig.
  • عدم مدیریت خطا در async middleware: Express به‌طور پیش‌فرض خطاهای async را مدیریت نمی‌کند. راه‌حل: از الگوی asyncHandler استفاده کنید یا به Fastify و Koa مهاجرت کنید.
  • تایپ‌دهی ضعیف به req.user: اگر User را بدون اختیاری کردن تایپ کنید، در middlewareهایی که قبل از احراز هویت اجرا می‌شوند، خطای کامپایل می‌گیرید. همیشه user?: User را اختیاری علامت‌گذاری کنید.
  • نادیده گرفتن noUncheckedIndexedAccess: بدون این تنظیم، دسترسی به آرایه همیشه غیرقابل‌خطا فرض می‌شود. در backend که با داده‌های خارجی کار می‌کنید، این تنظیم یک لایه‌ی محافظت مهم است.
  • استفاده از Object.keys بدون type guard: Object.keys تایپ string[] برمی‌گرداند نه (keyof T)[]. برای تایپ دقیق، نیاز به یک helper یا cast دارید.
  • عدم استفاده از Zod برای validation: در پروژه‌هایی که فقط از تایپ‌های TS استفاده می‌کنند، داده‌های ورودی می‌توانند در runtime تایپ اشتباه داشته باشند. Zod هم validation و هم type inference را با هم انجام می‌دهد.
  • نادیده گرفتن source maps در Docker: در پروژه‌ای، خطاهای production را در local debug می‌کردیم و همیشه نتیجه اشتباه بود چون image نهایی source maps نداشت. حتماً source maps را در image production هم قرار دهید.

لایه‌ای پایین‌تر از سینتکس سرور

اینجا وارد لایه‌ای می‌شوم که در پروژه‌های معمولی به آن نگاه نمی‌شود اما برای مهندسان پلتفرم و توسعه‌دهنده‌های ارشد اهمیت دارد. آنچه محیط Node.js و TypeScript با کد شما می‌کنند، در پنج مفهوم خلاصه می‌شود:

  1. V8 Engine و Type Erasure: Node.js از موتور V8 استفاده می‌کند که همان موتور Chrome است. کد TypeScript شما در زمان کامپایل به JavaScript تبدیل می‌شود و V8 هیچ اطلاعاتی از تایپ‌های TS ندارد. این یعنی هزینه‌ی runtime TS صفر است — نه کارایی، نه حافظه. اما این ویژگی یک پیامد عملی دارد: در runtime، TS هیچ محافظتی نمی‌کند. اگر Validation در سمت سرور مهم است، باید با Zod یا دستی انجام شود. مطالعه‌ی موازی این لایه در بهینه سازی جاوااسکریپت.
  2. Module Resolution و Node.js ESM: Node.js در نسخه‌های اخیر پشتیبانی از ESM را اضافه کرده اما پیچیدگی‌های خاصی دارد: نیاز به پسوند .js در importها، محدودیت در __dirname، و تفاوت در بارگذاری ماژول‌ها. TypeScript با module: "NodeNext" این پیچیدگی‌ها را مدیریت می‌کند اما به شرطی که تنظیمات دقیق باشد. مطالعه‌ی موازی در ماژول ها در تایپ اسکریپت.
  3. Node.js Event Loop و Blocking Operations: Node.js یک محیط single-threaded است و عملیات blocking می‌تواند کل سرور را متوقف کند. TypeScript این لایه را از نظر تایپ محافظت نمی‌کند اما فهم این مفهوم در طراحی معماری مؤثر است. مثلاً استفاده از fs.readFileSync در handler یک endpoint HTTP، می‌تواند کل سرور را متوقف کند. راه‌حل: نسخه‌های async یا Worker Threads. مطالعه‌ی موازی در مفاهیم پیشرفته جاوااسکریپت.
  4. Memory Management و GC در Long-Running Processes: سرورهای Node.js ساعت‌ها و روزها بدون restart اجرا می‌شوند. این یعنی نشتی حافظه، اثر تجمعی دارد و در نهایت سرور را از پا در می‌آورد. TypeScript این موضوع را حل نمی‌کند اما در طراحی الگوهای کلاس و Closure می‌تواند به کاهش آن کمک کند. مطالعه‌ی موازی در بهینه سازی جاوااسکریپت و بهینه‌سازی سرعت سایت.
  5. Interaction با Deployment و Container: در production، TypeScript به‌عنوان یک لایه‌ی build-time وجود دارد. این یعنی روش build (tsc، esbuild، SWC)، اندازه‌ی image Docker، source maps و ترتیب اجرای scriptها همه روی رفتار production اثر می‌گذارند. در پروژه‌ای که از SWC برای build استفاده می‌کردیم، به‌دلیل نبود type-checking در build، چند خطای تایپ در production پیدا شد. راه‌حل: استفاده‌ی جداگانه از tsc --noEmit در CI/CD. مطالعه‌ی موازی در ابزارهای CI/CD و گیت در وردپرس.

یک تجربه‌ی واقعی از پروژه‌ای که با Event Loop مواجه شدیم: در یک API که گزارش‌های CSV تولید می‌کرد، از یک کتابخانه‌ی قدیمی استفاده می‌کردیم که در تولید CSV بزرگ، عملیات blocking انجام می‌داد. نتیجه: در حین تولید گزارش، تمام درخواست‌های دیگر با تأخیر چند ثانیه‌ای مواجه می‌شدند. راه‌حل: انتقال این عملیات به Worker Threads و استفاده از یک queue برای گزارش‌ها. این تغییر، پایداری سرور را به‌طور محسوس بهبود داد و امکان تولید همزمان چند گزارش را ممکن کرد.

اگر روی پروژه‌های وردپرسی هستید و می‌خواهید این لایه‌ها را در development pipeline خود اعمال کنید، پیشنهاد می‌کنم ابتدا به توسعه وردپرس از صفر نگاهی بیندازید. برای مطالعه‌ی موازی با استانداردها و معماری، استانداردهای HTML و CSS و CSS مدرن از Flexbox تا Grid دید وسیع‌تری می‌دهند. برای درک این لایه در چارچوب کارایی، بهینه سازی جاوااسکریپت و بهینه‌سازی سرعت سایت منابع کلیدی هستند. اگر روی موضوع فریم‌ورک‌های backend متمرکز هستید، بک‌اند چیست و آیا Node.js برای بک‌اند مناسب است دید وسیع‌تری می‌دهند. اگر هم به سمت پایگاه‌داده و اتصال می‌روید، آموزش MySQL از صفر و اتصال به پایگاه‌داده منابع کلیدی هستند.

TypeScript در Node.js مثل چتر در یک سفر کوهستانی است: همیشه لازم نیست، اما روزی که لازم شود، بدون آن نمی‌توانید ادامه دهید. تفاوت بین یک backend پایدار و یک backend شکننده، در همین جزئیات پنهان است.

ایستگاه پایانی این مسیر

ترکیب TypeScript و Node.js را می‌توان در یک جمله خلاصه کرد: «ابزاری برای محافظت از قراردادهای backend در سطح کد، از لایه‌ی HTTP تا پایگاه‌داده.» سه درس که از این مسیر با خودم بردم:

  1. محیط اجرا را جدی بگیرید. تفاوت‌های ظریف Node.js با React — مثل تفاوت ESM و CommonJS، event loop و مدیریت خطا — تأثیر مستقیم روی رفتار TS دارند. اگر این تفاوت‌ها را نشناسید، حتی کد تمیز TS می‌تواند در production شکست بخورد.
  2. Validation و تایپ را یکپارچه کنید. در backend، تایپ‌های TS در runtime وجود ندارند. استفاده از Zod یا الگوهای مشابه، این شکاف را پر می‌کند و تضمین می‌کند که داده‌ی ورودی واقعاً با تایپ‌های تعریف‌شده هم‌خوانی دارد.
  3. Build pipeline را آگاهانه بچینید. استفاده از tsx در توسعه و node در production، همراه با source maps و type-check جداگانه در CI، تفاوت بین یک چرخه‌ی توسعه‌ی سریع و یک دیباگ production را می‌سازد.

مسیر یادگیری backend با این نوشته تمام نمی‌شود. اگر می‌خواهید مرحله‌ی بعدی را بردارید، آموزش تایپ اسکریپت از صفر، تنظیمات tsconfig و ماژول ها در تایپ اسکریپت سه قدم منطقی بعدی هستند. اگر روی فریم‌ورک‌های backend متمرکز هستید، بک‌اند چیست، آیا Node.js برای بک‌اند مناسب است و بهترین زبان‌های بک‌اند دید وسیع‌تری می‌دهند. اگر هم به سمت پایگاه‌داده و معماری می‌روید، آموزش MySQL از صفر، ترندهای معماری وب و ابزارهای CI/CD منابع کلیدی هستند.

ترکیب TypeScript با Node.js همیشه یکی از آن تصمیم‌هایی است که در ابتدای پروژه کمی ساده به‌نظر می‌رسد اما در ماه‌های بعد، جزئیات ظریف آن — از ESM و CommonJS تا memory management و event loop — تعیین‌کننده می‌شوند. اگر شما هم تجربه‌ای از یک باگ production دارید که ریشه‌اش در یکی از این جزئیات بود — یا از یک الگوی Service Layer یا Result Type که پایداری backend را چند برابر کرد — آن تجربه را برای ما تعریف کنید. آن نوع داستان‌ها، برای کسی که امروز در حال ساخت یک backend جدید است، ارزش عملی بیشتری از هر مستند رسمی دارند.