طوال عشر سنوات، كان تشغيل TypeScript على الخادم يعني القبول بخطوة بناء. تُثبّت حزمة typescript، وتضبط tsc، وتُنتج مجلد dist/، ثم تقضي قدراً مفاجئاً من عمرك في تصحيح السبب وراء اختلاف ما شغّلته عمّا كتبته. أو تلجأ إلى ts-node، ثم tsx، فتستبدل زمن البناء بزمن الإقلاع البارد.
يُنهي Node.js 26 هذا الاتفاق. تجريد الأنواع (type stripping) صار مستقراً ومُفعّلاً افتراضياً — يكفي أن تكتب node server.ts فيعمل. بلا مُحمّل، بلا راية، بلا مترجم.
لكن عبارة "يعمل ببساطة" تُخفي حدّاً حادّاً. فقد أزال Node.js 26 أيضاً الراية --experimental-transform-types بالكامل. إن كانت شيفرتك تستخدم enums أو decorators أو خصائص المُعامِلات، وكنت تعتمد على Node لتحويلها، فتلك الشيفرة لم تعد تعمل. يعلّمك هذا الدرس النصفين معاً: كيف تبني خدمة إنتاجية حقيقية بلا خطوة بناء، وما هي مزايا TypeScript التي عليك التخلي عنها بالضبط للوصول إلى ذلك.
المتطلبات المسبقة
قبل البدء، تأكّد من توفّر:
- Node.js 26.0.0 أو أحدث (تحقّق عبر
node --version) - TypeScript 5.8+ كاعتمادية تطوير من أجل فحص الأنواع
- إلمام بوحدات ES ونمط
async/awaitوأساسيات REST - محرّر شيفرة يدعم TypeScript (يُنصح بـ VS Code)
- تثبيت Docker إن أردت متابعة خطوة النشر
ما لا تحتاجه: ts-node أو tsx أو nodemon أو حزّام أو مجلد dist/.
ما الذي ستبنيه
واجهة REST كاملة لإدارة المهام تتضمّن:
- نقاط نهاية CRUD فوق
node:httpوحده — بلا Express وبلا Fastify - تخزيناً دائماً عبر
node:sqlite، مشغّل SQLite المدمج - تحقّقاً من صحة الطلبات في زمن التشغيل مكتوباً كدوال عادية
- حزمة اختبارات تعمل على
node --test - فحص أنواع مفروضاً في التكامل المستمر كبوابة منفصلة
- صورة Docker من مرحلة واحدة بلا أي مخرجات بناء
الخدمة النهائية تملك صفر اعتماديات في زمن التشغيل. الشيء الوحيد داخل node_modules هو TypeScript نفسه، وهو لا يعمل أبداً في الإنتاج.
الخطوة 1: افهم ما الذي تغيّر فعلاً
قبل كتابة أي شيفرة، اضبط النموذج الذهني. Node لا يترجم TypeScript الخاص بك، بل يمحوه.
حين يُحمّل Node ملف .ts، تقوم وحدة اسمها amaro (غلاف رفيع حول مُحلّل SWC) بإزالة كل تعليق نوعي واستبداله بـ مسافات بيضاء. تبقى أرقام الأسطر وإزاحات الأعمدة متطابقة بايتاً ببايت، ولهذا لا تحتاج إلى خرائط مصدر — فتتبّع المكدّس يشير أصلاً إلى السطر الصحيح في ملفك الأصلي.
تحقّق من إعدادك:
node --version
# v26.0.0 أو أحدثأنشئ ملفاً تجريبياً لتأكيد عمل التجريد:
// scratch.ts
type Greeting = { name: string; formal: boolean };
function greet({ name, formal }: Greeting): string {
return formal ? `Good evening, ${name}.` : `Hey ${name}!`;
}
console.log(greet({ name: "Amira", formal: true }));شغّله مباشرة:
node scratch.ts
# Good evening, Amira.بلا راية. بلا إعدادات. هذه هي الميزة بأكملها.
تنتج عن مبدأ "امحُ ولا تترجم" نتيجتان مهمّتان:
Node لا يُجري أي فحص للأنواع إطلاقاً. سيُنفّذ زمن التشغيل بكل رضا شيفرةً أنواعها مكسورة تماماً. السطر const x: number = "hello" يعمل بلا مشكلة. تصبح سلامة الأنواع شأناً يخصّ التكامل المستمر لا زمن التشغيل — ونعالج ذلك في الخطوة 9.
كل ما يتطلّب توليد شيفرة في زمن التشغيل مستحيل. فـ enum ليس نوعاً فحسب؛ إنه يُترجم إلى كائن JavaScript حقيقي. لا يوجد ما يُمحى، لذا يرفضه Node. هذا مصدر كل صداع الترحيل المذكور أدناه.
الخط الزمني، لتعرف ما تتوقعه على الإصدارات الأقدم
| إصدار Node | حالة تجريد الأنواع |
|---|---|
| 22.x | خلف الراية --experimental-strip-types |
| 23.x | بلا راية، لكنه تجريبي |
| 24.x | افتراضي لملفات .ts |
| 25.2+ | مُعلَن مستقراً |
| 26.x | مستقر، والراية --experimental-transform-types أُزيلت |
إن احتجت لتعطيله لأي سبب، فالراية هي --no-strip-types.
الخطوة 2: تهيئة المشروع
أنشئ هيكل المشروع:
mkdir task-api && cd task-api
npm init -y
npm install --save-dev typescript @types/nodeهذه قائمة الاعتماديات كاملةً. الآن عدّل package.json:
{
"name": "task-api",
"version": "1.0.0",
"type": "module",
"engines": {
"node": ">=26.0.0"
},
"scripts": {
"dev": "node --watch --env-file-if-exists=.env src/server.ts",
"start": "node --env-file-if-exists=.env src/server.ts",
"test": "node --test 'src/**/*.test.ts'",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.8.0",
"@types/node": "^26.0.0"
}
}لاحظ "type": "module". يعمل تجريد الأنواع الأصلي على أفضل وجه مع وحدات ES، وقد انتقلت المنظومة بأكملها إلى هناك. ولاحظ أيضاً أن dev وstart يشيران إلى الملف TypeScript نفسه — لا توجد نقطة دخول إنتاجية منفصلة.
ملف tsconfig الذي يهمّ
هذا أهم ملف في المشروع. إن أخطأت فيه، فسيقبل TypeScript بكل سرور شيفرةً يرفض Node تشغيلها.
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"lib": ["esnext"],
"types": ["node"],
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true,
"allowImportingTsExtensions": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}أربعة من هذه الخيارات موجودة تحديداً لتُبقيك أميناً:
erasableSyntaxOnly هو النجم. يجعل tsc يرفض أي بنية لا يستطيع Node تجريدها — enums، والفضاءات الاسمية ذات الأعضاء التنفيذية، وخصائص المُعامِلات. بدونه تكتشف الأمر في زمن التشغيل داخل الإنتاج. معه تكتشفه في محرّرك.
verbatimModuleSyntax يُجبرك على كتابة import type صراحةً. وهذا ليس أمراً تجميلياً. فمُجرّد Node مُحلّل لا فاحص أنواع — ليست لديه أدنى فكرة عمّا إذا كان Task في import { Task } from './types.ts' نوعاً أم قيمة. فإن كان نوعاً ولم تُصرّح بذلك، يُصدر Node استيراداً حقيقياً في زمن التشغيل، فلا يجد تصديراً بهذا الاسم، فيرمي خطأً.
rewriteRelativeImportExtensions و**allowImportingTsExtensions** يسمحان لك بكتابة .ts في مسارات الاستيراد، وهو ما يشترطه Node.
noEmit: true لأن tsc لا يُنتج مخرجات هنا إطلاقاً. لقد صار مدقّقاً لغوياً.
الخطوة 3: القواعد الست التي ستكسر شيفرتك
كل ترحيل إلى التجريد الأصلي يصطدم بالجدران الستة نفسها. تعلّمها قبل أن تكتب سطراً واحداً.
القاعدة 1: لا enums
// مكسور — يرمي Node خطأ SyntaxError
enum Status {
Pending = "pending",
Done = "done"
}يولّد enum كائناً في زمن التشغيل. استخدم كائن const مع نوع مشتق منه بدلاً عنه — فهو أصغر حجماً، وقابل لهزّ الشجرة، ويُنتج رسائل خطأ أفضل:
// src/types.ts
export const Status = {
Pending: "pending",
Done: "done",
Archived: "archived"
} as const;
export type Status = (typeof Status)[keyof typeof Status];
// type Status = "pending" | "done" | "archived"تستخدمه بالطريقة نفسها تقريباً: Status.Pending للقيمة، وStatus للنوع.
القاعدة 2: امتدادات .ts صريحة في كل استيراد نسبي
import { createTaskStore } from "./store"; // مكسور
import { createTaskStore } from "./store.ts"; // صحيحيتعثّر هنا كل قادم من عالم الحزّامات. فآلية تحليل الوحدات في Node لا تُخمّن الامتدادات.
القاعدة 3: import type إلزامي للأنواع
// مكسور في زمن التشغيل — يُصدر Node استيراداً حقيقياً لتصدير نوعي فقط
import { Task } from "./types.ts";
// صحيح
import type { Task } from "./types.ts";
// صحيح — استيراد مختلط مع علامة نوع مضمّنة
import { Status, type Task } from "./types.ts";يلتقط verbatimModuleSyntax هذا الخطأ وقت فحص الأنواع.
القاعدة 4: لا أسماء مستعارة للمسارات في tsconfig.json
يتجاهل Node الخيار compilerOptions.paths تماماً. ذلك الاسم المستعار الذكي @/utils/logger يُحلّ في محرّرك وينفجر في زمن التشغيل. استخدم مسارات نسبية، أو حقل imports الخاص بـ Node داخل package.json:
{
"imports": {
"#store/*": "./src/store/*"
}
}هذا تحليل Node حقيقي، لذا يعمل في زمن التشغيل ويفهمه tsc أيضاً.
القاعدة 5: لا decorators ولا خصائص مُعامِلات
لا تزال decorators مقترحاً في المرحلة الثالثة لدى TC39. لا يوفّر Node لها ترقيعاً ولن يدعمها حتى تصل إلى JavaScript أصلاً. وتخضع خصائص المُعامِلات للقاعدة نفسها:
// مكسور — خاصية المُعامِل تولّد عملية إسناد
class TaskStore {
constructor(private db: DatabaseSync) {}
}
// صحيح — اكتب الإسناد بنفسك
class TaskStore {
readonly #db: DatabaseSync;
constructor(db: DatabaseSync) {
this.#db = db;
}
}لهذه القاعدة عواقب حقيقية: لا يمكن لـ NestJS وTypeORM وclass-validator القديمة أن تعمل تحت التجريد الأصلي. فهي مبنية على بيانات decorators الوصفية. إن كنت تعتمد عليها، أبقِ خطوة البناء لديك.
القاعدة 6: لا JSX
يتعامل تجريد الأنواع مع الأنواع لا مع تحويلات البنية. ما تزال ملفات .tsx تحتاج إلى حزّام. وهذا درس من جانب الخادم، فلا يمسّنا الأمر — لكنه يفسّر لماذا لن يزول Next.js وVite قريباً.
الخلاصة المختصرة: إن كانت شيفرة TypeScript لديك ستبقى TypeScript صالحة بعد حذف كل تعليق نوعي منها، فيمكن لـ Node تشغيلها. أما إن تركت خلفها شيئاً يحتاج إلى توليد، فلا يمكنه ذلك.
الخطوة 4: طبقة البيانات مع node:sqlite
يأتي Node بمشغّل SQLite في نواته. واعتباراً من Node 25.7 صار مُعلَناً مرشّح إصدار (استقرار 1.2) لا مستقراً بالكامل — وهو أمر يستحق المعرفة قبل أن تراهن عليه في نظام حرج، رغم أن واجهته استقرّت منذ عدة إصدارات.
أنشئ src/types.ts:
export const Status = {
Pending: "pending",
Done: "done",
Archived: "archived"
} as const;
export type Status = (typeof Status)[keyof typeof Status];
export interface Task {
id: number;
title: string;
status: Status;
createdAt: string;
}
export interface NewTask {
title: string;
status?: Status;
}والآن src/store.ts:
import { DatabaseSync } from "node:sqlite";
import type { Task, NewTask, Status } from "./types.ts";
export function createTaskStore(path: string) {
const db = new DatabaseSync(path);
// جداول STRICT ترفض عدم تطابق الأنواع بدل تحويلها صامتةً
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
createdAt TEXT NOT NULL
) STRICT
`);
// العبارات المُحضّرة تُترجم مرة واحدة ويُعاد استخدامها في كل نداء
const insertStmt = db.prepare(
`INSERT INTO tasks (title, status, createdAt)
VALUES (:title, :status, :createdAt)`
);
const listStmt = db.prepare(`SELECT * FROM tasks ORDER BY id DESC`);
const getStmt = db.prepare(`SELECT * FROM tasks WHERE id = :id`);
const updateStmt = db.prepare(
`UPDATE tasks SET status = :status WHERE id = :id`
);
const deleteStmt = db.prepare(`DELETE FROM tasks WHERE id = :id`);
return {
list(): Task[] {
return listStmt.all() as unknown as Task[];
},
get(id: number): Task | undefined {
return getStmt.get({ id }) as unknown as Task | undefined;
},
create(input: NewTask): Task {
const createdAt = new Date().toISOString();
const result = insertStmt.run({
title: input.title,
status: input.status ?? "pending",
createdAt
});
return {
id: Number(result.lastInsertRowid),
title: input.title,
status: input.status ?? "pending",
createdAt
};
},
updateStatus(id: number, status: Status): Task | undefined {
const result = updateStmt.run({ id, status });
if (result.changes === 0) return undefined;
return this.get(id);
},
remove(id: number): boolean {
return deleteStmt.run({ id }).changes > 0;
},
close(): void {
db.close();
}
};
}
export type TaskStore = ReturnType<typeof createTaskStore>;بضع نقاط تستحق التنويه. إن node:sqlite متزامن بحكم التصميم — فـ DatabaseSync يحجب حلقة الأحداث. وهذا صحيح غالباً مع SQLite، لأن قراءة من قرص محلي تنتهي أسرع من كلفة جدولة نداء غير متزامن. لكن هذا يعني أن استعلاماً بطيئاً يُعطّل خادمك كله، فأبقِ استعلاماتك مفهرسة وتجنّب المسح الكامل للجداول.
تستخدم المُعامِلات المسمّاة الخيار allowBareNamedParameters وقيمته الافتراضية true — لذا يرتبط { id } بـ :id دون أن تكتب النقطتين بنفسك.
أما تحويلات as unknown as Task[] فهي أمينة مع الواقع: تُعيد SQLite صفوفاً بلا أنواع، والتظاهر بغير ذلك أسوأ. وفي نظام أكبر ستتحقّق من صحة هذه الصفوف بالطريقة نفسها التي تتحقّق بها من الطلبات الواردة.
الخطوة 5: التحقّق من الصحة بلا decorators
بما أن class-validator وأقرانها المعتمدة على decorators خارج الطاولة، اكتب التحقّق كدوال عادية تُعيد كائن نتيجة. هذا أطول من decorator، لكنه شيفرة تستطيع قراءتها والتنقّل داخلها بالمُنقّح.
أنشئ src/validate.ts:
import { Status } from "./types.ts";
import type { NewTask } from "./types.ts";
export type Validated<T> =
| { ok: true; value: T }
| { ok: false; errors: string[] };
const VALID_STATUSES = Object.values(Status) as string[];
export function validateNewTask(input: unknown): Validated<NewTask> {
const errors: string[] = [];
if (typeof input !== "object" || input === null) {
return { ok: false, errors: ["body must be a JSON object"] };
}
const body = input as Record<string, unknown>;
if (typeof body.title !== "string" || body.title.trim().length === 0) {
errors.push("title is required and must be a non-empty string");
} else if (body.title.length > 200) {
errors.push("title must be 200 characters or fewer");
}
if (body.status !== undefined && !VALID_STATUSES.includes(String(body.status))) {
errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
}
if (errors.length > 0) return { ok: false, errors };
return {
ok: true,
value: {
title: (body.title as string).trim(),
status: body.status as NewTask["status"]
}
};
}
export function validateStatus(input: unknown): Validated<Status> {
if (typeof input !== "object" || input === null) {
return { ok: false, errors: ["body must be a JSON object"] };
}
const status = (input as Record<string, unknown>).status;
if (!VALID_STATUSES.includes(String(status))) {
return {
ok: false,
errors: [`status must be one of: ${VALID_STATUSES.join(", ")}`]
};
}
return { ok: true, value: status as Status };
}يعني الاتحاد المُميّز Validated أن TypeScript يُضيّق النوع نيابة عنك: داخل فرع if (result.ok) تكون result.value مكتملة الأنواع ولا وجود لـ result.errors.
الخطوة 6: طبقة HTTP
الآن الخادم نفسه، باستخدام node:http وحده. أنشئ src/router.ts:
import type { IncomingMessage, ServerResponse } from "node:http";
import type { TaskStore } from "./store.ts";
import { validateNewTask, validateStatus } from "./validate.ts";
const MAX_BODY_BYTES = 64 * 1024;
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
throw new Error("request body too large");
}
chunks.push(chunk as Buffer);
}
if (chunks.length === 0) return {};
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
}
function send(res: ServerResponse, status: number, payload: unknown): void {
const body = JSON.stringify(payload);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body)
});
res.end(body);
}
export function createRouter(store: TaskStore) {
return async function handle(
req: IncomingMessage,
res: ServerResponse
): Promise<void> {
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
const segments = url.pathname.split("/").filter(Boolean);
const method = req.method ?? "GET";
// GET /health
if (method === "GET" && url.pathname === "/health") {
return send(res, 200, { status: "ok", uptime: process.uptime() });
}
if (segments[0] !== "tasks") {
return send(res, 404, { error: "not found" });
}
// GET /tasks
if (method === "GET" && segments.length === 1) {
return send(res, 200, { tasks: store.list() });
}
// POST /tasks
if (method === "POST" && segments.length === 1) {
const body = await readJsonBody(req);
const result = validateNewTask(body);
if (!result.ok) {
return send(res, 422, { errors: result.errors });
}
return send(res, 201, { task: store.create(result.value) });
}
// كل ما يلي يحتاج معرّفاً رقمياً
const id = Number(segments[1]);
if (segments.length !== 2 || !Number.isInteger(id) || id < 1) {
return send(res, 400, { error: "invalid task id" });
}
// GET /tasks/:id
if (method === "GET") {
const task = store.get(id);
return task
? send(res, 200, { task })
: send(res, 404, { error: "task not found" });
}
// PATCH /tasks/:id
if (method === "PATCH") {
const result = validateStatus(await readJsonBody(req));
if (!result.ok) {
return send(res, 422, { errors: result.errors });
}
const task = store.updateStatus(id, result.value);
return task
? send(res, 200, { task })
: send(res, 404, { error: "task not found" });
}
// DELETE /tasks/:id
if (method === "DELETE") {
return store.remove(id)
? send(res, 204, {})
: send(res, 404, { error: "task not found" });
}
return send(res, 405, { error: "method not allowed" });
};
}والآن نقطة الدخول، src/server.ts:
import { createServer } from "node:http";
import { createTaskStore } from "./store.ts";
import { createRouter } from "./router.ts";
const PORT = Number(process.env.PORT ?? 3000);
const DB_PATH = process.env.DB_PATH ?? "./tasks.db";
const store = createTaskStore(DB_PATH);
const handle = createRouter(store);
const server = createServer((req, res) => {
handle(req, res).catch((error: unknown) => {
const message = error instanceof Error ? error.message : "unknown error";
console.error("[request-error]", message);
if (!res.headersSent) {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "internal server error" }));
}
});
});
server.listen(PORT, () => {
console.log(`[startup] listening on http://localhost:${PORT}`);
});
// إيقاف رشيق — ترسل الحاويات SIGTERM قبل SIGKILL
function shutdown(signal: string): void {
console.log(`[shutdown] received ${signal}, closing`);
server.close(() => {
store.close();
process.exit(0);
});
// خروج قسري إن رفضت الاتصالات الانتهاء
setTimeout(() => process.exit(1), 10_000).unref();
}
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));استدعاء .catch() على المُوجّه ليس اختيارياً. فالرفض غير المُعالَج داخل مُعالِج طلب غير متزامن سيُسقط العملية في إصدارات Node الحديثة، لذا تحتاج كل حدود غير متزامنة إلى واحد.
شغّله:
npm run dev
# [startup] listening on http://localhost:3000تُعيد --watch التشغيل عند كل حفظ، فتُغني عن nodemon. وباجتماعها مع تجريد الأنواع، تصبح دورة التحرير حتى تشغيل الشيفرة بلا أي خطوة بناء إطلاقاً.
الخطوة 7: الإعدادات ومتغيّرات البيئة
يقرأ Node ملفات .env أصلاً. أنشئ واحداً:
# .env
PORT=3000
DB_PATH=./tasks.dbتُحمّله الراية --env-file=.env في سكربتاتك — لكنها ترمي خطأً إن كان الملف مفقوداً، وهي الحالة الطبيعية في الإنتاج حيث تحقن المنصّة متغيّرات البيئة الحقيقية. استخدم هناك الصيغة التي لا ترمي خطأً:
{
"scripts": {
"start": "node --env-file-if-exists=.env src/server.ts"
}
}وإن فضّلت التحميل من داخل الشيفرة، تفعل process.loadEnvFile() الشيء نفسه برمجياً. وهي أيضاً ترمي خطأً عند غياب الملف، فاحرسها:
// حمّل ملف .env في التطوير فقط، وتجاهله إن كان غائباً
if (process.env.NODE_ENV !== "production") {
try {
process.loadEnvFile(".env");
} catch {
console.warn("[config] no .env file found, using process environment");
}
}ولأي شيء يتجاوز حفنة متغيّرات، حلّل الإعدادات وتحقّق منها مرة واحدة عند الإقلاع كي يفشل الخطأ المطبعي فوراً بدل الثالثة فجراً:
// src/config.ts
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
console.error(`[config] missing required env var: ${key}`);
process.exit(1);
}
return value;
}
export const config = {
port: Number(process.env.PORT ?? 3000),
dbPath: process.env.DB_PATH ?? "./tasks.db",
nodeEnv: process.env.NODE_ENV ?? "development"
} as const;الخطوة 8: الاختبار عبر node --test
يتعامل مشغّل الاختبارات المدمج مع ملفات .ts بلا أي إعداد إضافي، لأن تجريد الأنواع ينطبق على ملفات الاختبار أيضاً.
أنشئ src/store.test.ts:
import { test, describe, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createTaskStore, type TaskStore } from "./store.ts";
describe("TaskStore", () => {
let store: TaskStore;
beforeEach(() => {
// ":memory:" يمنح كل اختبار قاعدة بيانات نظيفة وسريعة ومعزولة
store = createTaskStore(":memory:");
});
test("creates a task with a default status", () => {
const task = store.create({ title: "Write the tutorial" });
assert.equal(task.title, "Write the tutorial");
assert.equal(task.status, "pending");
assert.ok(task.id > 0);
});
test("lists tasks newest first", () => {
store.create({ title: "first" });
store.create({ title: "second" });
const tasks = store.list();
assert.equal(tasks.length, 2);
assert.equal(tasks[0]?.title, "second");
});
test("updates status and returns the updated row", () => {
const created = store.create({ title: "ship it" });
const updated = store.updateStatus(created.id, "done");
assert.equal(updated?.status, "done");
});
test("returns undefined when updating a missing task", () => {
assert.equal(store.updateStatus(9999, "done"), undefined);
});
test("removes a task exactly once", () => {
const created = store.create({ title: "temporary" });
assert.equal(store.remove(created.id), true);
assert.equal(store.remove(created.id), false);
});
});وsrc/validate.test.ts:
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { validateNewTask } from "./validate.ts";
describe("validateNewTask", () => {
test("accepts a valid payload and trims the title", () => {
const result = validateNewTask({ title: " padded " });
assert.equal(result.ok, true);
if (result.ok) {
assert.equal(result.value.title, "padded");
}
});
test("rejects an empty title", () => {
const result = validateNewTask({ title: " " });
assert.equal(result.ok, false);
});
test("rejects an unknown status", () => {
const result = validateNewTask({ title: "ok", status: "nonsense" });
assert.equal(result.ok, false);
if (!result.ok) {
assert.match(result.errors[0] ?? "", /status must be one of/);
}
});
test("rejects a non-object body", () => {
assert.equal(validateNewTask("a string").ok, false);
assert.equal(validateNewTask(null).ok, false);
});
});شغّلها:
npm testصيغ مفيدة:
node --test --watch 'src/**/*.test.ts' # إعادة التشغيل عند الحفظ
node --test --experimental-test-coverage 'src/**/*.test.ts' # تقرير التغطية
node --test --test-name-pattern="status" 'src/**/*.test.ts' # تصفية بالاسمضع أنماط الـ glob بين علامتي اقتباس مفردتين كي تمرّرها الصدفة إلى Node بدل أن توسّعها بنفسها.
الخطوة 9: فحص الأنواع كبوابة في التكامل المستمر
هذه الخطوة التي يتخطّاها الناس، وهي الأهم على الإطلاق. يُشغّل Node شيفرتك دون فحص نوع واحد. خطأ مطبعي في اسم خاصية يصل إلى الإنتاج مباشرة ما لم يلتقطه شيء آخر.
ذلك الشيء الآخر هو tsc --noEmit، وقد تُرقّي من أداة بناء إلى بوابة إلزامية:
npm run typecheckاربطه بالتكامل المستمر ليمنع الدمج:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "26"
cache: "npm"
- run: npm ci
# غير قابل للتفاوض: لا شيء آخر يفحص الأنواع
- name: Type check
run: npm run typecheck
- name: Test
run: npm testأضف خطّافاً محلياً قبل الإيداع إن كان فريقك ميّالاً للنسيان:
# .git/hooks/pre-commit
#!/bin/sh
npm run typecheck || {
echo "Type check failed — commit aborted."
exit 1
}تذكّر أن erasableSyntaxOnly في ملف tsconfig يعني أن هذه البوابة نفسها تلتقط أيضاً مخالفات القاعدتين 1 و5. أمر واحد يحرس سلامة الأنواع والتوافق مع زمن التشغيل معاً.
الخطوة 10: النشر بملف Dockerfile من مرحلة واحدة
هنا يصبح العائد ملموساً. ملف Dockerfile التقليدي لـ TypeScript متعدّد المراحل: مرحلة تُثبّت كل شيء وتترجم، وأخرى تنسخ dist/ إلى صورة نحيفة. وبلا خطوة بناء، تختفي هذه الرقصة بأكملها.
FROM node:26-slim
WORKDIR /app
# ثبّت اعتماديات الإنتاج فقط — وTypeScript ليست منها
COPY package*.json ./
RUN npm ci --omit=dev
# انسخ مصدر TypeScript؛ فهو نفسه المُخرَج القابل للنشر
COPY src ./src
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000
# شغّل بمستخدم غير جذري
USER node
CMD ["node", "src/server.ts"]اقرأ ذلك مرة أخرى: يتخطّى npm ci --omit=dev حزمة TypeScript كلياً، لأن الإنتاج لا يحتاج المترجم أبداً. تحتوي الصورة على ملفاتك المصدرية ولا شيء غيرها.
ابنِ وشغّل:
docker build -t task-api .
docker run -p 3000:3000 task-apiولملف .dockerignore:
node_modules
*.db
.env
.git
وإن نشرت على منصّة تُشغّل npm start مباشرة — مثل Railway أو Render أو Fly.io — فلن يتغيّر شيء. أمر البدء يشير أصلاً إلى ملف .ts. ولا يوجد أمر بناء تضبطه.
اختبار ما نفّذته
مع تشغيل الخادم، امشِ عبر دورة الحياة الكاملة:
# فحص الصحة
curl -s localhost:3000/health
# إنشاء مهمة
curl -s -X POST localhost:3000/tasks \
-H 'content-type: application/json' \
-d '{"title":"Migrate the build pipeline"}'
# {"task":{"id":1,"title":"Migrate the build pipeline","status":"pending",...}}
# سرد المهام
curl -s localhost:3000/tasks
# تحديث الحالة
curl -s -X PATCH localhost:3000/tasks/1 \
-H 'content-type: application/json' \
-d '{"status":"done"}'
# التحقّق يرفض المدخل السيّئ برمز 422
curl -s -X POST localhost:3000/tasks \
-H 'content-type: application/json' \
-d '{"title":""}'
# {"errors":["title is required and must be a non-empty string"]}
# الحذف
curl -s -i -X DELETE localhost:3000/tasks/1
# HTTP/1.1 204 No Contentثم تحقّق من أن شبكة الأمان تلتقط الأخطاء فعلاً. أدخل خطأ نوعي متعمّد:
const task = store.create({ title: 42 }); // رقم لا نصيُشغّل node src/server.ts هذا دون أي اعتراض — فالنوع مُمحى وSQLite تخزّن القيمة. أما npm run typecheck فيفشل فوراً. تلك الفجوة بين الاثنين هي بالضبط سبب كون الخطوة 9 إلزامية.
استكشاف الأخطاء وإصلاحها
SyntaxError [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]: TypeScript enum is not supported in strip-only mode — الرسالة تقول ما تعنيه بالضبط. حوّل الـ enum إلى نمط as const من القاعدة 1. وسترى صنف الخطأ نفسه مع خصائص المُعامِلات والفضاءات الاسمية ذات الأعضاء التنفيذية.
ERR_MODULE_NOT_FOUND يشير إلى ملف موجود بوضوح — أغفلت امتداد .ts في استيراد نسبي (القاعدة 2)، أو استخدمت اسماً مستعاراً لمسار من tsconfig (القاعدة 4).
SyntaxError: The requested module does not provide an export named 'X' — استوردت نوعاً كقيمة. أضف الكلمة المفتاحية type (القاعدة 3). وتفعيل verbatimModuleSyntax يمنع تكرار ذلك.
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — يرفض Node عمداً تجريد الأنواع داخل node_modules. إحدى الاعتماديات تشحن ملفات .ts خاماً. أبلغ عنها في مستودعها؛ فالحزم المنشورة يجب أن تشحن JavaScript.
أخطاء تحليل decorators — لا يوجد التفاف حول هذا تحت التجريد الأصلي. إما أن تتخلى عن المكتبة المعتمدة على decorators أو تُبقي خطوة ترجمة لذلك المشروع.
الأنواع سليمة محلياً لكن التكامل المستمر يفشل — تأكّد أن التكامل المستمر يعمل على Node 26. تتصرّف الإصدارات الأقدم بشكل مختلف، ولم تعد --experimental-transform-types موجودة لتغطية الفرق.
كل شيء بطيء تحت الحمل — تذكّر أن node:sqlite متزامن. حلّل أداء استعلاماتك وأضف فهارس؛ فغياب فهرس واحد يحجب حلقة الأحداث في كل طلب.
متى لا ينبغي أن تفعل هذا
الأمانة بشأن الحدود أهم من العرض التسويقي. أبقِ خطوة البناء إذا كنت:
- تستخدم NestJS أو TypeORM أو
class-validator— كلها تعتمد على decorators، وكلها غير متوافقة - تُصيّر JSX/TSX — ما تزال Next.js وVite وأخواتها تحتاج حزّامات
- تحتاج هزّ الشجرة أو التصغير لدوال بلا خادم حسّاسة للإقلاع البارد
- مضطراً لدعم Node 22 أو أقدم في أي بيئة نشر
- تعتمد شيفرتك بكثافة على enums والفضاءات الاسمية ولا يستحق الترحيل عناءه
التجريد الأصلي مثالي لخدمات الواجهة الخلفية وأدوات سطر الأوامر والسكربتات والعمّال والمهام المجدولة — أي شيفرة تتحكّم فيها ببنيتك وتُقدّر فيها سرعة الإقلاع على تحسين حجم الحزمة.
الخطوات التالية
وسّع ما بنيته:
- أضف تسجيلاً مُهيكلاً — استبدل
console.logبمُسجّل حقيقي يُصدر أسطر JSON تستطيع منصّتك فهرستها - أضف تحديد المعدّل — راجع دليلنا حول تحديد المعدّل مع Upstash Redis
- أضف المراقبة — جهّز الخدمة بـ تتبّع OpenTelemetry
- انتقل إلى قاعدة بيانات حقيقية — واجهة المخزن صغيرة بما يكفي لاستبدالها بـ Drizzle ORM
- قارن مسارات المترجمات — اقرأ عن مترجم TypeScript 7 المبني بلغة Go، الذي يجعل بوابة فحص الأنواع أسرع بفارق هائل
الخلاصة
يُقلّص Node.js 26 سلسلة أدوات TypeScript في الواجهة الخلفية إلى ما يقارب اللاشيء. لقد بنيت واجهة REST كاملة — توجيهاً وتخزيناً وتحقّقاً واختبارات وإيقافاً رشيقاً ونشراً عبر Docker — باعتمادية تطوير واحدة بالضبط وصفر اعتماديات في زمن التشغيل. لا مجلد dist/، ولا إعدادات حزّام، ولا فجوة بين الشيفرة التي كتبتها والشيفرة التي تعمل.
المقايضة صريحة وتستحق التكرار: تتخلى عن enums وdecorators وخصائص المُعامِلات والأسماء المستعارة للمسارات وJSX. وفي المقابل تحصل على إقلاع فوري ونشر بسيط وتتبّعات مكدّس تشير إلى أسطر حقيقية في ملفات حقيقية.
الانضباط الوحيد الذي يفرضه هذا هو أن tsc --noEmit صار غير قابل للتفاوض. سيُشغّل Node أي شيء تُسلّمه إياه. صار خط التكامل المستمر لديك هو الشيء الوحيد الفاصل بين خطأ مطبعي والإنتاج — فأقم تلك البوابة أولاً، قبل أن تكتب الملف الثاني.