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

الوحدة 5 · إيصال TypeScript إلى الإنتاج

ملفات التصريحات وتوسيع الوحدات

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

الدرس في لمحة

صِف كود JavaScript لم تكتبه بملفات التصريحات، وحدّد أنواع المتغيّرات العامة ومتغيّرات البيئة، ووسّع الـ interfaces والوحدات الموجودة بدمج التصريحات وتوسيع الوحدات.

  1. 1declare: إنه موجود، ثق بي
  2. 2تحديد أنواع حزمة JavaScript
  3. 3دمج التصريحات
  4. 4توسيع الوحدات

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

ليس كل ما يلمسه كود TypeScript لديك مكتوبًا بـ TypeScript. نظام الدفع في Cartwheel يحمّل مقتطف تحليلات بوسم <script>، ويستخدم حاسبة شحن داخلية قديمة مكتوبة بـ JavaScript، ويقرأ متغيّرات البيئة عبر Vite، ويعمل بـ lib متأخّرة سنة عن المتصفّحات التي يعمل عليها. في كلٍّ من هذه الحالات، تحتاج إلى إخبار الـ compiler عن كود لا يستطيع رؤيته. وهذا ما وُجدت له ملفات التصريحات وdeclare.

declare: إنه موجود، ثق بي

declare تصف شيئًا موجودًا وقت التشغيل لكنه معرَّف في مكان آخر. لا تنتج أي JavaScript. والملف المنتهي بـ .d.ts لا يحتوي إلا مثل هذه التصريحات:

types/analytics.d.ts
type AnalyticsEvent = 'checkout_started' | 'order_placed' | 'payment_failed';

declare const cartwheelAnalytics: {
  track(event: AnalyticsEvent, props?: Record<string, string | number>): void;
};

هذا الملف ليس فيه import ولا export، فهو script عام: كل ما فيه مرئي في كل ملف من المشروع. والآن صارت cartwheelAnalytics.track('order_plaecd') خطأ ترجمة، وهذا ما لم تنجح فيه وثائق المقتطف أبدًا.

التصريح وعدٌ غير مفحوص، تمامًا مثل تحويلات as في الأقسام السابقة. إن اختلف الـ API الحقيقي للمقتطف، سيثق TypeScript بنسختك بسرور. أبقِ التصريحات صغيرة، ولا تكتب إلا الأجزاء التي تستخدمها، وضع تعليقًا بإصدار المكتبة الذي تحقّقت منها مقابله.

تحديد أنواع حزمة JavaScript

قبل كتابة أي تصريحات لحزمة، تحقّق من المكانين اللذين تأتي منهما الأنواع عادة. حزم كثيرة تأتي بأنواعها الخاصة ("types" في package.json الخاص بها، أو ملفات .d.ts بجوار JavaScript). ولغيرها، يصون المجتمع حزم @types/ على DefinitelyTyped: npm install -D @types/legacy-shipping-calc إن وُجدت.

ولحزمة داخلية ليس لها أيٌّ منهما، أعلن الوحدة بنفسك:

types/legacy-shipping-calc.d.ts
declare module 'legacy-shipping-calc' {
  export type Quote = { carrier: string; feeCents: number; days: number };
  export function quote(country: string, weightGrams: number): Quote[];
}

الصيغة المختصرة declare module 'legacy-shipping-calc'; دون جسم تعمل أيضًا، وتجعل كل import من نوع any. اعتبرها مخرج طوارئ مؤقتًا أثناء الترحيل، لا حلًا.

أبقِ التصريحات المكتوبة يدويًا معًا في مجلد واحد، مثل types/، وتأكّد أن include في tsconfig يشمله؛ فملف .d.ts لا يراه الـ compiler أبدًا لا يفعل شيئًا، ورسالة الخطأ الناتجة “Could not find a declaration file for module” مربكة في المرة الأولى. ولاحظ أيضًا أن هذا الملف عام، مثل ملف التحليلات: كتلة declare module 'name' باسم نصي هي طريقتك لوصف حزمة من ملف script.

