كتابة تطبيق Express بالأنواع
بعد أن صار لدينا فهم أساسي لكيفية عمل TypeScript وكيفية إنشاء مشاريع صغيرة به، حان الوقت لنبدأ بإنشاء شيء مفيد. سننشئ الآن مشروعاً جديداً يقدّم حالات استخدام أكثر واقعية بعض الشيء.
هناك تغيير جوهري عن الجزء السابق وهو أننا لن نستخدم ts-node بعد الآن. فهي أداة عملية تساعدك على البدء، لكن على المدى الطويل يُستحسن استخدام مترجم TypeScript الرسمي الذي يأتي مع حزمة npm typescript. يولّد المترجم الرسمي ملفات JavaScript من ملفات .ts ويحزمها، بحيث لا تحتوي نسخة الإنتاج المبنية على أي شيفرة TypeScript بعد الآن. هذه هي النتيجة التي نصبو إليها بالضبط، لأن TypeScript نفسه غير قابل للتنفيذ في المتصفحات أو Node.
تهيئة المشروع
سننشئ مشروعاً من أجل Ilari، الذي يحب قيادة الطائرات الصغيرة لكنه يجد صعوبة في إدارة سجل رحلاته الجوية. هو مبرمج بنفسه، لذا لا يحتاج بالضرورة إلى واجهة مستخدم، لكنه يرغب في استخدام برنامج مخصّص يعمل عبر طلبات HTTP مع إبقاء إمكانية إضافة واجهة مستخدم قائمة على الويب إلى التطبيق لاحقاً.
لنبدأ بإنشاء أول مشروع حقيقي لنا: مذكرات رحلات Ilari الجوية. كالمعتاد، نفّذ npm init وثبّت حزمة typescript كاعتمادية تطوير.
npm install typescript --save-dev
لنجرِ أيضاً التعديلات المطلوبة في package.json:
{
// ..
"type": "module",
"scripts": {
"tsc": "tsc"
},
// ..
}
يمكننا الآن تهيئة إعدادات tsconfig.json بتشغيل:
npm run tsc -- --init
لاحظ الشرطتين الإضافيتين -- قبل الوسيط الفعلي! تُفسَّر الوسائط قبل -- على أنها خاصة بأمر npm، أما التي بعدها فتكون مخصصة للأمر الذي يُشغَّل عبر السكربت (أي tsc في هذه الحالة).
يحتوي ملف tsconfig.json الذي أنشأناه للتو على قائمة طويلة بكل إعداد متاح لنا. غير أن معظمها معلّق. قد تساعدك دراسة هذا الملف في العثور على بعض خيارات الإعداد التي قد تحتاجها. ولا بأس إطلاقاً في إبقاء الأسطر المعلّقة، في حال احتجتها يوماً ما.
في الوقت الحالي، نريد تفعيل ما يلي:
{
"compilerOptions": {
"target": "esnext",
"noEmit": true,
"module": "nodenext",
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"strict" : true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}
لنتناول كل إعداد على حدة:
يضبط target: "esnext" أن يُترجم TypeScript إلى أحدث ميزات JavaScript. ستستخدم الشيفرة المترجمة صيغة JavaScript الأحدث. ولأننا نستخدم Node الإصدار 24، فنحن في الواقع لا نترجم الشيفرة، لذا لا يهم الهدف كثيراً.
أما noEmit: true فمألوف لدينا، فهو يخبر المترجم بالقيام بفحص الأنواع فقط دون توليد الشيفرة المترجمة.
يخبر module: "nodenext" TypeScript باستخدام آلية حلّ الوحدات الأصلية في Node.js لوحدات ESM (وحدات ES). عملياً، يعني هذا أنه يمكننا استخدام صيغة import في استيراد الوحدات.
يتيح esModuleInterop: true التوافق بين أنماط استيراد وحدات CommonJS وES modules في TypeScript.
بدونه، يتطلب استيراد وحدة CommonJS كتابة import * as express from 'express';
ومعه، يمكنك استخدام import express from 'express';
يتيح allowImportingTsExtensions: true استيراد ملفات TypeScript مباشرةً، وهذا ضروري عندما نشغّل الشيفرة بـNode.js
strict : true هو اختصار لعدة خيارات منفصلة:
- noImplicitAny
- noImplicitThis
- alwaysStrict
- strictBindCallApply
- strictNullChecks
- strictFunctionTypes
- strictPropertyInitialization
إنها توجّه أسلوب كتابتنا للشيفرة نحو استخدام ميزات TypeScript بصرامة أكبر. وربما أهمها بالنسبة لنا هو noImplicitAny المألوف سابقاً. فهو يمنع ضبط النوع any ضمنياً، وهو ما قد يحدث مثلاً إذا لم تحدّد أنواع معاملات دالة. يمكن العثور على تفاصيل بقية الإعدادات في توثيق tsconfig. ويوصي التوثيق الرسمي باستخدام strict.
- noUnusedLocals يمنع وجود متغيرات محلية غير مستخدمة، وnoUnusedParameters يرمي خطأً إذا احتوت دالة على معاملات غير مستخدمة.
- noImplicitReturns يفحص كل مسارات الشيفرة في الدالة للتأكد من أنها تُرجع قيمة.
- noFallthroughCasesInSwitch يضمن أن كل حالة في switch case تنتهي إما بعبارة return أو break.
- esModuleInterop يتيح التشغيل البيني بين CommonJS وES Modules
انظر المزيد في التوثيق.
بعد أن ضبطنا إعداداتنا، يمكننا المتابعة بتثبيت express وبالطبع أيضاً @types/express. ولأن هذا مشروع حقيقي يُقصد به أن ينمو مع الوقت، سنستخدم ESlint منذ البداية:
npm install express
npm install --save-dev eslint @eslint/js typescript-eslint @stylistic/eslint-plugin @types/express
الآن يجب أن يبدو ملف package.json لدينا هكذا:
{
"name": "flights",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"tsc": "tsc"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@types/express": "^5.0.6",
"eslint": "^10.1.0",
"typescript": "^6.0.2",
"typescript-eslint": "^8.57.2"
},
"dependencies": {
"express": "^5.2.1"
}
}
ننشئ أيضاً ملف eslint.config.mjs بالمحتوى التالي:
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin';
export default tseslint.config({
files: ['**/*.ts'],
extends: [
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'@stylistic': stylistic,
},
rules: {
'@stylistic/semi': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_' },
],
},
});
الآن نحتاج فقط إلى تهيئة بيئة التطوير لدينا، ونكون جاهزين لبدء كتابة شيفرة جدّية.
سنختار الخيار نفسه كما في السابق، ونشغّل tsc وnode --watch بالتوازي. لنثبّت أولاً concurrently:
npm install --save-dev concurrently
نعرّف أخيراً بضعة سكربتات npm إضافية، وها نحن جاهزون للبدء:
{
// ...
"scripts": {
"tsc": "tsc",
"dev": "concurrently \"tsc --watch\" \"node --watch index.ts\"",
"start": "node index.ts",
"lint": "eslint ."
},
// ...
}
عرّفنا أيضاً السكربت npm start لتشغيل نسخة الإنتاج من التطبيق.
كما ترى، هناك الكثير من الأمور التي يجب المرور بها قبل البدء بالبرمجة الفعلية. عندما تعمل على مشروع حقيقي، فإن التحضير المتأني يدعم عملية تطويرك. خذ الوقت اللازم لإنشاء إعداد جيد لك ولفريقك، حتى يسير كل شيء بسلاسة على المدى الطويل.
ليكن هناك شيفرة
الآن يمكننا أخيراً البدء بالبرمجة! كالعادة، نبدأ بإنشاء نقطة نهاية ping، فقط للتأكد من أن كل شيء يعمل.
محتوى ملف index.ts:
import express from 'express';
const app = express();
app.use(express.json());
const PORT = 3000;
app.get('/ping', (_req, res) => {
console.log('someone pinged here');
res.send('pong');
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
الآن، إذا شغّلنا التطبيق بـnpm run dev أو npm start يمكننا التحقق من أن طلباً إلى http://localhost:3000/ping يعطي الاستجابة pong، أي أن إعدادنا سليم!

الآن لدينا خط أنابيب (pipeline) أدنى عامل لتطوير مشروعنا. بمساعدة المترجم وESLint نضمن الحفاظ على جودة شيفرة جيدة. بهذه القاعدة، يمكننا البدء بإنشاء تطبيق يمكننا لاحقاً نشره في بيئة إنتاج.
كلمات قليلة عن تشغيل TypeScript مع Node.js
كما ذُكر، يعمل دعم TypeScript المدمج في Node عبر إزالة الأنواع، فهو ببساطة يحذف تعليقات الأنواع (type annotations) ويشغّل JavaScript المتبقية. هذا سريع وكافٍ لمعظم شيفرة TypeScript. غير أن بعض ميزات TypeScript تتجاوز مجرد تعليقات الأنواع وتتطلب تحويلاً فعلياً للشيفرة لتعمل بشكل صحيح في وقت التشغيل.
تتيح راية --experimental-transform-types لـNode.js التعامل مع هذه الميزات. ومن أبرزها Enums. تُترجم TypeScript enums إلى كائنات JavaScript حقيقية. بدون التحويل، ستزيل Node صيغة enum وتترك شيفرة غير صالحة.
بدون هذه الراية، سيؤدي استخدام enum في شيفرتك إلى خطأ في وقت التشغيل، حتى وإن لم يبلّغ مدقّق أنواع TypeScript عن أي مشاكل.
رغم عدم استخدامنا أياً من هذه الميزات الآن، لنضف الراية إلى السكربتات:
{
// ...
"scripts": {
"tsc": "tsc",
"dev": "concurrently \"tsc --watch\" \"node --watch --experimental-transform-types index.ts\"",
"start": "node --experimental-transform-types index.ts",
"lint": "eslint ."
},
// ...
}
لاحظ أن الراية موسومة بـتجريبية، أي أن سلوكها قد يتغير في إصدارات Node المستقبلية. ومع نضوج دعم TypeScript الأصلي في Node، يُتوقع أن تصبح ميزات مثل تحويل enum جزءاً من السلوك الافتراضي في النهاية.
ستحصل على تحذير بشأن الطابع التجريبي:
(node:80296) ExperimentalWarning: Transform Types is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
يمكن إسكات التحذير بإضافة الراية --disable-warning=ExperimentalWarning.
9. الواجهة الخلفية لـPatientor، الخطوة 1
10. الواجهة الخلفية لـPatientor، الخطوة 2
تنفيذ الوظائف
أخيراً، نحن جاهزون لبدء كتابة بعض الشيفرة.
لنبدأ من الأساسيات. يريد Ilari أن يكون قادراً على تتبّع تجاربه في رحلاته الجوية.
يريد أن يكون قادراً على حفظ مدخلات المذكرات، التي تحتوي على:
- تاريخ المدخلة
- حالة الطقس (sunny أو windy أو cloudy أو rainy أو stormy)
- مدى الرؤية (great أو good أو ok أو poor)
- نص حر يصف التجربة
حصلنا على بعض البيانات النموذجية التي سنستخدمها كقاعدة نبني عليها. البيانات محفوظة بصيغة JSON ويمكن العثور عليها هنا.
تبدو البيانات كالتالي:
[
{
"id": 1,
"date": "2026-01-01",
"weather": "rainy",
"visibility": "poor",
"comment": "Pretty scary flight, I'm glad I'm alive"
},
{
"id": 2,
"date": "2026-04-01",
"weather": "sunny",
"visibility": "good",
"comment": "Everything went better than expected, I'm learning much"
},
// ...
]
لنبدأ بإنشاء نقطة نهاية تُرجع جميع مدخلات مذكرات الرحلات.
أولاً، علينا اتخاذ بعض القرارات حول كيفية تنظيم شيفرتنا المصدرية. من الأفضل وضع جميع الشيفرة المصدرية تحت دليل src، حتى لا تختلط مع ملفات الإعداد. سننقل index.ts إلى هناك ونجري التعديلات اللازمة على سكربتات npm.
سنضع جميع الموجّهات (routers) والوحدات المسؤولة عن معالجة مجموعة موارد محددة مثل diaries، تحت دليل src/routes. هذا مختلف قليلاً عما فعلناه في الجزء 4، حيث استخدمنا دليل src/controllers.
الموجّه الذي يتولى جميع نقاط نهاية المذكرات موجود في src/routes/diaries.ts ويبدو هكذا:
import express from 'express';
const router = express.Router();
router.get('/', (_req, res) => {
res.send('Fetching all diaries!');
});
router.post('/', (_req, res) => {
res.send('Saving a diary!');
});
export default router;
سنوجّه جميع الطلبات ذات البادئة /api/diaries إلى ذلك الموجّه تحديداً في index.ts
import express from 'express';
import diaryRouter from './routes/diaries.ts';
const app = express();
app.use(express.json());
const PORT = 3000;
app.get('/ping', (_req, res) => {
console.log('someone pinged here');
res.send('pong');
});
app.use('/api/diaries', diaryRouter);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
والآن، إذا أرسلنا طلب HTTP GET إلى http://localhost:3000/api/diaries، ينبغي أن نرى الرسالة: Fetching all diaries!
بعد ذلك، علينا البدء بتقديم البيانات الأولية (الموجودة هنا) من التطبيق. سنجلب البيانات ونحفظها في data/entries.json.
لن نكتب شيفرة عمليات معالجة البيانات الفعلية في الموجّه. سننشئ بدلاً من ذلك خدمة (service) تتولى معالجة البيانات. من الممارسات الشائعة جداً فصل «منطق الأعمال» عن شيفرة الموجّه في وحدات تُسمى غالباً خدمات. اسم service أصله من التصميم المدفوع بالمجال وشاع استخدامه عبر إطار عمل Spring.
لننشئ دليل src/services ونضع فيه ملف diaryService.ts. يحتوي الملف على دالتين لجلب مدخلات المذكرات وحفظها:
import diaryData from '../../data/entries.json';
const getEntries = () => {
return diaryData;
};
const addDiary = () => {
return null;
};
export default {
getEntries,
addDiary
};
لكن هناك شيء غير صحيح:

لحسن الحظ، هناك حل سهل، فقط غيّر الاستيراد كما يلي:
import diaryData from '../../data/entries.json' with { type: "json" };
هذا مطلوب لأنه إذا كنت تستورد شيئاً غير الشيفرة، يحتاج Node.js إلى معرفة نوع الملف الذي تستورده. بدون هذه الإشارة، يرى ملف .json ولا يعرف ما إذا كان عليه التعامل معه كشيفرة أو كبيانات، فيرمي خطأً.
لنضمن الآن عمل الوظائف من الطرف إلى الطرف، ولنربط الموجّه بالخدمة:
import express from 'express';
import diaryService from '../services/diaryService.ts';
const router = express.Router();
router.get('/', (_req, res) => {
const data = diaryService.getEntries()
res.send(data);
});
router.post('/', (_req, res) => {
res.send("add a new diary");
});
export default router;
وبالفعل، نرى المذكرات في نقطة النهاية:

تعريف الأنواع
رأينا سابقاً كيف يستطيع المترجم تحديد نوع متغير من القيمة المُسندة إليه. وبالمثل، يمكن للمترجم تفسير مجموعات بيانات كبيرة مكوّنة من كائنات ومصفوفات:

نتيجة لذلك، يحذّرنا المترجم إذا حاولنا فعل شيء مثير للشك ببيانات JSON التي نتعامل معها. مثلاً، إذا كنا نتعامل مع مصفوفة تحتوي كائنات من نوع معيّن، وحاولنا إضافة كائن لا يملك كل الحقول التي تملكها الكائنات الأخرى، أو به تعارضات في الأنواع (مثلاً، عدد حيث ينبغي أن يكون نصاً)، يستطيع المترجم أن يعطينا تحذيراً.
رغم أن المترجم جيد جداً في التأكد من عدم قيامنا بأي شيء غير مرغوب، إلا أنه أكثر أماناً أن نعرّف أنواع البيانات بأنفسنا.
حالياً، لدينا تطبيق Express بلغة TypeScript عامل بشكل أساسي، لكن لا تكاد توجد أنواع فعلية في الشيفرة. ولأننا نعرف نوع البيانات التي ينبغي قبولها في حقلَي weather وvisibility، فلا سبب يمنعنا من تضمين أنواعهما في الشيفرة.
لننشئ ملفاً لأنواعنا، types.ts، نعرّف فيه جميع أنواعنا لهذا المشروع.
أولاً، لنكتب أنواع قيم Weather وVisibility باستخدام نوع اتحادي (union type) من النصوص المسموح بها:
export type Weather = 'sunny' | 'rainy' | 'cloudy' | 'windy' | 'stormy';
export type Visibility = 'great' | 'good' | 'ok' | 'poor';
ومن هناك، يمكننا المتابعة بإنشاء نوع DiaryEntry، الذي سيكون واجهة (interface):
export interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment: string;
}
يمكننا الآن محاولة كتابة نوع JSON المستورد:
import diaryData from '../../data/entries.json' with { type: "json" };
import type { DiaryEntry } from '../types.ts';
const diaries: DiaryEntry[] = diaryData;
const getEntries = (): DiaryEntry[] => {
return diaries;
};
const addDiary = () => {
return null;
};
export default {
getEntries,
addDiary
};
لكن بما أن قيم JSON معلنة مسبقاً، فإن إسناد نوع لمجموعة البيانات يؤدي إلى خطأ:

تكشف نهاية رسالة الخطأ عن المشكلة: حقول weather غير متوافقة. في DiaryEntry حدّدنا أن نوعه Weather، لكن مترجم TypeScript استنتج أن نوعه string.
يمكننا إصلاح المشكلة بإجراء تأكيد نوع (type assertion). وكما ذكرنا سابقاً، ينبغي ألا يُجرى تأكيد النوع إلا إذا كنا واثقين من معرفتنا بما نفعل!
إذا أكّدنا نوع المتغير diaryData ليكون DiaryEntry بالكلمة المفتاحية as، فينبغي أن يعمل كل شيء:
import diaryData from '../../data/entries.json' with { type: "json" };
import type { DiaryEntry } from '../types.ts';
const diaries: DiaryEntry[] = diaryData as DiaryEntry[];
const getEntries = (): DiaryEntry[] => {
return diaries;
};
const addDiary = () => {
return null;
};
export default {
getEntries,
addDiary
};
ينبغي ألا نستخدم تأكيد النوع أبداً إلا إذا لم يكن هناك طريقة أخرى للمتابعة، إذ يبقى هناك دائماً خطر تأكيد نوع غير مناسب لكائن والتسبب في خطأ تشغيل سيئ. وبينما يثق المترجم بأنك تعرف ما تفعل عند استخدام as، فإننا بهذا لا نستفيد من كامل قوة TypeScript بل نعتمد على المبرمج في تأمين الشيفرة.
في حالتنا، يمكننا تغيير طريقة تصدير بياناتنا بحيث نكتب نوعها داخل ملف البيانات. ولأننا لا نستطيع استخدام الأنواع في ملف JSON، ينبغي تحويل ملف JSON إلى ملف ts باسم entries.ts، يصدّر البيانات المكتوبة النوع هكذا:
import type { DiaryEntry } from "../src/types.ts";
const diaryEntries: DiaryEntry[] = [
{
"id": 1,
"date": "2026-01-01",
"weather": "rainy",
"visibility": "poor",
"comment": "Pretty scary flight, I'm glad I'm alive"
},
// ...
];
export default diaryEntries;
الآن، عندما نستورد المصفوفة، يفسّرها المترجم بشكل صحيح:
import diaries from '../../data/entries.ts';
import type { DiaryEntry } from '../types.ts';
const getEntries = (): DiaryEntry[] => {
return diaries;
}
const addDiary = () => {
return null;
}
export default {
getEntries,
addDiary
};
لاحظ أنه إذا أردنا أن نكون قادرين على حفظ مدخلات دون حقل معيّن، مثل comment، يمكننا ضبط نوع الحقل اختيارياً بإضافة ? إلى تعريف النوع:
export interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;
}
import type
عند استيراد نوع لا يكفي أن تكتب فحسب
import { DiaryEntry } from '../types.ts';
بل يجب بدلاً من ذلك إجراء استيراد الأنواع فقط
import type { DiaryEntry } from '../types.ts';
عند تشغيل ملفات TypeScript مباشرةً بـNode.js، تُزال معلومات أنواع TypeScript في وقت التشغيل. يعني هذا أنه إذا استوردت شيئاً موجوداً كنوع فقط، مثل interface أو type alias، ولم تضع عليه import type, بشكل صريح، فقد يحاول وقت التشغيل حلّه كقيمة JavaScript حقيقية ويفشل.
استخدام import type يخبر كلاً من مترجم TypeScript وتحويل وقت التشغيل بأن هذا الاستيراد موجود لأغراض فحص الأنواع فقط ويجب محوه بالكامل قبل التنفيذ.
لحسن الحظ، هناك قاعدة ESLint هي consistent-type-imports تساعدنا على عدم نسيان استخدام import type. لنفعّل القاعدة في .esling.config.mjs:
rules: {
// ...
"@typescript-eslint/consistent-type-imports": "error",
},الآن يُنبَّهنا عند أي سهو!
الأنواع المساعدة (Utility Types)
أحياناً، قد نرغب في استخدام تعديل معيّن على نوع ما. تخيّل مثلاً صفحة لعرض بعض البيانات، بعضها حساس وبعضها غير حساس. قد نريد التأكد من عدم استخدام أو عرض أي بيانات حساسة. يمكننا اختيار حقول النوع المسموح باستخدامها لفرض ذلك. ويمكننا فعل هذا باستخدام النوع المساعد Pick.
في مشروعنا، ينبغي أن نأخذ في الحسبان أن Ilari قد يرغب في إنشاء قائمة بجميع مدخلات مذكراته باستثناء حقل التعليق، لأنه خلال رحلة مخيفة جداً قد ينتهي به الأمر بكتابة شيء لا يرغب بالضرورة في إظهاره لأي شخص آخر.
يتيح لنا النوع المساعد Pick اختيار حقول نوع موجود نريد استخدامها. يمكن استخدام Pick إما لبناء نوع جديد تماماً أو لإخبار دالة بما ينبغي أن تُرجعه في وقت التشغيل. الأنواع المساعدة نوع خاص من الأنواع، لكن يمكن استخدامها تماماً كالأنواع العادية.
في حالتنا، لإنشاء نسخة «خاضعة للرقابة» من DiaryEntry للعرض العام، يمكننا استخدام Pick في تعريف الدالة:
const getNonSensitiveEntries =
(): Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>[] => {
// ...
}
وسيتوقع المترجم من الدالة أن تُرجع مصفوفة قيم من نوع DiaryEntry المعدّل، تشمل الحقول الأربعة المختارة فقط.
في هذه الحالة، نريد استبعاد حقل واحد فقط، لذا سيكون من الأفضل استخدام النوع المساعد Omit الذي يمكننا استخدامه لتحديد الحقول المراد استبعادها:
const getNonSensitiveEntries = (): Omit<DiaryEntry, 'comment'>[] => {
// ...
}
لتحسين القراءة، ينبغي بالتأكيد تعريف اسم نوع مستعار (type alias) NonSensitiveDiaryEntry في ملف types.ts:
export type NonSensitiveDiaryEntry = Omit<DiaryEntry, 'comment'>;
تتغير الشيفرة هكذا:
import diaries from '../../data/entries.ts';
import type { NonSensitiveDiaryEntry, DiaryEntry } from '../types.ts';
const getEntries = (): DiaryEntry[] => {
return diaries;
};
const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => {
return diaries;
};
const addDiary = () => {
return null;
};
export default {
getEntries,
addDiary,
getNonSensitiveEntries
};
هناك أمر مقلق في تطبيقنا. في getNonSensitiveEntries، نُعيد مدخلات المذكرات كاملة، ولا يظهر أي خطأ رغم كتابة الأنواع!
يحدث هذا لأن TypeScript يفحص فقط ما إذا كانت لدينا جميع الحقول المطلوبة أم لا، لكنه لا يمنع الحقول الزائدة. في حالتنا، يعني هذا أنه غير ممنوع إرجاع كائن من نوع DiaryEntry[]، لكن لو حاولنا الوصول إلى حقل comment لما أمكننا ذلك لأننا سنصل إلى حقل لا يعرفه TypeScript حتى وإن كان موجوداً.
للأسف، قد يؤدي هذا إلى سلوك غير مرغوب إذا لم تكن مدركاً لما تفعله؛ فالوضع سليم بقدر ما يهم TypeScript، لكنك على الأرجح تسمح باستخدام غير مرغوب. لو أعدنا الآن جميع مدخلات المذكرات من دالة getNonSensitiveEntries إلى الواجهة الأمامية، فسنكون نسرّب الحقول غير المرغوبة إلى المتصفح الطالب - حتى وإن بدت أنواعنا تشير إلى غير ذلك!
ولأن TypeScript لا يعدّل البيانات الفعلية بل نوعها فقط، علينا استبعاد الحقول بأنفسنا:
import diaries from '../../data/entries.ts'
import type { NonSensitiveDiaryEntry, DiaryEntry } from '../types.ts'
const getEntries = () : DiaryEntry[] => {
return diaries
}
const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => {
return diaries.map(({ id, date, weather, visibility }) => ({
id,
date,
weather,
visibility,
}));
};
const addDiary = () => {
return null;
}
export default {
getEntries,
getNonSensitiveEntries,
addDiary
}
تشمل الأنواع المساعدة العديد من الأدوات العملية، ومن الجدير بلا شك أن تستغرق بعض الوقت في دراسة التوثيق.
لنغيّر الآن المسار بحيث يُرجع بيانات المذكرات غير الحساسة فقط:
import express from 'express';
import diaryService from '../services/diaryService.ts';
const router = express.Router();
router.get('/', (_req, res) => {
res.send(diaryService.getNonSensitiveEntries());
});
router.post('/', (_req, res) => {
res.send('Saving a diary!');
});
export default router;
الاستجابة هي ما نتوقعه بالضبط:

كتابة أنواع الطلب والاستجابة
حتى الآن لم نناقش أي شيء عن أنواع معاملات معالج المسار.
إذا مرّرنا المؤشر مثلاً فوق المعامل res، نلاحظ أن نوعه هو:
Response<any, Record<string, any>, number>
يبدو غريباً بعض الشيء. النوع Response هو نوع عام (generic type) له ثلاثة معاملات نوع. إذا فتحنا تعريف النوع (بالنقر بزر الفأرة الأيمن واختيار Go to Type Definition في VS Code) نرى ما يلي:
export interface Response<
ResBody = any,
LocalsObj extends Record<string, any> = Record<string, any>,
StatusCode extends number = number,
> extends http.ServerResponse, Express.Response {
معامل النوع الأول هو الأكثر إثارة للاهتمام بالنسبة لنا، فهو يقابل جسم الاستجابة (response body) وقيمته الافتراضية any. ولهذا يقبل مترجم TypeScript أي نوع من الاستجابات ولا نحصل على أي مساعدة لجعل الاستجابة صحيحة.
يمكننا، بل وينبغي لنا على الأرجح، إعطاء نوع مناسب كمتغير النوع. في حالتنا، هو مصفوفة من مدخلات المذكرات:
import express, { type Response } from 'express';
import type { NonSensitiveDiaryEntry } from "../types.ts";
// ...
router.get('/', (_req, res: Response<NonSensitiveDiaryEntry[]>) => {
res.send(diaryService.getNonSensitiveEntries());
});
// ...
إذا حاولنا الآن الاستجابة بنوع بيانات خاطئ، نحصل على خطأ نوع

وبالمثل، فإن معامل الطلب من النوع Request وهو أيضاً نوع عام. سنلقي نظرة أقرب عليه لاحقاً.
11. الواجهة الخلفية لـPatientor، الخطوة 3
12. الواجهة الخلفية لـPatientor، الخطوة 4
منع نتيجة undefined غير المقصودة
لنوسّع الواجهة الخلفية لدعم جلب مدخلة واحدة محددة بطلب HTTP GET إلى المسار api/diaries/:id.
يحتاج DiaryService إلى توسعة بدالة findById:
// ...
const findById = (id: number): DiaryEntry => {
const entry = diaries.find(d => d.id === id);
return entry;
};
export default {
getEntries,
getNonSensitiveEntries,
addDiary,
findById
}
لكن تظهر مرة أخرى مشكلة جديدة:

المشكلة هي أنه لا يوجد ضمان بإمكانية العثور على مدخلة بالمعرّف المحدد. من الجيد أن يُنبَّهنا إلى هذه المشكلة المحتملة في مرحلة الترجمة أصلاً. بدون TypeScript لن يُحذَّرنا من هذه المشكلة، وفي أسوأ الأحوال كنا سننتهي بإرجاع كائن undefined بدلاً من إخبار المستخدم بأن المدخلة المحددة غير موجودة.
أولاً وقبل كل شيء، في حالات كهذه، علينا أن نقرر ما ينبغي أن تكون عليه قيمة الإرجاع إذا لم يُعثر على الكائن، وكيف ينبغي التعامل مع الحالة. تُرجع دالة find في المصفوفة القيمة undefined إذا لم يُعثر على الكائن، وهذا مناسب. يمكننا حل مشكلتنا بكتابة نوع قيمة الإرجاع كما يلي:
const findById = (id: number): DiaryEntry | undefined => {
const entry = diaries.find(d => d.id === id);
return entry;
}
معالج المسار هو التالي:
import express from 'express';
import diaryService from '../services/diaryService.ts'
router.get('/:id', (req, res) => {
const diary = diaryService.findById(Number(req.params.id));
if (diary) {
res.send(diary);
} else {
res.sendStatus(404);
}
});
// ...
export default router;
إضافة مذكرة جديدة
لنبدأ ببناء نقطة نهاية HTTP POST لإضافة مدخلات مذكرات رحلات جديدة. ينبغي أن تكون المدخلات الجديدة من النوع نفسه الذي للبيانات الموجودة.
تبدو شيفرة معالجة الاستجابة كما يلي:
router.post('/', (req, res) => {
const { date, weather, visibility, comment } = req.body;
const addedEntry = diaryService.addDiary({
date,
weather,
visibility,
comment,
});
res.json(addedEntry);
})
إذن، تفكّك الشيفرة المعاملات من جسم الطلب (request body) وتضعها في كائن يُعطى كمعامل للدالة addDiary في diaryService.
لكن انتظر، ما نوع هذا الكائن؟ إنه ليس بالضبط DiaryEntry، لأنه ما زال يفتقد حقل id. قد يكون من المفيد إنشاء نوع جديد NewDiaryEntry لمدخلة لم تُحفظ بعد. لننشئه في types.ts باستخدام نوع DiaryEntry الموجود والنوع المساعد Omit:
export type NewDiaryEntry = Omit<DiaryEntry, 'id'>;
الآن يمكننا استخدام النوع الجديد في diaryService، وتفكيك كائن المدخلة الجديدة عند إنشاء مدخلة ليتم حفظها:
import type { NewDiaryEntry, NonSensitiveDiaryEntry, DiaryEntry } from '../types';
// ...
const addDiary = ( entry: NewDiaryEntry ): DiaryEntry => {
const newDiaryEntry = {
id: Math.max(...diaries.map(d => d.id)) + 1,
...entry
};
diaries.push(newDiaryEntry);
return newDiaryEntry;
};
هناك الكثير من الأحمر في محررنا:

السبب هو قاعدة ESlint @typescript-eslint/no-unsafe-assignment التي تمنعنا من إسناد حقول جسم الطلب إلى متغيرات.
في الوقت الحالي، لنتجاهل قاعدة ESlint من الملف كله بإضافة السطر التالي كأول سطر في الملف:
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
لتحليل البيانات الواردة، يجب أن يكون وسيط json مهيأً:
import express from 'express';
import diaryRouter from './routes/diaries.ts';
const app = express();
app.use(express.json());
const PORT = 3000;
app.use('/api/diaries', diaryRouter);
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
الآن التطبيق جاهز لاستقبال طلبات HTTP POST لمدخلات مذكرات جديدة من النوع الصحيح!
التحقق من صحة الطلبات
هناك الكثير من الأمور التي قد تسوء عندما نقبل بيانات من مصادر خارجية. نادراً ما تعمل التطبيقات بالكامل بمفردها، ونحن مضطرون للتعايش مع حقيقة أن البيانات من مصادر خارج نظامنا لا يمكن الوثوق بها تماماً. عندما نستقبل بيانات من مصدر خارجي، لا سبيل لأن تكون مكتوبة النوع أصلاً عندما تصل إلينا. علينا اتخاذ قرارات حول كيفية التعامل مع عدم اليقين المصاحب لذلك.
قاعدة ESlint المعطّلة كانت تشير إلى أن الإسناد التالي محفوف بالمخاطر:
const newDiaryEntry = diaryService.addDiary({
date,
weather,
visibility,
comment,
});
نودّ الحصول على ضمان بأن الكائن في طلب POST من النوع الصحيح. لنعرّف الآن دالة parseNewDiaryEntry تستقبل جسم الطلب كمعامل وتُرجع كائن NewDiaryEntry مكتوب النوع بشكل صحيح. ستُعرَّف الدالة في ملف utils.ts.
يستخدم تعريف المسار الدالة كما يلي:
import parseNewDiaryEntry from '../utils.ts';
// ...
router.post('/', (req, res) => {
try {
const newDiaryEntry = parseNewDiaryEntry(req.body);
const addedEntry = diaryService.addDiary(newDiaryEntry);
res.json(addedEntry);
} catch (error: unknown) {
let errorMessage = 'Something went wrong.';
if (error instanceof Error) {
errorMessage += ' Error: ' + error.message;
}
res.status(400).send(errorMessage);
}
})
يمكننا الآن أيضاً إزالة السطر الأول الذي يتجاهل قاعدة ESLint no-unsafe-assignment.
وبما أننا نكتب الآن شيفرة آمنة ونحاول ضمان حصولنا بالضبط على البيانات التي نريدها من الطلبات، ينبغي أن نبدأ بتحليل والتحقق من صحة كل حقل نتوقع استقباله.
يبدو هيكل دالة parseNewDiaryEntry كالتالي:
import type { NewDiaryEntry } from './types.ts';
const parseNewDiaryEntry = (object): NewDiaryEntry => {
const newEntry: NewDiaryEntry = {
// ...
};
return newEntry;
};
export default parseNewDiaryEntry;
ينبغي أن تحلل الدالة كل حقل وأن تتأكد من أن قيمة الإرجاع من النوع NewDiaryEntry بالضبط. يعني هذا أنه يجب علينا فحص كل حقل على حدة.
مرة أخرى، لدينا مشكلة نوع: ما نوع المعامل object؟ بما أن object هو جسم طلب، فقد كتبه Express بالنوع any. ولأن فكرة هذه الدالة هي تحويل حقول مجهولة النوع إلى حقول بالنوع الصحيح والتحقق مما إذا كانت معرّفة كما هو متوقع، فقد تكون هذه هي الحالة النادرة التي نريد فيها السماح بالنوع any.
لكن إذا كتبنا نوع الكائن كـany، تشتكي ESLint من ذلك:

يمكننا تجاهل قاعدة ESlint لكن الفكرة الأفضل هي اتباع إحدى النصائح التي يعرضها المحرر في Quick Fix وضبط نوع المعامل على unknown:
import type { NewDiaryEntry } from './types.ts';
const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
const newEntry: NewDiaryEntry = {
// ...
}
return newEntry;
}
export default parseNewDiaryEntry;
unknown هو النوع الأمثل لحالتنا هذه من التحقق من صحة المدخلات، لأننا لا نحتاج بعد إلى تعريف نوع يطابق any، بل يمكننا أولاً التحقق من النوع ثم تأكيد أنه النوع المتوقع. باستخدام unknown لا نحتاج أيضاً للقلق بشأن قاعدة ESlint @typescript-eslint/no-explicit-any، لأننا لا نستخدم any. غير أننا قد نحتاج مع ذلك إلى استخدام any في بعض الحالات التي لا نكون فيها بعد متأكدين من النوع ونحتاج إلى الوصول إلى خصائص كائن من نوع any للتحقق من قيم الخصائص نفسها أو فحص أنواعها.
ملاحظة جانبية من المحرّر
إذا كنت مثلي وتكره أن تبقى شيفرتك في حالة معطوبة لفترة طويلة بسبب أنواع غير مكتملة، يمكنك البدء بـ«تزييف» الدالة:
const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
console.log(object); // الآن لم يعد object غير مستخدم const newEntry: NewDiaryEntry = { weather: 'cloudy', // زيِّف قيمة الإرجاع visibility: 'great', date: '2026-1-1', comment: 'fake news' };
return newEntry; };
إذن، قبل أن تصبح البيانات والأنواع الحقيقية جاهزة للاستخدام، أُعيد هنا شيئاً من النوع الصحيح بالتأكيد. تبقى الشيفرة في حالة تشغيلية طوال الوقت ويبقى ضغط دمي في مستوياته الطبيعية.
حرّاس الأنواع
لنبدأ بإنشاء المحلّلات لكل حقل من حقول المعامل object: unknown.
للتحقق من صحة حقل comment، علينا فحص وجوده والتأكد من أنه من النوع string.
ينبغي أن تبدو الدالة هكذا تقريباً:
const parseComment = (comment: unknown): string => {
if (!comment || !isString(comment)) {
throw new Error('Incorrect or missing comment');
}
return comment;
};
تستقبل الدالة معاملاً من نوع unknown وتُرجعه بالنوع string إذا كان موجوداً وبالنوع الصحيح.
تبدو دالة التحقق من النص كما يلي:
const isString = (text: unknown): text is string => {
return typeof text === 'string' || text instanceof String;
};
الدالة هي ما يُسمى حارس نوع (type guard). يعني ذلك أنها دالة تُرجع قيمة منطقية و لديها مُحمَّل نوع (type predicate) كنوع إرجاع. في حالتنا، مُحمَّل النوع هو:
text is string
الصيغة العامة لمُحمَّل النوع هي parameterName is Type حيث parameterName هو اسم معامل الدالة وType هو النوع المستهدف.
إذا أعادت دالة حارس النوع القيمة true، يعرف مترجم TypeScript أن المتغير المفحوص من النوع المعرَّف في مُحمَّل النوع.
قبل استدعاء حارس النوع، لا يكون النوع الفعلي للمتغير comment معروفاً:

