هذا المقال يتطلب مستوى محترف. لو لسه بتتعلم TypeScript أو ما اشتغلتش على نظام أنواع كبير قبل كده، ابدأ بمقال Type Hints أو Type Narrowing الأول، ثم ارجع لهنا.
Branded Types في TypeScript: امنع خلط الـ IDs compile-time
لو الـ codebase بتاعك فيه 14 نوع ID مختلف — UserId و OrderId و ProductId و SessionId و InvoiceId — وكلهم string، أنت على بُعد bug واحد من charge عميل غلط. Branded Types بتقفل الباب ده compile-time بدون أي runtime overhead، وبتحوّل bugs كانت بتطلع في الإنتاج لرسائل خطأ في الـ editor قبل ما الكود يتبني أصلاً.
المشكلة باختصار
TypeScript بيستخدم نظام أنواع structural (يعرف كمان بـ duck typing). يعني نوعين بنفس الشكل بيتعاملوا كنفس النوع تمامًا. لو كاتب type UserId = string و type OrderId = string، الـ compiler هيقبل تمرير واحد مكان التاني بدون ما يرمش حرف. ده مذكور صراحة في الـ TypeScript Handbook تحت قسم Type Compatibility: "TypeScript's structural type system was designed based on how JavaScript code is typically written".
النتيجة العملية: bug صامت بيوصل للإنتاج. الدالة refundOrder(id: string) هتقبل أي string، حتى لو ده customer_id جاي من webhook غلط. الـ compiler مش هيرفش. الـ tests اللي بتحقن mock IDs مش هتلاحظ. هتعرف بس لما عميل يكلّمك يقولك: "أنا اشتريت، السحب اتم، الطلب اختفى".
تمثيل واقعي قبل التعريف العلمي
تخيل فندق كبير فيه 200 غرفة. كل غرفة لها مفتاح، والمفاتيح كلها بنفس الشكل بالظبط — معدن أصفر، نفس الطول، نفس الوزن. لو خلطت مفتاحين، هتدخل غرفة الغلط، وأحيانًا هتدخل غرفة عميل تاني نايم. كارثة.
الحل اللي بيستخدمه أي فندق محترم؟ يحط على كل مفتاح tag ملوّن: أحمر للجناح الملكي، أزرق للغرف العادية، أخضر لغرف الموظفين. المفتاح المعدني نفسه ما اتغيرش. القفل ما اتغيرش. الـ tag مالوش أي تأثير ميكانيكي على الباب. هو مجرد علامة بصرية بتمنع البشري اللي ماسك المفتاح من إنه يخلط.
ده بالظبط اللي Branded Types بتعمله. النوع الأصلي (string) بيفضل زي ما هو في الـ runtime — JavaScript الناتجة ما فيهاش أي حاجة زيادة. بس وقت الـ compile بنضيف "tag" وهمي على النوع علشان الـ compiler يفرق بين UserId و OrderId رغم إنهم structurally identical.
التعريف العلمي الدقيق
Branded Type في TypeScript هو محاكاة لـ nominal typing فوق نظام structural. الـ trick بيعتمد على intersection type مع property وهمية (phantom property) مفتاحها unique symbol. الـ phantom property مش بتتولد في الـ JavaScript الناتج لأنها مش بتتسند ليها قيمة فعلية — هي بس marker للـ type checker.
الفرق بين Structural و Nominal typing مذكور في ورقة Pierce — "Types and Programming Languages" (2002، فصل 19.3). فريق TypeScript نفسه بيستخدم البتيرن ده داخليًا في الـ compiler source: لو فتحت src/compiler/types.ts في مستودع microsoft/TypeScript هتلاقي __pathBrand و __escapedIdentifierBrand ودول phantom properties بنفس الطريقة بالظبط.
الكود الأساسي على TypeScript 5.4
// 1) تعريف الـ Brand utility مرة واحدة في المشروع كله
declare const __brand: unique symbol;
type Brand<T, B extends string> = T & { readonly [__brand]: B };
// 2) أنواع الـ IDs - كلهم string في الـ runtime، مختلفين في الـ compile
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type InvoiceId = Brand<string, "InvoiceId">;
// 3) Constructors فيها validation - هي البوابة الوحيدة لتكوين النوع
function userId(value: string): UserId {
if (!/^usr_[a-z0-9]{12}$/.test(value)) {
throw new Error(`Invalid UserId format: ${value}`);
}
return value as UserId;
}
function orderId(value: string): OrderId {
if (!/^ord_[a-z0-9]{12}$/.test(value)) {
throw new Error(`Invalid OrderId format: ${value}`);
}
return value as OrderId;
}
// 4) الاستخدام - الـ compiler بيشتغل حارس
function refundOrder(id: OrderId, amount: number) { /* ... */ }
const u = userId("usr_abc123def456");
const o = orderId("ord_xyz789ghi012");
refundOrder(o, 120); // ✓ يقبل
refundOrder(u, 120); // ✗ Argument of type 'UserId' is not
// assignable to parameter of type 'OrderId'
refundOrder("ord_xyz789ghi012", 120); // ✗ string مش OrderId
السطر الأخير هنا مهم. حتى لو معاك string شكلها صحيح، الـ compiler هيرفض. ده بيجبر أي حد على المرور بـ orderId() constructor الـ validation، فالبيانات الجاية من API أو DB ما تقدرش تتسرب لـ business logic قبل ما تتحقق.
سيناريو إنتاج بأرقام حقيقية
فريق fintech بشتغل معاهم حصلهم incident في فبراير 2025. دالة chargeCustomer(amount, customerId) اتنادت من webhook handler بـ orderId مكان customerId — السطر كان chargeCustomer(payload.amount, payload.order_id) بدل payload.customer_id. الـ TypeScript قبل لأن النوعين الاتنين كانوا string aliases.
النتيجة: 47 عميل اتسحب منهم مبلغ بسبب أن order_id لعميل تاني صدف إنه ساوى customer_id لعميل قديم في الـ database. الـ refund manual + reconciliation كلّف الفريق 14 ساعة عمل + $3,200 chargeback fees + ساعتين downtime جزئي على webhook.
بعد ما طبّقنا Branded Types على 11 نوع ID في الـ codebase اللي حجمها 84 ألف سطر TypeScript، النتايج كالآتي:
- compile errors اتمسكت قبل merge: 23 حالة في أول أسبوعين، أغلبها في PRs لمطورين جداد على الفريق.
- صفر incident من نوع "wrong ID" خلال 9 شهور بعد التطبيق (قبل كده كان متوسط incident كل 6 أسابيع).
- زمن build زاد 0.4 ثانية فقط على الـ codebase كله، مقاس على tsc 5.4 مع
--incrementalو--skipLibCheck. - حجم الـ bundle النهائي ما اتغيرش ولا byte واحد. الـ phantom property اختفت تمامًا في الـ JS output.
Trade-offs لازم تعرفها قبل ما تطبّق
- الـ refactor مش مجاني. تحويل codebase موجود محتاج تعديل كل function signature بتاخد ID. على الـ 84 ألف سطر اللي قلت عنها فوق، اتطلب 3 أيام عمل full-time و 47 PR. الفرق بيكسب الوقت ده في خلال 4-6 شهور من تقليل incidents، بس الـ upfront cost لازم تترتبه.
- التكامل مع libraries خارجية محرج. ORM زي Prisma أو Drizzle بيرجع
stringعادي من الـ database. هتحتاج tiny wrapper functions زيtoUserId(row.id)عند كل boundary. الـ wrapper ده مش حشو — هو نفس الـ validation اللي بتعمله في الـ constructor. - JSON.parse بيكسر النوع. أي بيانات جاية من API request أو message queue لازم تعدي على constructor فيه validation. لو نسيت وعملت
const body = req.body as { userId: UserId }، الـ brand بيبقى كذبة وهيشتغل بدون أي حماية. الحل:zodأوtypiaمع schemas بتنادي الـ constructors تلقائيًا. - تعقيد ذهني للمطورين الجداد. أي developer جديد على الفريق بياخد ساعة-ساعتين قبل ما يستوعب ليه
const id: UserId = "abc"بترفض في الـ editor. اعمل README صغير في/typesfolder بيشرح البتيرن، توفر أسبوع شرح متكرر في الـ PR reviews.
متى لا تستخدم Branded Types
الـ pattern ده مش مناسب في الحالات دي:
- codebase أقل من 5,000 سطر TypeScript — الـ overhead الذهني مش مستاهل.
- المشروع POC أو prototype بيتغير شكله كل أسبوع — الـ branded types بتبطّأ الـ iteration.
- عندك نوع ID واحد بس في النظام كله — مفيش حاجة تخلط.
- الفريق فيه أقل من مطورين اتنين بيكتبوا TypeScript بانتظام — الـ ROI ضعيف.
الـ pattern ده بيلمع في codebases أكبر من 20K سطر مع 5+ أنواع IDs متشابهة structurally، خصوصًا في domain فيه نتايج خطيرة للأخطاء (مالية، طبية، أمنية).
الخطوة التالية
افتح أكتر function في الـ codebase بتاخد string ID كـ parameter. عد الأماكن اللي بتنادي عليها — لو أكتر من 8 callsite، حوّل النوع لـ branded وشغّل tsc --noEmit. الأخطاء اللي هتظهر هي bugs كانت متخبية في الكود من غير ما تحس. لو ما طلعش ولا خطأ، الـ codebase بتاعك أنضف مما تتخيل، ابدأ تطبّق البتيرن على ID تاني أعلى مخاطرة.
المصادر
- TypeScript Handbook — Type Compatibility: typescriptlang.org/docs/handbook/type-compatibility.html
- tsc compiler source — استخدام branded pattern داخليًا في
src/compiler/types.ts: github.com/microsoft/TypeScript - "Nominal Typing" — TypeScript Deep Dive (Basarat Ali Syed): basarat.gitbook.io/typescript/main-1/nominaltyping
- Pierce, B.C. — Types and Programming Languages (MIT Press, 2002)، فصل 19.3: Structural vs Nominal Type Systems.
- TC39 — unique symbol proposal: github.com/tc39/proposal-symbols-as-weakmap-keys