الإقليم ٦ — الحدود التي لا تُوثَق: التحقّق وقت التشغيل (Zod)
النبذة
هذا الإقليم قصيرٌ مفاهيمياً لكنه يختم أهمَّ خيطٍ في المنهج كلّه. منذ الإقليم ١ تحمل علامةً: الأنواع تُمحى وقت التشغيل. ومنذ الإقليم ٣ تركنا req.body معلّقاً كـ any لا يُوثَق. ومنذ ٥ رأينا Prisma يعيد النوعَ من جهة القاعدة. الآن نعيده من جهة الشبكة، ونفهم لماذا لا غنى عن حارسٍ يعمل وقت التشغيل حيث لا أنواع. من يفهم هذا الإقليم، يفهم لماذا تنهار برامجُ كثيرةٌ رغم أنها "مكتوبةُ النوع".
اللغز المستفزّ
متحكّمُك يبدأ هكذا (نمطٌ شائعٌ خطير):
tstype CreateUserInput = { username: string; email: string; password: string; role: "USER" | "ADMIN" }; export async function createUser(req: Request, res: Response) { const body = req.body as CreateUserInput; // "أنا متأكّد أنه بهذا الشكل" const hash = await bcrypt.hash(body.password, 10); // ...هل أنت متأكّد؟ await prisma.user.create({ data: { ...body, password: hash } }); }
المترجمُ سعيدٌ تماماً. صدّقك حين قلت as CreateUserInput. لكن الشبكةُ ترسل ما تشاء. توقّع نتيجةَ كلٍّ قبل أن تقرأ:
- جسمٌ بلا
passwordإطلاقاً →body.passwordيصيرundefined→bcrypt.hash(undefined, 10)→ ماذا؟ password: 12345(رقمٌ لا نصّ) → ماذا يحدث للتجزئة؟role: "SUPERADMIN"→ النوع يقول"USER" | "ADMIN"، لكن لا أحد يفحص وقت التشغيل → يُكتب في القاعدة دورٌ لا تتوقّعه (لو لم يمنعه enum القاعدة).{ ...body }فيه حقلٌ زائدٌ خبيثisAdmin: trueأوid: "..."→ يُسنَد جماعياً (mass assignment) لِما لم تقصد.- جسمٌ ليس JSON أصلاً، أو متداخلٌ بعمقٍ لتفجير الذاكرة.
ولا واحدٌ منها يمسكه المترجم. لماذا؟
لأن as CreateUserInput كذبةٌ تُصدَّق وقتَ الترجمة وتُمحى وقتَ التشغيل. الفحصُ انتهى قبل أن يبدأ البرنامج؛ والبياناتُ تصل بعد أن بدأ، حيث لا نوعَ أصلاً. المترجمُ لا يستطيع الوصولَ إلى الشبكة — هي خارج عالمه الزمنيّ.
السؤال المستفزّ:
اكتب حارساً يحوّل بايتاتٍ مجهولةً غيرَ موثوقة (unknown) إلى قيمةٍ موثوقةٍ مكتوبةِ النوع — أو يرفضها — وقت التشغيل. ومن أين يأتي النوعُ بعد الحراسة، إن كانت الأنواع تُمحى؟
ليش: الهاوية الأساسيّة — وأين تنتهي ضمانةُ المترجم
ثبّت هذه الحقيقةَ فوق كل ما سبق:
الأنواعُ الساكنةُ تحرس الكودَ الذي كتبتَه. لا تحرس البياناتِ التي تدخل برنامجَك من الخارج وقتَ التشغيل. ضمانةُ المترجم تنتهي عند حدّ البرنامج. كلُّ ما يعبر هذا الحدّ — طلبُ شبكةٍ، صفُّ قاعدةٍ، ملفٌّ، متغيّرُ بيئة، ردُّ API خارجيّ — يصل في زمنٍ لا يملك فيه المترجمُ أي سلطة.
إذن كلُّ حدٍّ (boundary) يحتاج بوّابةً تعمل وقت التشغيل. تحويلُ المجهول (unknown) إلى معلومٍ موثوقٍ ليس ترفاً — هو الوحيدُ الذي يجعل بقيّةَ برنامجك المكتوبِ-النوع صادقاً. بلا هذه البوّابة، كلُّ أنواعِك بعد الحدّ أوهامٌ مبنيّةٌ على كذبة as.
ثلاثُ طبقاتِ حراسةٍ — الآن تكتمل الصورة
جمعنا عبر المنهج ثلاثةَ حرّاسٍ، كلٌّ يحرس شيئاً مختلفاً في زمنٍ مختلف. لا يغني أحدُهم عن الآخر:
| الحارس | متى | يحرس ماذا | غرضُه |
|---|---|---|---|
| مترجم TS (١) | وقت الكتابة/الترجمة | الكودَ الداخليّ الذي كتبتَه | اصطيادُ أخطائك مبكراً (ثم يُمحى) |
| Zod (٦) | وقت التشغيل، عند حدّ HTTP | البياناتِ الداخلةَ من الشبكة | تحويلُ مجهولٍ→موثوقٍ + رسائلُ خطأٍ لطيفة |
| قيودُ القاعدة (٤) | وقت التشغيل، آخرُ خطّ | كلَّ كتابةٍ مهما كان مصدرُها | الصحّةُ الحتميّةُ المطلقة |
مثالٌ يوضّح لِمَ الثلاثة: تفرّدُ username. Zod لا يستطيع فحصَه (لا يرى القاعدة)، فيمرّ. القاعدةُ تفرضه ذرّياً (٤). والمترجمُ لا علاقةَ له أصلاً. ومثالٌ معاكس: "كلمةُ المرور ٨ أحرفٍ على الأقلّ" — Zod يفحصها بلطفٍ ويعطي رسالةً للمستخدم، بينما القاعدةُ لا تكترث. كلٌّ في موضعه.
ليش وكيف: Zod — السكيمةُ قيمةٌ وقتَ التشغيل
في TS، النوعُ خياليٌّ يُمحى. فكرةُ Zod: اجعل السكيمةَ قيمةً حقيقيّةً (كائنَ JS موجوداً وقت التشغيل) تعرف كيف تفحص قيمةً مجهولةً وتقرّر: مطابِقةٌ (أعِدها مكتوبةَ النوع) أو لا (أعِد أخطاءً مفصّلة).
tsimport { z } from "zod"; const createUserSchema = z.object({ username: z.string().trim().min(3, "3 أحرف على الأقل"), email: z.email("بريدٌ غير صالح"), password: z.string().min(8, "8 أحرف على الأقل"), role: z.enum(["USER", "ADMIN"]).optional(), });
createUserSchema ليس نوعاً — هو كائنٌ تستطيع أن تطبعه وتمرّره وتنادي عليه دوالّ. هذا هو الفرق: يعيش وقت التشغيل حيث الأنواعُ ميّتة.
الفحص: safeParse مقابل parse
tsconst result = createUserSchema.safeParse(req.body); // لا يرمي — يرجّع نتيجة if (!result.success) { const errors = z.flattenError(result.error); // { fieldErrors, formErrors } return res.status(400).json({ error: "Invalid user data", details: errors.fieldErrors }); } const data = result.data; // ← الآن موثوقٌ ومكتوبُ النوع
safeParse يرجّع { success, data } أو { success: false, error } بلا رميٍ (مشروعك يستعمله). parse يرمي استثناءً عند الفشل (مفيدٌ مع معالِج الأخطاء في Express 5 الذي يلتقط الرفض — الإقليم ٣). وflattenError يحوّل الأخطاءَ إلى fieldErrors لكل حقل — جاهزةً لواجهةٍ تعرضها بجانب كل خانة.
الميزةُ القاتلة: z.infer — توليدُ النوع من السكيمة (الوجه المقابل لـ Prisma)
tstype CreateUserInput = z.infer<typeof createUserSchema>; // = { username: string; email: string; password: string; role?: "USER" | "ADMIN" }
عرّفتَ التحقّق مرّةً (قيمةٌ وقت التشغيل)، فاشتُقّ النوعُ الساكن منه مجّاناً. هذا مصدرُ الحقيقة الواحد عند الحدّ: لا تكتب النوعَ والتحقّقَ منفصلين فيتباعدان. وهنا يكتمل التناظرُ الذي وعدتُ به في الإقليم ٥:
- Prisma: يولّد النوعَ من سكيمة القاعدة → يجسر الـ erasure عند حدّ القاعدة.
- Zod: يشتقّ النوعَ من سكيمة التحقّق → يجسر الـ erasure عند حدّ الشبكة.
الـ erasure محا الأنواعَ وقت التشغيل، فاستُعيدت من طرفين عبر شيءٍ يعيش وقت التشغيل: كودٌ مولَّدٌ من سكيمةٍ (Prisma)، أو سكيمةٌ هي نفسُها قيمةٌ (Zod). فهمُ هذا التناظر = فهمُ كيف يبقى ستاكٌ كاملٌ صادقَ-النوع رغم لغةٍ تمحو أنواعَها.
"حلّل، لا تتحقّق فقط" (parse, don't validate)
فرقٌ دقيقٌ مهم: لا تفحص req.body ثم تمرّر any كما هو. حوّله (parse) إلى قيمةٍ جديدةٍ مكتوبةِ النوع، واستعمل مخرجَ Zod لا الإدخالَ الخام. هكذا يصير كلُّ ما بعد البوّابة موثوقاً ومكتوبَ النوع، ويختفي any من بقيّة المسار. (وZod يقدر أيضاً أن يحوّل: z.coerce.number() يحوّل نصَّ الـ query إلى رقم؛ .trim() ينظّف؛ فالمخرجُ ليس فحصاً فقط بل تطبيعاً.)
الأمن: البوّابةُ خطُّ الدفاع الأول
التحقّقُ ليس راحةً فقط — هو أمنٌ: يرفض الأنواعَ الخاطئة، ويقصّ الحقولَ الزائدة (ضدّ mass assignment: role: "ADMIN" في تسجيلٍ يجب أن يُرفَض أو يُتجاهَل لا أن يُسنَد)، ويحدّ الأطوالَ (ضدّ حمولاتٍ مفجِّرة). إنه البوّابةُ التي تلي helmet/cors/rate-limit في سلسلتك (٣)، وتسبق الهويةَ والمنطق. (يربط بعقلية حدود الموارد ضد إساءة الاستعمال من Docker والـ rate-limit.)
اللغز / البناء من الصفر
اللغز أ — ابنِ مُحقِّقاً من الصفر (بلا Zod) لتشعر بالفكرة. اكتب makeValidator(shape) حيث shape كائنٌ يصف الحقولَ المتوقّعة (مثلاً { username: "string", age: "number" })، يرجّع دالةً (value: unknown) تُعيد { ok: true, data } أو { ok: false, errors }. القيود: امسك الحقلَ الناقص، والنوعَ الخاطئ، وقرّر سياستَك في الحقل الزائد (ارفض أم اقصّ؟). هذا مصغّرُ Zod بيدك — يجعلك ترى أن "السكيمة قيمةٌ تفحص" ليست سحراً.
اللغز ب — أعِد بناء عقد مشروعك بـ Zod. اكتب createUserSchema (الحقول الخمسة بقيودها)، استعمل safeParse على أجسامٍ مختلفة، وأنتِج مظروفَ الأخطاء { error, details: fieldErrors } تماماً كمتحكّم مشروعك. ثم اشتقّ type CreateUserInput = z.infer<typeof createUserSchema> ومرّر result.data لدالةٍ تتطلّب هذا النوع بالضبط — لاحظ أن المخرجَ موثوقٌ ومكتوبُ النوع، لا any.
اللغز ج — البوّابةُ الخصمة. أطلِق على سكيمتك حمولاتٍ خبيثة: بلا password؛ password رقماً؛ role: "SUPERADMIN"؛ حقلاً زائداً isAdmin: true؛ بريداً مشوّهاً؛ و"ليس JSON". لكلٍّ: هل رفضتها البوّابة؟ بأي رسالة؟ ثم اكتب بجملةٍ لكلٍّ: لماذا لم يكن المترجمُ ليمسكها؟ (الجواب دائماً: الـ erasure + أنها تصل وقت التشغيل.)
اللغز د — حوّلها middleware (تركيبٌ مع الإقليم ٣). على tiny-express حقّك، اكتب validate(schema) يرجّع middleware: يفحص req.body، فإن فشل يردّ 400 بمظروف fieldErrors، وإن نجح يستبدل req.body بالمخرج الموثوق ثم next(). ركّبه قبل المتحكّم: app.post("/register", validate(createUserSchema), createUser). الآن متحكّمُك يستقبل بياناتٍ موثوقةً مكتوبةَ النوع دائماً، وانتقل عبءُ الحراسة من المتحكّم إلى حلقةٍ معادةِ الاستخدام. هذا هو موضعُ التحقّق الصحيح في السلسلة.
لا تنتقل قبل أن تبني مُحقِّق اللغز (أ) وتحوّله middleware في (د). الأول يهدم سحرَ Zod، والثاني يضعه في مكانه الصحيح من سلسلتك.
الخلاصة — أين تتّصل هذه العقدة بالشجرة
- تحت: يختم خيطَ الـ erasure من الإقليم ١: الأنواع تُمحى، فلا تحرس ما يدخل وقت التشغيل. كلُّ حدٍّ يحتاج بوّابةَ تشغيلٍ. Zod سكيمةٌ-قيمةٌ تحوّل
unknown→ موثوقٍ، وتشتقّ النوعَ بـz.infer(الوجهُ المقابل لتوليد Prisma، ٥). - العقدة الجديدة: حارسُ الحدّ وقتَ التشغيل — الطبقةُ الثانيةُ من ثلاثٍ (مترجم / Zod / قاعدة)، كلٌّ يحرس زمناً ومجالاً مختلفاً. "حلّل لا تتحقّق فقط".
- فوق: Zod حلقةٌ في سلسلة Express (٣) قبل المتحكّم؛ ومبدؤه — "كلُّ حدٍّ يحتاج بوّابةً" — هو بالضبط ما يجعل الهويةَ (٧) حلقةً أخرى تحرس حدّاً آخر: من المُرسِل؟
الأبواب المفتوحة: البوّابةُ تثق بشكل البيانات. لكن GET /me لا يحتاج فحصَ شكلٍ — يحتاج معرفةَ من المُرسِل، وHTTP عديمُ الذاكرة (٠). كيف يُثبِت طلبٌ هويةَ صاحبه بلا أن يحفظ الخادمُ جلسة، وبلا أن يُزوَّر الإثبات؟ (٧).
الوصلة للواقع (نمط SelfLab)
user.schema.ts وuser.controller.ts يصيران واضحين تماماً:
ts// user.schema.ts — السكيمةُ قيمةٌ وقت التشغيل export const createUserSchema = z.object({ firstName: z.string().trim().min(2, "..."), email: z.email({ error: "..." }), password: z.string().min(8, "..."), role: z.enum(["USER", "ADMIN"]).optional(), }); // user.controller.ts — البوّابة const validation = createUserSchema.safeParse(req.body); if (!validation.success) { const errors = z.flattenError(validation.error); return res.status(400).json({ error: "Invalid user data", details: errors.fieldErrors }); } const { firstName, ... } = validation.data; // موثوقٌ من هنا فصاعداً
تقرؤه الآن كتجسيدٍ حرفيٍّ للدرس: safeParse البوّابة، flattenError رسائلُ الحقول، validation.data المخرجُ الموثوق، ورمزُ 400 (طلبُك خاطئ، الإقليم ٠). وتملك الآن تحسينَه: حوّله إلى validate(schema) middleware معادِ الاستخدام (لغز د) كي لا يتكرّر في كل متحكّم، وضعه في السلسلة بعد helmet/cors.
البذرة التالية: البوّابةُ تتحقّق من شكل البيانات. لكن سلسلةَ مشروعك تحتاج حارساً مختلفاً قبل GET /me وقبل التصحيح: من أنتَ؟ وHTTP نسيك بمجرّد أن أغلق الاتصال (٠). ننزل للإقليم ٧ نحلّ بذرةَ انعدام الحالة: نخزّن كلمةَ مرورٍ لا نستطيع استرجاعها (bcrypt)، ونبني إثباتَ هويةٍ لا يُزوَّر يحمله العميلُ في كل طلب — JWT بأيدينا من base64url و HMAC — فيبقى الخادمُ عديمَ الحالة ويعرفك مع ذلك.