دمج التصريحات

بعض التصريحات التي لها الاسم نفسه تتّحد بدل أن تتصادم. أكثر ما ستستخدمه: الـ interfaces تندمج.

TypeScript
interface CheckoutConfig { currency: string }
interface CheckoutConfig { giftWrap: boolean }

const config: CheckoutConfig = { currency: 'EGP', giftWrap: true }; // both required

الأسماء المستعارة للأنواع لا تندمج أبدًا؛ تصريحان type CheckoutConfig يعطيان خطأ “Duplicate identifier”. هذا هو الفرق العملي الرئيسي بين type وinterface، ولهذا تكشف المكتبات التي تتوقّع أن تُوسَّع عن interfaces.

والدمج هو طريقتك لسدّ الفجوات في المكتبة القياسية. بيئة التجربة في هذه الدورة تستخدم الـ lib الخاصة بـ ES2022، فـ Array.prototype.findLast (من ES2023) غائبة عن أنواعها رغم أن كل متصفّح حالي يملكها. والدمج في الـ interface العام Array<T> يضيفها:

TypeScript
interface Array<T> {
  findLast(predicate: (value: T, index: number, array: T[]) => unknown): T | undefined;
}

في مشروع حقيقي، الإصلاح الأفضل هو الإعداد الصحيح لـ lib أو target. أما الدمج فللحالات التي لا تستطيع فيها الإعدادات التعبير عمّا تملكه بيئة التشغيل فعلًا، مثل polyfill تضمّنه بنفسك.

وهو أيضًا الطريقة التي يحدّد بها Vite أنواع متغيّرات البيئة لديك. أنواع العميل فيه تعلن interface عامًا باسم ImportMetaEnv، ويندمج مشروعك فيه:

src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_URL: string;
  readonly VITE_PAYMENTS_KEY: string;
}

توسيع الوحدات

للإضافة إلى نوع يعيش داخل وحدة، افتح الوحدة بـ declare module من ملف هو نفسه وحدة:

src/theme.d.ts
import 'cartwheel-ui';

declare module 'cartwheel-ui' {
  interface Theme {
    brand: { primary: string; onPrimary: string };
  }
}

صار للـ interface المسمّى Theme داخل cartwheel-ui حقل brand في كل مكان يُستخدم فيه. وللإضافة إلى النطاق العام من ملف وحدة، استخدم declare global { interface Window { dataLayer: unknown[] } }.

في التمرين ستعلن متغيّر التحليلات العام، وتضيف findLast إلى الـ lib في بيئة التجربة، وتوسّع interface إعداد بالدمج. الدرس الأخير: نقل قاعدة كود JavaScript كاملة.

أهم النقاط

  • ملف .d.ts لا يحتوي إلا أنواعًا؛ وdeclare تصف شيئًا موجودًا وقت التشغيل لكنه معرَّف في مكان آخر.
  • تحقّق من وجود أنواع مدمجة أو حزمة @types/ قبل كتابة التصريحات، ولا تكتب إلا أجزاء المكتبة التي تستخدمها.
  • الـ interfaces التي لها الاسم نفسه في النطاق نفسه تندمج في واحد؛ أما الأسماء المستعارة للأنواع فلا تندمج أبدًا، ولهذا تكون أنواع المكتبات القابلة للتوسيع interfaces.
  • declare module 'pkg' { … } في ملف فيه import أو export توسّع تلك الوحدة؛ وفي ملف ليس فيه أيٌّ منهما تعلن وحدة محيطة جديدة كليًا.
  • declare global { … } داخل وحدة تضيف إلى النطاق العام، مثل خاصية على Window أو حقل في ImportMetaEnv.

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

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

تضيف import { quote } from 'legacy-shipping-calc'، وهي حزمة JavaScript بلا أنواع. ماذا يجب أن تجرّب أولًا؟

