EduFlow
أنماط TypeScript المتقدّمة
⁦0%⁩

الوحدة 1 · الأنواع أداةً للتصميم

الـ Literal Types و as const و satisfies

18 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

امنع القيم الحرفية من الاتساع إلى string، واشتقّ أنواع union من مصفوفات وقت التشغيل باستخدام as const، وتحقّق من كائنات الإعداد بـ satisfies دون أن تفقد أنواعها الدقيقة.

  1. 1لماذا تتّسع القيم الحرفية
  2. 2as const: جمّد القيم الحرفية
  3. 3التعليق النوعي مقابل satisfies
  4. 4الاختيار بينها

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

إليك بلاغ خطأ قدّمه كل فريق TypeScript مرة على الأقل: “أمرّر النص الصحيح بالضبط ويقول لي Type 'string' is not assignable to type 'OrderStatus'.”

TypeScript
type OrderStatus = 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'returned';
type Order = { id: number; status: OrderStatus };
function save(order: Order) {}

const draft = { id: 1001, status: 'processing' };
save(draft);
// Error: Argument of type '{ id: number; status: string; }' is not assignable
// to parameter of type 'Order'.

النص هو 'processing' فعلًا. لكن TypeScript لم يستنتج له 'processing'، بل استنتج string. هذا هو الاتساع (widening)، وفهمه يشرح ثلاث أدوات ستستخدمها كل أسبوع: الـ literal types (الأنواع الحرفية)، وas const، وsatisfies.

لماذا تتّسع القيم الحرفية

يستنتج TypeScript النوع الذي يُرجَّح أن تحمله القيمة طوال حياتها، لا القيمة التي تبدأ بها فقط. الـ const لا يمكن أن يتغيّر أبدًا، فيحتفظ بالقيمة الحرفية. أما let أو خاصية الكائن فيمكن إعادة الإسناد إليهما، فيوسّعهما TypeScript:

TypeScript
const a = 'processing';           // 'processing'
let b = 'processing';             // string
const c = { status: 'processing' }; // { status: string }

الأخيرة هي المفاجأة. const c تمنعك من إعادة الإسناد إلى c، لكن c.status = 'anything' ما زالت JavaScript مشروعة، فتتّسع الخاصية إلى string.

هناك ثلاثة إصلاحات جيدة، مرتّبة حسب الأفضلية. اكتب القيمة الحرفية حيث يكون النوع معروفًا أصلًا (save({ id: 1001, status: 'processing' }))، فتُفحص مقابل Order مباشرة. أو علّق المتغيّر بنوعه (const draft: Order = …) حين تبنيه مسبقًا. أو استخدم as const، وهي موضوعنا التالي.

as const: جمّد القيم الحرفية

as const تطلب من TypeScript أن يستنتج أضيق نوع ممكن: كل قيمة حرفية تبقى حرفية، وكل مصفوفة تصبح tuple للقراءة فقط، وكل خاصية تصبح readonly:

TypeScript
const COUNTRIES = ['EG', 'AE', 'SA', 'JO', 'GB', 'DE', 'US', 'CA'] as const;
// readonly ['EG', 'AE', 'SA', 'JO', 'GB', 'DE', 'US', 'CA']

type Country = (typeof COUNTRIES)[number];
// 'EG' | 'AE' | 'SA' | 'JO' | 'GB' | 'DE' | 'US' | 'CA'

ذلك السطر الثاني من أنفع الأنماط في TypeScript اليومية. typeof COUNTRIES تحوّل القيمة إلى نوعها، و[number] تسأل “ما النوع الذي أحصل عليه حين أفهرس هذا بأي رقم؟” (ستتعرّف على هذين العاملين (operators) كما ينبغي في keyof و typeof وأنواع الوصول بالفهرس). والنتيجة union مشتقّ من مصفوفة وقت التشغيل. تحصل على مصدر حقيقة واحد: المصفوفة موجودة وقت التشغيل للقوائم المنسدلة والتحقّق، والنوع يتبعها تلقائيًا.

