Menu
flag Ar iconالعربيةdown icon

Branded Types في TypeScript: الأنواع الاسمية بالأمثلة

النوع الموسوم (branded type) نوع بدائي يحمل وسمًا غير مرئي، مثل string & { readonly __brand: "UserId" }، فلا يمكن تمرير UserId حيث يُتوقع OrderId. تعرّف على طريقة عمل الوسوم، ودوال الإنشاء التي تتحقق من المدخلات، ومساعد Brand عام، ووسوم unique symbol، والأرقام الموسومة.

تحتوي هذه الصفحة على محررات قابلة للتشغيل - حرّر، شغّل، وشاهد النتيجة فوراً.

تقارن TypeScript الأنواع حسب شكلها، لذا فإن اسمين بديلين لـ string قابلان للتبادل. يضيف النوع الموسوم وسمًا موجودًا في نظام الأنواع فقط، string & { readonly __brand: "UserId" }، فيصبح UserId غير متوافق مع النص العادي ومع كل وسم آخر:

وقت التشغيل، userId هو النص "u_42" فقط. الوسم تسمية وقت الترجمة، ووظيفته الوحيدة منع الخلط بين المعرّفات والوحدات والنصوص التي جرى التحقق منها.

المشكلة: الأسماء البديلة مجرد أسماء

الـ type alias لا ينشئ نوعًا جديدًا. إنه يعطي نوعًا موجودًا اسمًا ثانيًا، ويعامل المترجم الاسمين كشيء واحد:

هذا هو التنميط البنيوي (structural typing): تتحقق TypeScript من أن الشكل مناسب، وstring يناسب string. في الكائنات تختلف الأشكال عادةً؛ أما في المعرّفات وعناوين البريد والعملات والوحدات فلا تختلف أبدًا. والوسوم تعالج هذه الحالة تحديدًا.

كيف يعمل الوسم

string & { readonly __brand: "UserId" } هو intersection: يجب أن تكون القيمة نصًا وأن تملك أيضًا خاصية __brand من النوع "UserId". لا يوجد نص حقيقي يملك هذه الخاصية، لذا لا يمكن إسناد أي نص عادي إليه:

index.ts(10,10): error TS2345: Argument of type 'string' is not assignable to parameter of type 'UserId'.
  Type 'string' is not assignable to type '{ readonly __brand: "UserId"; }'.
index.ts(11,10): error TS2345: Argument of type 'OrderId' is not assignable to parameter of type 'UserId'.
  Type 'OrderId' is not assignable to type '{ readonly __brand: "UserId"; }'.
    Types of property '__brand' are incompatible.
      Type '"OrderId"' is not assignable to type '"UserId"'.

الاتجاه المهم ما زال يعمل: UserId نص، فيمكنك تمريره إلى أي شيء يأخذ نصًا، واستدعاء .startsWith() عليه، ووضعه داخل قالب نص. الوسم يمنع الدخول فقط.

دوال إنشاء تتحقق من المدخلات

تأكيد النوع as UserId هو الطريق الوحيد للدخول، وتأكيد النوع لا يفحص شيئًا. ضعه في دالة واحدة تتحقق من المُدخل، وعندها تعرف أن كل قيمة موسومة في البرنامج قد اجتازت هذا الفحص:

لا تفحص sendWelcome مدخلها مرة أخرى أبدًا، لأن نوع معاملها يقول إن الفحص قد جرى. هذه فكرة «حلّل ولا تكتفِ بالتحقق» (parse, don't validate): افحص عند الحدود، ثم احمل الإثبات في النوع. ويعمل حارس النوع كدالة إنشاء أيضًا إذا فضّلت قيمة منطقية على الاستثناء: function isEmail(s: string): s is Email.

مساعد Brand عام

كتابة الـ intersection يدويًا لكل نوع تصبح تكرارًا. نوع generic صغير يفعل ذلك مرة واحدة:

وسم الرقم يعمل بالطريقة نفسها كوسم النص. لاحظ أن amount / 100 هو number عادي: العمليات الحسابية على رقم موسوم تعطي نتيجة غير موسومة، كما يأتي لاحقًا.

وسوم unique symbol

اسم خاصية نصي مثل __brand يبدو كخاصية حقيقية: userId.__brand يجتاز فحص الأنواع على أنه "UserId" لكنه undefined وقت التشغيل، وقد تختار مكتبتان الاسم نفسه. مفتاح من نوع unique symbol يتجنب المشكلتين:

يعلن declare const brand: unique symbol رمزًا موجودًا لفاحص الأنواع فقط؛ والكلمة المفتاحية declare تعني أنه لا يُولَّد له أي JavaScript. ولأن الرمز غير مُصدَّر من وحدته، لا يستطيع الكود في الملفات الأخرى حتى أن يسمّي خاصية الوسم، فخارج تلك الوحدة لا سبيل للحصول على Meters إلا عبر الدوال التي تصدّرها أو تأكيد نوع as Meters.

الوسوم لا تكلف شيئًا وقت التشغيل

المخرجات المترجمة لا تحتوي على أي أثر للوسم. هذه هي الأسطر المولَّدة لمثال Meters، بعد ترويسة الوحدة التي يضيفها المترجم: اختفى declare والاسمان البديلان للنوعين وكل as، وما زال الاستدعاء المكتوم في السطر الأخير يعمل.

function toMeters(feet) {
    return (feet * 0.3048);
}
const height = 10;
const inMeters = toMeters(height);
console.log(inMeters.toFixed(3)); // 3.048
// @ts-expect-error: Meters is not Feet
toMeters(inMeters);

القيمة الموسومة هي القيمة البدائية العادية: يعطي typeof النتيجة "string" أو "number"، ويكتبها JSON.stringify كالمعتاد، والمقارنات تعمل كما كانت. والوجه الآخر أنه لا شيء يُفحص وقت التشغيل ما لم تفحصه دالة الإنشاء. البيانات المحلَّلة من JSON أو قاعدة بيانات أو URL تصل كـ string، ولا تصبح UserId إلا عندما تمررها عبر تلك الدالة.

العمليات الحسابية والدوال تُسقط الوسم

العمليات على قيمة موسومة تعيد النوع الأساسي، لأن الوسم ليس جزءًا مما ينتجه + أو .slice():

type Cents = number & { readonly __brand: "Cents" };

const a = 500 as Cents;
const b = 250 as Cents;

const sum = a + b;           // number, not Cents
const total: Cents = a + b;  // error TS2322: Type 'number' is not assignable to type 'Cents'
const fixed = (a + b) as Cents; // re-brand when the result is still valid

وهذا عادةً ما تريده: جمع مبلغين بالسنتات يعطي سنتات، لكن ضرب السنتات بالسنتات لا يعطي ذلك، وأنت وحدك تعرف أي العمليات تحافظ على المعنى. اكتب دوالًا مساعدة صغيرة مثل addCents(a: Cents, b: Cents): Cents للعمليات التي يحتاجها كودك.

متى تستخدم branded types

استخدم الوسوم حيث يكون الخلط بين قيمتين من النوع البدائي نفسه خطرًا حقيقيًا ولا يستطيع المترجم المساعدة بطريقة أخرى:

الحالةأمثلة على الوسوم
معرّفات من جداول مختلفةUserId، OrderId، ProductId
نصوص جرى التحقق منهاEmail، Url، NonEmptyString، Slug
الوحدات والعملاتMeters، Feet، Cents، Usd، Eur
نص منقّى أو مُهرَّبSafeHtml، SqlIdentifier
أرقام ضمن نطاقPercentage، PositiveInt

تجاوزها في القيم التي لا يُخلط بينها أبدًا، وفي أنواع الكائنات التي تختلف أشكالها أصلًا. تستطيع مكتبات التحقق إنتاج أنواع موسومة من مخطط: في Zod يعطي z.string().brand<"UserId">() مخططًا تعيد دالة parse فيه UserId موسومًا، وهذا يوفّر عليك كتابة دوال الإنشاء يدويًا.

الأسئلة الشائعة

ما هي branded types في TypeScript؟

نمط يجعل نوعين لهما التمثيل نفسه وقت التشغيل غير متوافقين. تأخذ intersection بين النوع الأساسي ووسم لا تملكه أي قيمة عادية: type UserId = string & { readonly __brand: "UserId" }. عندها يُرفض النص العادي، أو OrderId بوسم مختلف، حيث يُتوقع UserId.

هل في TypeScript أنواع اسمية (nominal types)؟

لا. نظام الأنواع في TypeScript بنيوي: نوعان لهما الشكل نفسه قابلان للتبادل مهما كانت أسماؤهما. تتصرف تعريفات الأصناف التي فيها أعضاء private أو #private تصرفًا اسميًا، والـ branded types هي الطريقة الشائعة للحصول على الأثر نفسه مع الأنواع البدائية مثل النصوص والأرقام.

هل للـ branded types تكلفة وقت التشغيل؟

لا. الوسم موجود في النوع فقط. تبقى القيمة نصًا أو رقمًا عاديًا وقت التشغيل دون أي خاصية إضافية، وJavaScript المترجَم هو نفسه كما لو لم يكن هناك وسم. الكود الوحيد وقت التشغيل هو أي تحقق تختار وضعه في الدالة التي تنشئ القيم الموسومة.

كيف أنشئ قيمة من نوع موسوم؟

بتأكيد نوع، ويُفضَّل أن يكون في دالة صغيرة واحدة تفحص المُدخل أولًا: function toEmail(s: string): Email { if (!s.includes("@")) throw new Error("bad email"); return s as Email; }. إبقاء الـ as في هذا المكان الوحيد يعني أن كل Email في البرنامج قد اجتاز الفحص.

ما الفرق بين type alias والنوع الموسوم؟

type UserId = string مجرد اسم جديد: أي نص يُقبل حيث يُتوقع UserId. أما type UserId = string & { readonly __brand: "UserId" } فنوع جديد غير متوافق: يجب أن يمر النص العادي أولًا عبر دالة إنشاء أو تأكيد نوع.

Coddy programming languages illustration

تعلّم البرمجة مع Coddy

ابدأ الآن