لكن بعد الاستدعاء، إذا استمرت الشيفرة بعد الاستثناء (أي أن حارس النوع أعاد true)، يعرف المترجم أن comment من النوع string

استخدام حارس نوع يُرجع مُحمَّل نوع هو إحدى طرق القيام بـتضييق النوع (type narrowing)، أي إعطاء متغير نوع أكثر صرامة أو دقة. وكما سنرى قريباً، هناك أيضاً أنواع أخرى من حرّاس الأنواع متاحة.
ملاحظة جانبية: اختبار ما إذا كان شيء ما نصاً
لماذا لدينا شرطان في حارس نوع النص؟
const isString = (text: unknown): text is string => { return typeof text === 'string' || text instanceof String;}
ألا يكفي كتابة الحارس هكذا؟
const isString = (text: unknown): text is string => { return typeof text === 'string'; }
على الأرجح، الصيغة الأبسط كافية لجميع الأغراض العملية. لكن إذا أردنا التأكد، فثمة حاجة إلى الشرطين معاً. هناك طريقتان مختلفتان لإنشاء نص في JavaScript، إحداهما كنوع أولي (primitive) والأخرى ككائن، وكلتاهما تعمل بشكل مختلف قليلاً عند مقارنتهما بالمعاملين typeof وinstanceof:
const a = "I'm a string primitive"; const b = new String("I'm a String Object"); typeof a; --> returns 'string' typeof b; --> returns 'object' a instanceof String; --> returns false b instanceof String; --> returns true
غير أنه من غير المرجح أن ينشئ أحد نصاً بدالة إنشاء (constructor). على الأرجح ستكون النسخة الأبسط من حارس النوع كافية تماماً.
بعد ذلك، لننظر في حقل date. تحليل كائن التاريخ والتحقق من صحته مشابه جداً لما فعلناه مع التعليقات. وبما أن TypeScript لا يعرف نوعاً للتاريخ، علينا التعامل معه كنص string. ينبغي مع ذلك أن نستخدم تحققاً على مستوى JavaScript لفحص ما إذا كانت صيغة التاريخ مقبولة.
سنضيف الدالتين التاليتين:
const isDate = (date: string): boolean => {
return Boolean(Date.parse(date));
};
const parseDate = (date: unknown): string => {
if (!date || !isString(date) || !isDate(date)) {
throw new Error('Incorrect or missing date: ' + date);
}
return date;
};
الشيفرة ليست شيئاً مميزاً. الأمر الوحيد هو أننا لا نستطيع استخدام حارس نوع مبني على مُحمَّل نوع هنا، لأن التاريخ في هذه الحالة يُعتبر مجرد string. لاحظ أنه رغم أن دالة parseDate تقبل المتغير date بوصفه unknown، فبعد فحص نوعه بـisString، يعرف مترجم TypeScript أن نوعه string، ولهذا يمكننا إعطاء المتغير لدالة isDate التي تتطلب نصاً دون أي مشاكل.
أخيراً، نحن جاهزون للانتقال إلى النوعين الأخيرين، Weather وVisibility.
نريد أن يعمل التحقق والتحليل كما يلي:
const parseWeather = (weather: unknown): Weather => {
if (!weather || !isString(weather) || !isWeather(weather)) {
throw new Error('Incorrect or missing weather: ' + weather);
}
return weather;
};
السؤال هو: كيف يمكننا التحقق من أن النص بصيغة محددة؟ إحدى الطرق الممكنة لكتابة حارس النوع هي هذه:
const isWeather = (str: string): str is Weather => {
return ['sunny', 'rainy', 'cloudy', 'stormy'].includes(str);
};
سيعمل هذا بشكل جيد، لكن المشكلة أن قائمة القيم الممكنة لـWeather لا تبقى بالضرورة متزامنة مع تعريفات الأنواع إذا عُدّل النوع. هذا بالتأكيد ليس جيداً، لأننا نريد مصدراً واحداً فقط لجميع أنواع الطقس الممكنة.
كائن as const
في حالتنا، سيكون الحل الأفضل هو تحسين نوع Weather نفسه. بدلاً من اسم نوع مستعار، يمكننا استخدام كائن const، وهو يتيح لنا استخدام القيم الفعلية في شيفرتنا في وقت التشغيل، وليس في مرحلة الترجمة فقط.
لنُعِد تعريف النوع Weather كما يلي:
export const Weather = {
Sunny: 'sunny',
Rainy: 'rainy',
Cloudy: 'cloudy',
Stormy: 'stormy',
Windy: 'windy',
} as const;
export type Weather = typeof Weather[keyof typeof Weather];
لاحظ أننا نعرّف كائناً ثابتاً (const object) ونوعاً بالاسم نفسه. يتيح TypeScript ذلك لأنهما يعيشان في مساحتَي أسماء منفصلتين. النوع مشتق مباشرةً من قيم الكائن، لذا يبقيان متزامنين تلقائياً دائماً.
الآن يمكننا التحقق من أن نصاً ما هو إحدى القيم المقبولة، ويمكن كتابة حارس النوع هكذا:
const isWeather = (param: string): param is Weather => {
return (Object.values(Weather) as string[]).includes(param);
};
لا يوجد شيء مفاجئ في المحلّل:
const parseWeather = (weather: unknown): Weather => {
if (!weather || !isString(weather) || !isWeather(weather)) {
throw new Error('Incorrect or missing weather: ' + weather);
}
return weather;
};
ما زلنا بحاجة إلى إعطاء المعاملة نفسها للنوع Visibility. يبدو كائن const كما يلي:
export const Visibility = {
Great: 'great',
Good: 'good',
Ok: 'ok',
Poor: 'poor',
} as const;
export type Visibility = typeof Visibility[keyof typeof Visibility];
حارس النوع والمحلّل أدناه:
const isVisibility = (param: string): param is Visibility => {
return (Object.values(Visibility) as string[]).includes(param);
};
const parseVisibility = (visibility: unknown): Visibility => {
if (!visibility || !isString(visibility) || !isVisibility(visibility)) {
throw new Error('Incorrect or missing visibility: ' + visibility);
}
return visibility;
};
تُستخدم كائنات as const عادةً عندما تكون هناك مجموعة قيم محددة سلفاً لا يُتوقع أن تتغير مستقبلاً. وهي تقدّم طريقة رائعة للتحقق من القيم الواردة مع بقائها كائنات JavaScript عادية، مما يجعلها أكثر مرونة في بعض الحالات من enums، التي كانت سابقاً خياراً شائعاً لتعريف غرض مشابه.
ما هو في الحقيقة النوع Visibility وكائن Visibility
عندما عرّفنا
export const Visibility = {
Great: 'great',
Good: 'good',
Ok: 'ok',
Poor: 'poor',
} as const;
export type Visibility = typeof Visibility[keyof typeof Visibility];عرّفنا شيئين متميزين، كائن const المسمى Visibility والنوع Visibility، ومن المثير للاهتمام أن TypeScript يتيح تعايشهما رغم حمل الاسم نفسه.
إذا مرّرنا المؤشر فوق النوع Visibility، نرى ما هو في الحقيقة:
إذن هو ببساطة يُختزل إلى اتحاد أربع قيم نصية حرفية. يمكن استخدامه مثلاً كما يلي:
const x: Visibility = "great";
const y: Visibility = Visibility.Ok;في المثال الأخير، نستخدم كائن const المسمى Visibility ونسند قيمته Ok (أي النص ok) إلى y.
يمكننا أيضاً استخدام أسماء مختلفة لكائن const والنوع:
export const VisibilityValues = {
Great: 'great',
Good: 'good',
Ok: 'ok',
Poor: 'poor',
} as const;
export type Visibility = typeof VisibilityValues[keyof typeof VisibilityValues];عندها يصبح الحارس
import { VisibilityValues, type Visibility, ... } from './types.ts';
const isVisibility = (param: string): param is Visibility => {
return (Object.values(VisibilityValues) as string[]).includes(param);
};في هذه الحالة، سنحتاج أيضاً إلى استيراد كليهما على حدة الآن. وبعد أن فهمنا ما يجري، سنلتزم باستخدام الاسم نفسه لكليهما لأنه يجعل الشيفرة أقل إسهاباً قليلاً.
لكن لا يزال هناك سؤال واحد. ما هو بحق العالم
typeof Visibility[keyof typeof Visibility]
لنفككه خطوة بخطوة. يعطيك typeof Visibility نوع الكائن نفسه:
{ readonly Great: 'great', readonly Good: 'good', readonly Ok: 'ok', readonly Poor: 'poor', }
يستخرج keyof جميع مفاتيح ذلك النوع كاتحاد:
'Great' | 'Good' | 'Ok' | 'Poor'
الطبقة الأخيرة، الفهرسة في typeof Visibility باستخدام تلك المفاتيح، تبحث عن أنواع القيم لكل مفتاح من الكائن Visibility، منتجةً اتحاداً لجميع أنواع القيم (وهي في حالتنا مجرد نصوص حرفية):
'great' | 'good' | 'ok' | 'poor'
أخيراً، يمكننا إنهاء دالة parseNewDiaryEntry التي تتولى التحقق من صحة حقول جسم POST وتحليلها. غير أن هناك أمراً آخر يجب الاعتناء به. إذا حاولنا الوصول إلى حقول المعامل object كما يلي:
const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
const newEntry: NewDiaryEntry = {
comment: parseComment(object.comment),
date: parseDate(object.date),
weather: parseWeather(object.weather),
visibility: parseVisibility(object.visibility)
};
return newEntry;
};
لا تجتاز الشيفرة فحص الأنواع:

السبب هو أن نوع unknown لا يسمح بأي عمليات، لذا لا يمكن الوصول إلى الحقول:
يمكننا إصلاح المشكلة مرة أخرى بتضييق النوع. لدينا الآن حارسا نوع، أولهما يفحص وجود كائن المعامل وأنه من النوع object. بعد ذلك، يستخدم حارس النوع الثاني المعامل in للتأكد من أن الكائن يملك جميع الحقول المطلوبة:
const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
if ( !object || typeof object !== 'object' ) {
throw new Error('Incorrect or missing data');
}
if ('comment' in object && 'date' in object && 'weather' in object && 'visibility' in object) {
const newEntry: NewDiaryEntry = {
weather: parseWeather(object.weather),
visibility: parseVisibility(object.visibility),
date: parseDate(object.date),
comment: parseComment(object.comment)
};
return newEntry;
}
throw new Error('Incorrect data: some fields are missing');
};
إذا لم يُقيَّم الحارس إلى true، يُرمى استثناء.
استخدام المعامل in يضمن فعلياً وجود الحقول في الكائن. وبسبب ذلك، لم تعد فحوصات الوجود في المحلّلات ضرورية:
const parseVisibility = (visibility: unknown): Visibility => {
// أُزيل فحص !visibility
if (!isString(visibility) || !isVisibility(visibility)) {
throw new Error('Incorrect visibility: ' + visibility);
}
return visibility;
};
إذا كان حقل ما، مثل comment، اختيارياً، ينبغي أن يأخذ تضييق النوع ذلك في الحسبان، ولم يكن ممكناً استخدام المعامل in تماماً كما فعلنا هنا، لأن اختبار in يتطلب وجود الحقل.
إذا حاولنا الآن إنشاء مدخلة مذكرة جديدة بحقول غير صالحة أو ناقصة، نحصل على رسالة خطأ مناسبة:

يمكن العثور على الشيفرة المصدرية للتطبيق على GitHub.
13. الواجهة الخلفية لـPatientor، الخطوة 5
14. الواجهة الخلفية لـPatientor، الخطوة 6
استخدام مكتبات التحقق من المخططات
كتابة مدقّق لجسم الطلب قد تكون عبئاً كبيراً. لحسن الحظ، توجد عدة مكتبات للتحقق من المخططات (schema validator libraries) يمكنها المساعدة. لنلقِ الآن نظرة على Zod التي تعمل بشكل جيد جداً مع TypeScript.
لنبدأ:
npm install zod
إن محلّلات الحقول ذات القيم الأولية مثل
const isString = (text: unknown): text is string => {
return typeof text === 'string' || text instanceof String;
};
const parseComment = (comment: unknown): string => {
if (!isString(comment)) {
throw new Error('Incorrect comment');
}
return comment;
};
يسهل استبدالها كما يلي:
import { z } from 'zod';
// ...
const parseComment = (comment: unknown): string => {
return z.string().parse(comment);
};
أولاً تُستخدم دالة string في Zod لتعريف النوع المطلوب (أو المخطط بمصطلحات Zod). بعد ذلك يُحلَّل المتغير (وهو من النوع unknown) بالدالة parse التي تُرجع القيمة بالنوع المطلوب أو ترمي استثناءً.
لا نحتاج فعلياً إلى الدالة المساعدة parseComment بعد الآن، ويمكننا استخدام محلّل Zod مباشرةً:
export const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
if ( !object || typeof object !== 'object' ) {
throw new Error('Incorrect or missing data');
}
if ('comment' in object && 'date' in object && 'weather' in object && 'visibility' in object) {
const newEntry: NewDiaryEntry = {
weather: parseWeather(object.weather),
visibility: parseVisibility(object.visibility),
date: parseDate(object.date),
comment: z.string().parse(object.comment)
};
return newEntry;
}
throw new Error('Incorrect data: some fields are missing');
};
لدى Zod مجموعة من عمليات التحقق الخاصة بالنصوص، مثلاً واحدة تتحقق مما إذا كان النص تاريخاً صالحاً، لذا نتخلص أيضاً من محلّل حقل التاريخ:
export const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
if ( !object || typeof object !== 'object' ) {
throw new Error('Incorrect or missing data');
}
if ('comment' in object && 'date' in object && 'weather' in object && 'visibility' in object) {
const newEntry: NewDiaryEntry = {
weather: parseWeather(object.weather),
visibility: parseVisibility(object.visibility),
date: z.iso.date().parse(object.date),
comment: z.string().optional().parse(object.comment)
};
return newEntry;
}
throw new Error('Incorrect data: some fields are missing');
};
قرّرنا أيضاً جعل حقل التعليق اختيارياً.
مدقّق Zod الخاص بـenums يناسب حالة تكون فيها المدخلات الممكنة مجموعة ثابتة من النصوص، وهذه هي الحالة مع weather وvisibility.
export const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
if ( !object || typeof object !== 'object' ) {
throw new Error('Incorrect or missing data');
}
if ('comment' in object && 'date' in object && 'weather' in object && 'visibility' in object) {
const newEntry: NewDiaryEntry = {
weather: z.enum(Weather).parse(object.weather),
visibility: z.enum(Visibility).parse(object.visibility),
date: z.iso.date().parse(object.date),
comment: z.string().parse(object.comment)
};
throw new Error('Incorrect data: some fields are missing');
};
حتى الآن استخدمنا Zod فقط لتحليل نوع أو مخطط الحقول الفردية، لكن يمكننا المضي خطوة أبعد وتعريف مدخلة المذكرة الجديدة كاملةً كمخطط كائن في Zod:
const NewEntrySchema = z.object({
weather: z.enum(Weather),
visibility: z.enum(Visibility),
date: z.iso.date(),
comment: z.string().optional()
});
الآن يكفي استدعاء parse على المخطط المعرَّف:
export const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
return NewEntrySchema.parse(object);
};
بمساعدة التوثيق يمكننا أيضاً تحسين معالجة الأخطاء:
import { z } from 'zod';
//
router.post('/', (req, res) => {
try {
const newDiaryEntry = parseNewDiaryEntry(req.body);
const addedEntry = diaryService.addDiary(newDiaryEntry);
res.json(addedEntry);
} catch (error: unknown) {
if (error instanceof z.ZodError) {
res.status(400).send({ error: error.issues });
} else {
res.status(400).send({ error: 'unknown error' });
}
}
});
تبدو الاستجابة في حالة الخطأ جيدة جداً:

يمكننا تطوير حلنا بضع خطوات أخرى. تبدو تعريفات أنواعنا حالياً هكذا:
export interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;
}
export type NewDiaryEntry = Omit<DiaryEntry, 'id'>;
إذن، إلى جانب النوع NewDiaryEntry لدينا أيضاً مخطط Zod باسم NewEntrySchema يعرّف شكل المدخلة الجديدة. يمكننا استخدام المخطط لـاستنتاج النوع:
import { z } from 'zod';
import { NewEntrySchema } from './utils.ts'
export interface DiaryEntry {
id: number;
date: string;
weather: Weather;
visibility: Visibility;
comment?: string;
}
// استنتاج النوع من المخطط
export type NewDiaryEntry = z.infer<typeof NewEntrySchema>;
يمكننا المضي في هذا أبعد قليلاً وتعريف DiaryEntry انطلاقاً من NewDiaryEntry:
export type NewDiaryEntry = z.infer<typeof NewEntrySchema>;
export interface DiaryEntry extends NewDiaryEntry {
id: number;
}
يزيل هذا كل التكرار في تعريفات الأنواع والمخططات. يبدو الأمر رجعياً بعض الشيء، لكن للأسف العكس غير ممكن: لا يمكننا تعريف مخطط Zod انطلاقاً من تعريفات أنواع TypeScript، لذا أصبح مخطط Zod الآن المصدر الوحيد لنوعنا. وبما أن المخطط هو أيضاً أساس النوع، سننقل تعريف المخطط إلى ملف types.ts.
يمكن العثور على الحالة الحالية للشيفرة المصدرية في فرع part2 من مستودع GitHub هذا.
تحليل جسم الطلب في الوسيط
يمكننا الآن التخلص من هذه الدالة كلياً
export const parseNewDiaryEntry = (object: unknown): NewDiaryEntry => {
return NewEntrySchema.parse(object);
};
واستدعاء محلّل Zod مباشرةً في معالج المسار:
import { NewEntrySchema, type NonSensitiveDiaryEntry } from '../types.ts';
router.post('/', (req, res) => { try {
const newDiaryEntry = NewEntrySchema.parse(req.body);
const addedEntry = diaryService.addDiary(newDiaryEntry);
res.json(addedEntry);
} catch (error: unknown) {
if (error instanceof z.ZodError) {
res.status(400).send({ error: error.issues });
} else {
res.status(400).send({ error: 'unknown error' });
}
}
});
يمكننا المضي قدماً خطوة إضافية. فبدلاً من استدعاء دالة تحليل جسم الطلب صراحةً في معالج المسار، يمكن أيضاً إجراء التحقق من صحة المدخلات في دالة وسيط.
أضفنا أيضاً تعريفات الأنواع إلى معاملات معالج المسار، وسنستخدم الأنواع أيضاً في دالة الوسيط newDiaryParser:
import express, { type Request, type Response, type NextFunction } from 'express';
// ...
const newDiaryParser = (req: Request, _res: Response, next: NextFunction) => {
try {
NewEntrySchema.parse(req.body);
next();
} catch (error: unknown) {
next(error);
}
};
يستدعي الوسيط فقط محلّل المخطط على جسم الطلب. وإذا رمى التحليل استثناءً، يُمرَّر إلى وسيط معالجة الأخطاء.
إذن، بعد أن يجتاز الطلب هذا الوسيط، يُعرف أن جسم الطلب مدخلة مذكرة جديدة سليمة. يمكننا إخبار مترجم TypeScript بهذه الحقيقة بإعطاء معامل نوع لـRequest:
router.post('/', newDiaryParser, (req: Request<unknown, unknown, NewDiaryEntry>, res: Response<DiaryEntry>) => {
const addedEntry = diaryService.addDiary(req.body);
res.json(addedEntry);
});
بفضل الوسيط، أصبح جسم الطلب معروف النوع الصحيح ويمكن إعطاؤه مباشرةً كمعامل للدالة diaryService.addDiary.
تبدو صيغة Request<unknown, unknown, NewDiaryEntry> غريبة بعض الشيء. النوع Request هو نوع عام له عدة معاملات نوع. معامل النوع الثالث يمثّل جسم الطلب، ولكي نعطيه القيمة NewDiaryEntry علينا إعطاء قيمة ما للمعاملين الأولين. نقرر تعريفهما كـunknown لأننا لا نحتاجهما الآن.
وبما أن الأخطاء المحتملة في التحقق تُعالج الآن في وسيط معالجة الأخطاء، نحتاج إلى تعريف وسيط يتعامل مع أخطاء Zod بشكل صحيح:
const errorMiddleware = (error: unknown, _req: Request, res: Response, next: NextFunction) => {
if (error instanceof z.ZodError) {
res.status(400).send({ error: error.issues });
} else {
next(error);
}
};
router.post('/', newDiaryParser, (req: Request<unknown, unknown, NewDiaryEntry>, res: Response<DiaryEntry>) => {
// ...
});
router.use(errorMiddleware);
يمكن العثور على النسخة النهائية من الشيفرة المصدرية في فرع part3 من مستودع GitHub هذا.
15. الواجهة الخلفية لـPatientor، الخطوة 7
16. فحص