TypeScript
function isCountry(value: string): value is Country {
  return (COUNTRIES as readonly string[]).includes(value);
}

التحويل الموسِّع داخل isCountry ضروري لأن includes على tuple من القيم الحرفية لا تقبل إلا تلك القيم، وهذا يُفسد الغاية من فحص نص اعتباطي.

التعليق النوعي مقابل satisfies

كوبونات Cartwheel موجودة في كائن إعداد. الطريقة البديهية لتحديد نوعه هي التعليق النوعي:

TypeScript
type Coupon = { kind: 'percent'; percentOff: number } | { kind: 'free-shipping' };

const coupons: Record<string, Coupon> = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  FREESHIP: { kind: 'free-shipping' },
};

coupons.WELCOME10.percentOff; // Error: Property 'percentOff' does not exist on type 'Coupon'.
coupons.SPRNG15;              // compiles, undefined at runtime

التعليق النوعي يستبدل ما كان TypeScript يعرفه. كتبت WELCOME10 ككوبون نسبة مئوية، لكن نوعه صار مجرّد Coupon، والكائن يقبل أي مفتاح على الإطلاق، بما فيها الأخطاء الإملائية.

أما satisfies فتتحقّق من القيمة مقابل نوع دون أن تستبدل النوع المستنتَج:

TypeScript
const coupons = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  FREESHIP: { kind: 'free-shipping' },
} satisfies Record<string, Coupon>;

coupons.WELCOME10.percentOff; // number
coupons.SPRNG15;              // Error: Property 'SPRNG15' does not exist on type …

ما زلت تحصل على التحقّق: percentof المكتوبة خطأً أو kind: 'bogo' تُعلَّم مباشرة في الكائن الحرفي. وتحتفظ بالمفاتيح والأشكال الدقيقة. كما أن satisfies تحدّد نوع القيم الحرفية من السياق، فيبقى kind بقيمة 'percent' بدل أن يتّسع إلى string، دون الحاجة إلى as const.

استخدم التركيبة as const satisfies T حين تريد الأمرين: قيمًا حرفية للقراءة فقط بعمق، وفحصًا مقابل شكل. تُقرأ من اليسار إلى اليمين هكذا: “جمّد هذا، ثم تأكّد أنه مناسب”.

الاختيار بينها

قاعدة افتراضية بسيطة: علّق معاملات الدوال وأنواع الإرجاع والمتغيّرات التي يجب أن يكون نوعها هو النوع العام (let current: Order). استخدم satisfies لكائنات الإعداد وجداول البحث وكل ما تهمّ فيه المفاتيح والقيم المحدّدة لاحقًا. واستخدم as const للقوائم الثابتة التي تريدها union. الثلاثة كلها وقت الترجمة فقط؛ لا يغيّر أيٌّ منها بايتًا واحدًا من JavaScript الناتجة.

في التمرين ستشتقّ Country من قائمة دول Cartwheel، وتحدّد نوع جدول العملات بحيث يرفض الدول المجهولة ويتذكّر العملات بدقة. بهذا تكتمل أدوات التصميم في هذا القسم؛ وبعده تنتقل إلى الـ generics، بدءًا بالعلاقات التي تعبّر عنها.

أهم النقاط

  • متغيّرات let وخصائص الكائنات توسّع القيم الحرفية إلى string أو number؛ أما متغيّرات const فتحتفظ بالقيمة الحرفية.
  • as const تجعل القيمة للقراءة فقط بعمق وتحتفظ بكل قيمة حرفية، ما يتيح لك اشتقاق نوع union من مصفوفة وقت التشغيل.
  • التعليق النوعي (annotation) يستبدل النوع المستنتَج بالنوع المكتوب؛ أما satisfies فتتحقّق مقابل نوع وتحتفظ بالنوع المستنتَج.
  • تأكيدات as تتخطّى فحوصًا مثل الخصائص الناقصة، لذا استخدم satisfies حين تريد تحقّقًا لا ثقة عمياء.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