السؤال 2 من 4

ما هو keyof CheckoutConfig بعد هذين التصريحين في النطاق نفسه؟

TypeScript
interface CheckoutConfig { currency: string }
interface CheckoutConfig { giftWrap: boolean }
السؤال 3 من 4

ملف vite-env.d.ts لا يحتوي إلا interface ImportMetaEnv { readonly VITE_API_URL: string } (مع توجيه المرجع الخاص بـ Vite). ماذا يفعل؟

السؤال 4 من 4

الملف types/ui.d.ts لا يحتوي إلا declare module 'cartwheel-ui' { interface Theme { brand: string } }. بعدها، صار كل import من cartwheel-ui يفتقد بقية ما تصدّره الحزمة. لماذا؟

تدريب

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

أعلن ما لا يستطيع الـ compiler رؤيته

كود التطبيق أدناه يعمل وقت التشغيل أصلًا، لكنه لا يجتاز فحص الأنواع. دون تعديل كود التطبيق أو الاختبارات، أضف تصريحات في الموضع المحدّد:

  1. أعلن المتغيّر العام cartwheelAnalytics من مقتطف المزوّد: method باسم track(event, props?) حيث event من نوع AnalyticsEvent وprops تربط نصوصًا بنصوص أو أرقام، إضافة إلى قائمة sent للقراءة فقط من النصوص.
  2. أضف findLast إلى المصفوفات (موجودة في المتصفّح لكن ليس في الـ lib الخاصة بـ ES2022 في بيئة التجربة)، تُعيد نوع العنصر أو undefined.
  3. وسّع CheckoutConfig بحقل إلزامي giftWrap: boolean، دون تعديل الـ interface الأصلي.
// ---- Vendor snippet (do not edit) ----
// In the real app this arrives in a <script> tag; here we simulate it.
const vendorQueue: string[] = [];
(globalThis as Record<string, unknown>).cartwheelAnalytics = {
  sent: vendorQueue,
  track(event: string, props: Record<string, string | number> = {}) {
    vendorQueue.push(`${event} ${JSON.stringify(props)}`);
  },
};
// ---------------------------------------

type AnalyticsEvent = 'checkout_started' | 'order_placed' | 'payment_failed';

// "Library" config interface (pretend it lives in a package you cannot edit).
interface CheckoutConfig {
  currency: string;
}

// Your declarations go here.

type Order = { id: number; status: 'processing' | 'shipped' | 'delivered'; totalCents: number };

const orders: Order[] = [
  { id: 1041, status: 'shipped', totalCents: 4500 },
  { id: 1042, status: 'shipped', totalCents: 18900 },
  { id: 1043, status: 'processing', totalCents: 1200 },
];

function latestShipped(list: Order[]): Order | undefined {
  return list.findLast((o) => o.status === 'shipped');
}

function placeOrder(order: Order): void {
  cartwheelAnalytics.track('order_placed', { orderId: order.id, totalCents: order.totalCents });
}

const config: CheckoutConfig = { currency: 'EGP', giftWrap: true };

// ---- Type tests (do not edit) ----
type T1 = Expect<Equal<keyof CheckoutConfig, 'currency' | 'giftWrap'>>;
type T2 = Expect<Equal<ReturnType<typeof latestShipped>, Order | undefined>>;

function typeTests() {
  // @ts-expect-error: not a known analytics event
  cartwheelAnalytics.track('ordr_placed');
  // @ts-expect-error: property values must be strings or numbers
  cartwheelAnalytics.track('checkout_started', { express: true });
}

placeOrder(latestShipped(orders)!);
console.log(latestShipped(orders)?.id, cartwheelAnalytics.sent, config);
جارٍ تحميل المحرّر…

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

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها اختبارات الأنواع
  • latestShipped تُعيد آخر طلب مشحون
  • placeOrder ترسل حدث order_placed