لماذا تفشل save(draft) حين يكون Order['status'] هو 'processing' | 'shipped'؟

TypeScript
const draft = { id: 1001, status: 'processing' };
save(draft);
السؤال 2 من 4

ما هو Country هنا؟

TypeScript
const COUNTRIES = ['EG', 'AE', 'GB'] as const;
type Country = (typeof COUNTRIES)[number];
السؤال 3 من 4

عُرّف coupons مع satisfies Record<string, Coupon>. أيّ سطر يُعدّ خطأً؟

TypeScript
type Coupon = { kind: 'percent'; percentOff: number } | { kind: 'free-shipping' };
const coupons = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  FREESHIP: { kind: 'free-shipping' },
} satisfies Record<string, Coupon>;
السؤال 4 من 4

يكتب زميل const config = { retries: 3 } as CheckoutConfig;، وCheckoutConfig يتطلّب أيضًا timeoutMs. ماذا يحدث؟

تدريب

بيئة تجريب الكود

اشتقّ الدول، وافحص الجداول

اجعل جداول البحث في Cartwheel دقيقة دون أن تكرّر نفسك:

  1. اشتقّ Country من المصفوفة COUNTRIES، بحيث يكون الـ union 'EG' | 'AE' | … | 'CA'.
  2. حدّد نوع currencyByCountry بحيث يجب أن يحتوي مدخلًا واحدًا لكل Country، وبحيث يكون نوع currencyByCountry.DE هو 'EUR' بالضبط.
  3. حدّد نوع coupons بحيث تُفحص كل قيمة مقابل Coupon، لكن لا يحتوي الكائن إلا المفاتيح الثلاثة التي كتبتها، ويُعرف أن SPRING15 كوبون نسبة مئوية.

أبقِ isCountry تعمل وقت التشغيل مع أي نص.

type Currency = 'EGP' | 'AED' | 'SAR' | 'JOD' | 'GBP' | 'EUR' | 'USD' | 'CAD';
type Coupon = { kind: 'percent'; percentOff: number } | { kind: 'free-shipping' };

const COUNTRIES = ['EG', 'AE', 'SA', 'JO', 'GB', 'DE', 'US', 'CA'];
type Country = string; // derive this from COUNTRIES

const currencyByCountry: Record<string, Currency> = {
  EG: 'EGP',
  AE: 'AED',
  SA: 'SAR',
  JO: 'JOD',
  GB: 'GBP',
  DE: 'EUR',
  US: 'USD',
  CA: 'CAD',
};

const coupons: Record<string, Coupon> = {
  WELCOME10: { kind: 'percent', percentOff: 10 },
  SPRING15: { kind: 'percent', percentOff: 15 },
  FREESHIP: { kind: 'free-shipping' },
};

function isCountry(value: string): value is Country {
  return COUNTRIES.includes(value);
}

function currencyFor(country: Country): Currency {
  return currencyByCountry[country];
}

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<Country, 'EG' | 'AE' | 'SA' | 'JO' | 'GB' | 'DE' | 'US' | 'CA'>>;
type T2 = Expect<Equal<(typeof currencyByCountry)['DE'], 'EUR'>>;
type T3 = Expect<Equal<keyof typeof coupons, 'WELCOME10' | 'SPRING15' | 'FREESHIP'>>;
type T4 = Expect<Equal<(typeof coupons)['SPRING15']['percentOff'], number>>;

// @ts-expect-error: France is not a Cartwheel country
currencyByCountry.FR;

// @ts-expect-error: typo in the coupon code
coupons.SPRNG15;

console.log(currencyFor('DE'), isCountry('EG'), isCountry('FR'));
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • isCountry تقبل دول Cartwheel وترفض غيرها وقت التشغيل
  • currencyFor تُعيد العملة الصحيحة