فهم أنواع TypeScript: نموذج ذهني متكامل لتطوير أكواد أكثر قوة ووثوقية

دقائق القراءة: 40

في أحد الأيام، صادفت هذه التغريدة من لاري مازا:

تغريدة لاري مازا حول أهمية فهم الأنواع في البرمجة

بصفتي مهندس برمجيات تعلمت لغات مثل Python و Ruby و JavaScript و Clojure أولاً، كانت تجربتي الأولى مع C++ أشبه بفيلم رعب. لم أتمكن من إنجاز الكثير، وكانت العملية غير منتجة ومحبطة للغاية. ربما كان السبب أنني كنت أفعل كل شيء بشكل خاطئ ولم أفهم الأنواع (types) بالطريقة الصحيحة. ولكن على الرغم من كل تلك المشاكل، تمكنت من تنفيذ مجموعة من الخوارزميات وهياكل البيانات.

الآن بعد أن أصبحت أستخدم TypeScript بشكل متزايد في عملي اليومي وفي مشاريعي الجانبية، أشعر أنني أكثر استعدادًا للتعامل مع الأنواع. في الواقع، ليس التعامل معها، بل استخدامها لصالحي. هذا المقال هو محاولتي لمساعدة المطورين على التفكير بشكل أكبر في الأنواع وفهم هذا النموذج الذهني.

التفكير في أنواع JavaScript

إذا كنت هنا، فمن المحتمل أنك سمعت أن TypeScript هي مجموعة فائقة (superset) من JavaScript. إذا لم تكن كذلك، فهذا رائع، لقد تعلمت شيئًا جديدًا اليوم! TypeScript هي مجموعة فائقة لأن أي كود JavaScript يعتبر صالحًا في TypeScript من الناحية النحوية (syntactically). قد يتم تجميعه أو لا يتم تجميعه (compile) اعتمادًا على إعدادات مجمّع TypeScript، ولكن من حيث البنية، فإنه يعمل بشكل جيد.

لهذا السبب يمكنك ترحيل مشاريع JavaScript إلى TypeScript تدريجيًا بمجرد استبدال الامتداد .js بالامتداد .ts. كل شيء سيكون بدون تعريفات للأنواع (باستخدام النوع any)، ولكن هذه قصة أخرى.

أيضًا، إذا كنت تكتب الكود بلغة JavaScript – أو أي لغة برمجة أخرى – فمن المحتمل أنك تفكر في الأنواع بشكل طبيعي:

  • “همم، هذه قائمة من الأعداد الصحيحة، لذا سأحتاج إلى تصفية الأعداد الزوجية فقط وإرجاع قائمة جديدة.”
  • “هذا كائن (object)، لكنني أحتاج فقط للحصول على قيمة نصية (string) من الخاصية X.”
  • “هذه الدالة تستقبل معاملين. كلاهما A و B أعداد صحيحة وأريد جمعهما.”

نعم، لقد فهمت الفكرة. نحن نفكر في الأنواع، ولكنها مجرد أفكار في رؤوسنا. نحن نفكر فيها باستمرار لأننا نحتاج إلى معرفة كيفية التعامل مع البيانات، تحليلها، أو تعديلها. نحتاج إلى معرفة أي الطرق (methods) المسموح لنا باستخدامها في نوع الكائن هذا.

لتقديم مثال أكثر واقعية، تخيل أنك تريد جمع أسعار جميع المنتجات. كائن المنتج يبدو كالتالي:

const product = {
  title: 'Some product',
  price: 100.00,
};

ولكن الآن مع قائمة من المنتجات:

const products = [
  {
    title: 'Product 1',
    price: 100.00,
  },
  {
    title: 'Product 2',
    price: 25.00,
  },
  {
    title: 'Product 3',
    price: 300.00,
  }
];

حسنًا! الآن نريد دالة لجمع أسعار جميع المنتجات:

function sumAllPrices(products) {
  return products.reduce((sum, product) => sum + product.price, 0);
};

sumAllPrices(products); // 425

فقط استقبل المنتجات كوسيط (argument) واجمع أسعار جميع المنتجات باستخدام reduce(). يعمل JavaScript بشكل جيد. ولكن أثناء بناء هذه الدالة، تبدأ في التفكير في البيانات وكيفية التعامل معها بشكل صحيح.

الجزء الأول: المنتجات كوسيط. هنا تفكر فقط: “حسنًا، نحن نستقبل قائمة من بعض الكائنات.” نعم، في رؤوسنا المنتجات هي قائمة. لهذا السبب يمكننا التفكير في استخدام طريقة reduce(). إنها طريقة من النموذج الأولي للمصفوفة (Array prototype).

ثم يمكننا التفكير في الكائن بالتفصيل. نعلم أن كائن المنتج يحتوي على خاصية price. وهذه الخاصية هي رقم (number). لهذا السبب يمكننا استخدام product.price وجمعه مع المجمّع (accumulator).

تلخيصًا:

  • products هي قائمة من الكائنات.
  • بصفتها قائمة، يمكننا استخدام طريقة reduce()، لأن هذه الطريقة هي عضو في النموذج الأولي للمصفوفة (Array prototype).
  • كائن product يحتوي على بعض الخصائص. إحداها هي price، وهي رقم.
  • بصفتها خاصية رقمية، يمكننا استخدامها للجمع مع مجمّع reduce().
  • أردنا إرجاع رقم، وهو مجموع أسعار جميع المنتجات.

نحن نفكر دائمًا في أنواع البيانات، نحتاج فقط إلى إضافة تعليقات الأنواع (type annotations) لجعلها أكثر وضوحًا وطلب المساعدة من المجمّع. ذاكرتنا محدودة والمجمّعات هنا لمساعدتنا نحن البشر. لن يجعل نظام الأنواع بياناتنا أكثر اتساقًا فحسب، بل يمكنه أيضًا توفير إكمال تلقائي (autocompletion) لأنواع البيانات. إنه يعرف الأنواع، لذا يمكنه عرض الأعضاء للبيانات. سنلقي نظرة على هذه الفكرة لاحقًا. هنا أردت فقط أن أوضح أننا نفكر في الأنواع في رؤوسنا.

الأنواع البسيطة والاستخدامات الأساسية

إذًا، نحن مستعدون لاستخدام بعض لغات البرمجة ذات الأنواع القوية (strongly typed programming languages) مثل TypeScript. نحتاج ببساطة إلى إضافة تعليقات الأنواع بشكل صريح إلى هياكل بياناتنا. إنه أمر بسيط، أليس كذلك؟

ولكن في بعض الأحيان ليس بهذه السهولة (عادةً ما يكون الأمر صعبًا عندما تأتي من لغات ذات أنواع ديناميكية (dynamically typed languages). تشعر بعدم الإنتاجية. يبدو الأمر وكأنه معركة ضد الأنواع). الفكرة هنا هي جعل منحنى التعلم هذا أكثر سلاسة ومتعة.

هنا سنرى العديد من الأمثلة حول كيفية استخدام الأنواع في TypeScript. سنبدأ بأمثلة سهلة وبسيطة ونجعلها أكثر تعقيدًا تدريجيًا أثناء تصميم النموذج الذهني للتفكير في الأنواع.

كما هو الحال في JavaScript، يحتوي TypeScript أيضًا على أنواع بيانات أساسية (basic data types) مثل number و string و boolean و null وما إلى ذلك. يمكنك العثور على جميع أنواع البيانات الأساسية في وثائق TypeScript. باستخدام وحدات البيانات هذه، يمكننا جعل برامجنا أكثر فائدة.

لنكن أكثر عملية، دعنا نأخذ مثالًا بسيطًا. دالة sum(). كيف تعمل في JavaScript؟

function sum(a, b) {
  return a + b;
}

كل شيء على ما يرام؟ جيد. الآن دعنا نستخدمها:

sum(1, 2); // 3
sum(2, 2); // 4
sum(0, 'string'); // '0string'

ما هذا! المكالمتان الأوليان هما ما نتوقع حدوثه في نظامنا. لكن JavaScript مرنة للغاية، فهي تتيح لنا توفير أي قيمة لهذه الدالة. المكالمة الأخيرة غريبة. يمكننا استدعاء الدالة بسلسلة نصية (string)، لكنها ستعيد نتيجة غير متوقعة. لا يحدث خطأ أثناء التطوير، لكنه سيؤدي إلى سلوك غريب في وقت التشغيل (runtime).

ماذا نريد؟ نريد إضافة بعض القيود (constraints) إلى الدالة. ستكون قادرة على استقبال الأرقام فقط. بهذه الطريقة، نقلل من احتمالية حدوث سلوكيات غير متوقعة. ونوع الإرجاع للدالة هو أيضًا رقم.

function sum(a: number, b: number): number {
  return a + b;
}

رائع! كان الأمر بسيطًا جدًا. دعنا نستدعيها مرة أخرى:

sum(1, 2); // 3
sum(2, 2); // 4
sum(0, 'string'); // Argument of type '"string"' is not assignable to parameter of type 'number'.

عندما نضيف تعليق النوع لدالتنا، فإننا نوفر معلومات للمجمّع ليرى ما إذا كان كل شيء صحيحًا. سيتبع القيود التي أضفناها إلى الدالة. لذا فإن المكالمتين الأوليين هما نفس المكالمات في JavaScript. سيعيدان الحساب الصحيح. ولكن في المكالمة الأخيرة، لدينا خطأ في وقت التجميع (compile time). هذا مهم. يحدث الخطأ الآن في وقت التجميع ويمنعنا من إرسال كود غير صحيح إلى الإنتاج (production). يقول إن النوع string ليس جزءًا من مجموعة القيم في عالم النوع number.

بالنسبة للأنواع الأساسية، نحتاج فقط إلى إضافة نقطتين (:) متبوعتين بتعريف النوع:

const isTypescript: boolean = true;
const age: number = 24;
const username: string = 'tk';

الآن دعنا نزيد التحدي. تذكر كود كائن المنتج الذي كتبناه في JavaScript؟ دعنا نطبقه مرة أخرى، ولكن الآن بعقلية TypeScript. فقط للتذكير بما نتحدث عنه:

const product = {
  title: 'Some product',
  price: 100.00,
};

هذه هي قيمة المنتج. تحتوي على title كنص (string) و price كرقم (number). في الوقت الحالي، هذا ما نحتاج لمعرفته. سيكون نوع الكائن شيئًا كهذا:

{ title: string, price: number }

ونستخدم هذا النوع لتعليق دالتنا:

const product: { title: string, price: number } = {
  title: 'Some product',
  price: 100.00,
};

باستخدام هذا النوع، سيعرف المجمّع كيفية التعامل مع البيانات غير المتسقة:

const wrongProduct: { title: string, price: number } = {
  title: 100.00, // Type 'number' is not assignable to type 'string'.
  price: 'Some product', // Type 'string' is not assignable to type 'number'.
};

هنا ينقسم إلى خاصيتين مختلفتين:

  • الخاصية title هي string ويجب ألا تستقبل number.
  • الخاصية price هي number ويجب ألا تستقبل string.

يساعدنا المجمّع في اكتشاف أخطاء الأنواع من هذا القبيل. يمكننا تحسين تعليق النوع هذا باستخدام مفهوم يسمى Type Aliases (الأسماء المستعارة للأنواع). إنها طريقة لإنشاء اسم جديد لنوع معين. في حالتنا، يمكن أن يكون نوع المنتج:

type Product = {
  title: string;
  price: number;
};

const product: Product = {
  title: 'Some product',
  price: 100.00,
};

من الأفضل تصور النوع، وإضافة دلالات (semantics)، وربما إعادة استخدامه في نظامنا. الآن بعد أن أصبح لدينا نوع المنتج هذا، يمكننا استخدامه لتحديد نوع قائمة المنتجات. تبدو البنية كالتالي: MyType[]. في حالتنا، Product[].

const products: Product[] = [
  {
    title: 'Product 1',
    price: 100.00,
  },
  {
    title: 'Product 2',
    price: 25.00,
  },
  {
    title: 'Product 3',
    price: 300.00,
  }
];

الآن دالة sumAllPrices(). ستستقبل المنتجات وتعيد رقمًا، وهو مجموع أسعار جميع المنتجات.

function sumAllPrices(products: Product[]): number {
  return products.reduce((sum, product) => sum + product.price, 0);
};

هذا مثير للاهتمام للغاية. عندما حددنا نوع المنتج، فعندما نكتب product.، ستظهر الخصائص المحتملة التي يمكننا استخدامها. في حالة نوع المنتج، ستظهر الخصائص price و title.

sumAllPrices(products); // 425
sumAllPrices([]); // 0
sumAllPrices([{ title: 'Test', willFail: true }]); // Type '{ title: string; willFail: true; }' is not assignable to type 'Product'.

تمرير products سيؤدي إلى القيمة 425. قائمة فارغة ستؤدي إلى القيمة 0. وإذا مررنا كائنًا ببنية مختلفة – TypeScript لديه نظام أنواع هيكلي (structural type system) وسنتعمق في هذا الموضوع لاحقًا – فسيقوم المجمّع بإلقاء خطأ نوع يخبرنا بأن البنية ليست جزءًا من النوع Product.

الأنواع الهيكلية (Structural Typing)

الأنواع الهيكلية هي نوع من توافق الأنواع (type compatibility). إنها طريقة لفهم التوافق بين الأنواع بناءً على هيكلها: الميزات، الأعضاء، الخصائص. بعض اللغات لديها توافق الأنواع بناءً على أسماء الأنواع، ويسمى هذا بالأنواع الاسمية (nominal typing).

على سبيل المثال، في Java، حتى لو كانت الأنواع المختلفة لها نفس الهيكل، فإنها ستلقي خطأ تجميع لأننا نستخدم نوعًا مختلفًا لإنشاء وتحديد مثيل جديد (new instance).

class Person { String name; }
class Client { String name; }

Client c = new Person(); // compiler throws an error
Client c = new Client(); // OK!

في أنظمة الأنواع الاسمية، الجزء المهم من النوع هو الاسم، وليس الهيكل. TypeScript، من ناحية أخرى، يتحقق من التوافق الهيكلي للسماح أو عدم السماح ببيانات معينة. نظام الأنواع الخاص به يعتمد على الأنواع الهيكلية.

نفس تطبيق الكود الذي يتعطل في Java، سيعمل في TypeScript:

class Person { name: string; }
class Client { name: string; }

const c1: Client = new Person(); // OK!
const c2: Client = new Client(); // OK!

نريد استخدام النوع Client، وهو يحتوي على الخاصية name، للإشارة إلى النوع Person. وهو يحتوي أيضًا على خاصية من نفس النوع. لذا سيفهم TypeScript أن كلا النوعين لهما نفس الشكل.

ولكن الأمر لا يتعلق بالفئات (classes) فقط، بل يعمل مع أي “كائن” آخر:

const c3: Client = { name: 'TK' };

هذا الكود يتم تجميعه أيضًا لأن لدينا نفس الهيكل هنا. لا يهتم نظام أنواع TypeScript بما إذا كان فئة، أو كائنًا حرفيًا (object literal)؛ إذا كان له نفس الأعضاء، فسيكون مرنًا ويقوم بالتجميع.

ولكن الآن سنضيف نوعًا ثالثًا: Customer.

class Customer {
  name: string;
  age: number;
};

إنه لا يحتوي فقط على خاصية name، بل أيضًا على خاصية age. ماذا سيحدث إذا أنشأنا مثيل Client في ثابت من النوع Customer؟

const c4: Customer = new Client();

لن يقبل المجمّع ذلك. نريد استخدام Customer، الذي يحتوي على name و age. لكننا ننشئ مثيل Client الذي يحتوي على خاصية name فقط. لذا ليس لهما نفس الشكل. سيسبب ذلك خطأ:

Property 'age' is missing in type 'Client' but required in type 'Customer'.

الطريقة الأخرى ستعمل لأننا نريد Client، و Customer يحتوي على جميع الخصائص (name) من Client.

const c5: Client = new Customer();

يعمل بشكل جيد! يمكننا المضي قدمًا في التعدادات (enums)، والكائنات الحرفية، وأي نوع آخر، ولكن الفكرة هنا هي فهم أن هيكل النوع هو الجزء المهم.

وقت التشغيل ووقت التجميع (Runtime and Compile time)

هذا موضوع أكثر تعقيدًا في نظرية لغات البرمجة، لكنني أردت تقديم بعض الأمثلة للتمييز بين وقت التشغيل ووقت التجميع. بشكل أساسي، وقت التشغيل هو وقت تنفيذ البرنامج. تخيل أن الواجهة الخلفية (backend) تتلقى بيانات من صفحة نموذج الواجهة الأمامية (frontend form page)، وتتعامل مع هذه البيانات، وتحفظها. أو عندما تطلب الواجهة الأمامية بيانات من خادم لعرض قائمة بمنتجات Pokemons.

وقت التجميع هو بشكل أساسي عندما يقوم المجمّع بتنفيذ عمليات في الكود المصدري لتلبية متطلبات لغة البرمجة. يمكن أن يشمل فحص الأنواع (type checking) كعملية، على سبيل المثال.

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

  • عندما تكون الخاصية مفقودة من النوع: Property 'age' is missing in type 'Client' but required in type 'Customer'.
  • عندما لا يتطابق النوع: Type '{ title: string; willFail: true; }' is not assignable to type 'Product'.

دعنا نرى بعض الأمثلة للحصول على فهم أفضل. أريد كتابة دالة للحصول على فهرس جزء من لغة البرمجة الممررة.

function getIndexOf(language, part) {
  return language.indexOf(part);
}

تستقبل language و part التي سنبحث عنها للحصول على الفهرس.

getIndexOf('Typescript', 'script'); // 4
getIndexOf(42, 'script'); // Uncaught TypeError: language.indexOf is not a function at getIndexOf

عند تمرير سلسلة نصية، تعمل بشكل جيد. ولكن عند تمرير رقم، حصلنا على خطأ وقت تشغيل Uncaught TypeError. لأن الرقم لا يحتوي على دالة indexOf()، لذا لا يمكننا استخدامها حقًا. ولكن إذا قدمنا معلومات النوع للمجمّع، في وقت التجميع، فسوف يلقي خطأ قبل تشغيل الكود.

function getIndexOf(language: string, part: string): number {
  return language.indexOf(part);
}

الآن يعرف برنامجنا أنه سيحتاج إلى استقبال سلسلتين نصيتين وإرجاع رقم. يمكن للمجمّع استخدام هذه المعلومات لإلقاء الأخطاء عندما نحصل على خطأ نوع… قبل وقت التشغيل.

getIndexOf('Typescript', 'script'); // 4
getIndexOf(42, 'script'); // Argument of type '42' is not assignable to parameter of type 'string'.

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

في البداية، نحتاج إلى منحنى التعلم الكامل لفهم الأنواع وجميع النماذج الذهنية، ولكن بعد فترة، ستعتاد أكثر على تعليقات الأنواع وستصبح صديقًا للمجمّع في النهاية. سيكون مساعدًا، وليس صراخًا.

بينما نتعلم عن الفرق الأساسي بين وقت التجميع ووقت التشغيل، أعتقد أنه من الرائع التمييز بين الأنواع والقيم. يمكن نسخ جميع الأمثلة التي سأعرضها هنا وتشغيلها في TypeScript Playground لفهم المجمّع ونتيجة عملية التجميع (المعروفة أيضًا باسم “JavaScript“).

في TypeScript، لدينا عالمان مختلفان: مساحة القيمة (value space) ومساحة النوع (type space). مساحة النوع هي حيث يتم تعريف الأنواع واستخدامها لتمكين المجمّع من القيام بكل السحر الرائع. ومساحة القيمة هي القيم في برامجنا مثل المتغيرات، الثوابت، الدوال، القيم الحرفية، والأشياء التي لدينا في وقت التشغيل. من الجيد فهم هذا المفهوم لأنه في TypeScript لا يمكننا استخدام فحص الأنواع في وقت التشغيل. لديها فصل واضح جدًا بين فحص الأنواع وعملية التجميع. يقوم TypeScript بعملية فحص أنواع الكود المصدري ويرى ما إذا كان كل شيء صحيحًا ومتسقًا. ثم يمكنه التجميع إلى JavaScript. نظرًا لأن هذين الجزئين منفصلان، لا يمكننا استخدام فحص الأنواع في وقت التشغيل. فقط في “وقت التجميع”.

إذا حاولت استخدام نوع كقيمة، فسوف يلقي خطأ: 'CreditCard' only refers to a type, but is being used as a value here.. دعنا نرى أمثلة على هذه الفكرة. تخيل أننا نريد كتابة دالة تسمى purchase() حيث نستقبل طريقة دفع وبناءً على هذه الطريقة، نريد القيام ببعض الإجراءات. لدينا بطاقة ائتمان وبطاقة خصم. دعنا نعرفهما هنا:

type CreditCard = {
  number: number;
  cardholder: string;
  expirationDate: Date;
  secutiryCode: number;
};

type DebitCard = {
  number: number;
  cardholder: string;
  expirationDate: Date;
  secutiryCode: number;
};

type PaymentMethod = CreditCard | DebitCard;

هذه الأنواع موجودة في مساحة النوع (Type space)، لذا فهي تعمل فقط في وقت التجميع. بعد فحص هذه الدالة، يزيل المجمّع جميع الأنواع. إذا أضفت هذه الأنواع في TypeScript Playground، فسيكون الإخراج تعريفًا صارمًا فقط "use strict";. الفكرة هنا هي فهم أن الأنواع تعيش في مساحة النوع ولن تكون متاحة في وقت التشغيل. لذا في دالتنا، لن يكون من الممكن القيام بذلك:

const purchase = (paymentMethod: PaymentMethod) => {
  if (paymentMethod instanceof CreditCard) {
    // purchase with credit card
  } else {
    // purchase with debit card
  }
}

في المجمّع، يلقي خطأ: 'CreditCard' only refers to a type, but is being used as a value here.. يعرف المجمّع الفرق بين المساحتين وأن النوع CreditCard يعيش في مساحة النوع.

Playground أداة رائعة جدًا لرؤية إخراج كود TypeScript الخاص بك. إذا أنشأت كائن بطاقة ائتمان جديدًا كهذا:

const creditCard: CreditCard = {
  number: 2093,
  cardholder: 'TK',
  expirationDate: new Date(),
  secutiryCode: 101
};

سيقوم المجمّع بفحص النوع والقيام بكل السحر ثم يقوم بتحويل كود TypeScript إلى JavaScript. وسيكون لدينا هذا:

const creditCard = {
  number: 2093,
  cardholder: 'TK',
  expirationDate: new Date(),
  secutiryCode: 101
};

نفس الكائن، ولكن الآن فقط مع القيمة وبدون النوع.

القيود وتضييق الأنواع (Constraints & Type Narrowing)

عندما نقيّد ما يمكننا فعله، يصبح من الأسهل فهم ما يمكننا فعله. نستخدم الأنواع كقيود للحد من الأخطاء في برنامجنا. لفهم هذا المفهوم، سأستعير مثالًا من حديث لورين تان حول أنظمة الأنواع.

const half = x => x / 2;

كم عدد الطرق التي يمكن أن تفشل بها هذه الدالة؟ تخيل عددًا من المدخلات المحتملة:

[null, undefined, 0, '0', 'TK', { username: 'tk' }, [42, 3.14], (a, b) => a + b,]

وما هي النتائج لهذه المدخلات؟

half(null); // 0
half(undefined); // NaN
half(0); // 0
half('0'); // 0
half('TK'); // NaN
half({ username: 'tk' }); // NaN
half([42, 3.14]); // NaN
half((a, b) => a + b); // NaN

لدينا هنا نتائج مختلفة وغير متوقعة. هنا من الواضح أننا نريد رقمًا كمدخل لدالة half()، وأن تقوم بالحساب، وهذا كل شيء! ولكن في بعض الأحيان لا نتحكم في المدخلات أو يكون الكود كبيرًا، أو جديدًا/غير مألوف، ونكون قادرين على ارتكاب هذه الأخطاء الصغيرة.

تتمثل فكرة إضافة قيود إلى الكود الخاص بنا في تضييق نطاق إمكانيات مجموعة من الأنواع. في هذه الحالة، نريد تحديد نوع الإدخال ليكون من النوع number. إنه النوع الوحيد الذي نهتم به لإجراء حساب النصف. مع تضييق الأنواع (type narrowing)، نعطي مرة أخرى معلومات النوع للمجمّع.

const half = (x: number) => x / 2;

وباستخدام هذه المعلومات الجديدة، إذا استدعينا الدالة بحالات الاختبار مرة أخرى، فسنحصل على نتائج مختلفة:

half(null); // Argument of type 'null' is not assignable to parameter of type 'number'.
half(undefined); // Argument of type 'undefined' is not assignable to parameter of type 'number'.
half(0); // 0
half('0'); // Argument of type '"0"' is not assignable to parameter of type 'number'.
half('TK'); // Argument of type '"TK"' is not assignable to parameter of type 'number'.
half({ username: 'tk' }); // Argument of type '{ username: string; }' is not assignable to parameter of type 'number'.
half([42, 3.14]); // Argument of type 'number[]' is not assignable to parameter of type 'number'.
half((a, b) => a + b); // Argument of type '(a: any, b: any) => any' is not assignable to parameter of type 'number'.

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

ولكن هناك طرق أخرى لتضييق الأنواع في TypeScript. تخيل أن لدينا دالة تستقبل معاملًا يمكن أن يكون إما سلسلة نصية (string) أو رقمًا (number).

type StringOrNumber = string | number;
function stringOrNumber(value: StringOrNumber) {}

في جسم الدالة، لن يعرف المجمّع أي الطرق أو الخصائص التي يمكننا استخدامها لهذا النوع. هل هو سلسلة نصية أم رقم؟ نحن نعرف فقط عن القيمة في وقت التشغيل. ولكن يمكننا تضييق النوع باستخدام typeof:

function stringOrNumber(value: StringOrNumber) {
  if (typeof value === 'string') {
    // value.
    // your IDE will show you the possible methods from the string type
    // (parameter) value: string value
  }

  if (typeof value === 'number') {
    // value.
    // your IDE will show you the possible methods from the number type
    // (parameter) value: number value
  }
}

باستخدام عبارة if و typeof، يمكننا إعطاء المزيد من المعلومات للمجمّع. الآن سيعرف النوع المحدد لكل جسم if. يعرف IDE (بيئة التطوير المتكاملة) ما يجب عرضه للنوع المحدد. في وقت التشغيل، عندما تكون القيمة سلسلة نصية، ستنتقل إلى عبارة if الأولى، وسيقوم المجمّع باستنتاج أن النوع هو سلسلة نصية: (parameter) value: string. عندما تكون القيمة رقمًا، ستنتقل إلى عبارة if الثانية وسيقوم المجمّع باستنتاج أن النوع هو رقم: (parameter) value: number. يمكن أن تكون عبارة if مساعدة للمجمّع.

مثال آخر هو عندما يكون لدينا خاصية اختيارية (optional property) في كائن، ولكن في دالة، نحتاج إلى إرجاع قيمة بناءً على هذه القيمة الاختيارية. تخيل أن لدينا هذا النوع:

type User = {
  name: string;
  address: {
    street: string;
    complement?: string;
  };
};

إنه نوع User بسيط. دعنا نركز على خاصية complement. إنها اختيارية (ألقِ نظرة فاحصة على الرمز ?)، مما يعني أنها يمكن أن تكون string أو undefined. الآن نريد بناء دالة لاستقبال المستخدم والحصول على طول تكملة العنوان.

ماذا عن هذا؟

function getComplementLength(user: User): number {
  return user.address.complement.length; // (property) complement?: string | undefined // Object is possibly 'undefined'.
}

كما رأينا سابقًا، يمكن أن يكون complement عبارة عن string أو undefined. undefined لا يحتوي حقًا على خاصية تسمى length:

Uncaught TypeError: Cannot read property 'length' of undefined

يمكننا أن نفعل شيئًا كهذا:

function getComplementLength(user: User) {
  return user.address.complement?.length;
}

إذا كان complement يحتوي على قيمة سلسلة نصية، يمكننا استدعاء length، وإلا فسيعيد undefined. لذا فإن هذه الدالة لها نوعان محتملان للإرجاع: number | undefined. لكننا نريد التأكد من أننا نعيد number فقط. لذلك نستخدم شرط if أو شرط ثلاثي لتضييق النوع. سيتم استدعاء .length فقط عندما تكون له قيمة حقيقية (أو عندما لا يكون undefined).

function getComplementLength(user: User): number {
  return user.address.complement ? user.address.complement.length : 0;
}

إذا كان undefined، نعيد الحد الأدنى للطول: 0. الآن يمكننا استخدام الدالة بتصميم النوع الصحيح مع وبدون التكملة. بدون أخطاء التجميع ووقت التشغيل.

getComplementLength({ name: 'TK', address: { street: 'Shinjuku Avenue' } }); // 0
getComplementLength({ name: 'TK', address: { street: 'Shinjuku Avenue', complement: 'A complement' } }); // 12

سنحصل على 0 من استدعاء الدالة الأول و 12 من الاستدعاء الثاني. باستخدام مفهوم if هذا، يمكننا أيضًا استخدام مساعدين آخرين للقيام بنفس الشيء. يمكننا استخدام عامل التشغيل in للتحقق من خاصية من كائن، أو Array.isArray() للتحقق من مصفوفة، أو instanceof لأي نوع فئة آخر. يمكننا أيضًا استخدام مفاهيم أكثر تقدمًا مثل دالة التأكيد (assertion function) أو حراس الأنواع (type guards)، لكنني سأترك هذه المفاهيم لمقالات مستقبلية.

شيء واحد أريد التعمق فيه في موضوع القيود هذا هو عدم القابلية للتغيير (immutability). في JavaScript و TypeScript، لدينا فكرة الكائنات القابلة للتغيير (mutable objects). إذا قمت بتعريف قيمة في متغير، يمكننا إعادة تعيينها بقيمة أخرى لاحقًا.

let email = 'harry.potter@mail.com';
email // 'harry.potter@mail.com'
email = 'hermione.granger@mail.com';
email // 'hermione.granger@mail.com'

الآن تخيل أن لديك قائمة من الأرقام. وتريد استخدام دالة لجمع جميع أرقامها. تبدو الدالة كالتالي:

function sumNumbers(numbers: number[]) {
  let sum = 0;
  let num = numbers.pop();
  while (num !== undefined) {
    sum += num;
    num = numbers.pop();
  }
  return sum;
}

تستدعي الدالة وتمرر قائمتك وتحصل على النتيجة. تعمل بشكل جيد.

const list = [1, 2, 3, 4];
sumNumbers(list); // 10

ولكن ماذا حدث لقائمتك؟ هل قامت الدالة بتغييرها بالكامل؟

list; // []

إذا استخدمنا القائمة، فهي فارغة الآن. الدالة pop() في دالة sumNumbers() هي دالة “تغيير”. إنها تحصل على المراجع وتزيل العنصر منها. إنها ليست نسخة، إنها المرجع الحقيقي. في وقت التشغيل، يمكننا استخدام دوال أو طرق أخرى للقيام بنفس الشيء: استخدام reduce()، أو القيام بحلقة for دون الحاجة إلى إزالة العناصر من المصفوفة.

ولكن باستخدام TypeScript، يمكننا توفير عدم القابلية للتغيير في وقت التجميع. إذا لم تكن تستخدم الأنواع، فمن الممكن استخدام تأكيد النوع (type assertion) as const. تخيل هذا:

const author = {
  name: 'Walter Isaacson',
  email: 'walter.isaacson@mail.com',
  books: [
    {
      title: 'Leonardo Da Vinci',
      price: 50.00,
    }
  ]
};

author.books.push({ title: 'Steve Jobs', price: 10.00 });

مجرد كائن مؤلف ثم نضيف كتابًا جديدًا لهذا المؤلف. طريقة push() تحدث تحديثًا لمرجع مصفوفة الكتب. إنها طريقة “تغيير”. دعنا نرى ما إذا كنت تستخدم تأكيد const كـ as const:

const author = {
  name: 'Walter Isaacson',
  email: 'walter.isaacson@mail.com',
  books: [
    {
      title: 'Leonardo Da Vinci',
      price: 50.00,
    }
  ]
} as const;

author.books.push({ title: 'Steve Jobs', price: 10.00 }); // Property 'push' does not exist on type // 'readonly [{ readonly title: "Leonardo Da Vinci"; readonly price: 50; }]'

لن يقوم المجمّع بالتجميع. يحصل على خطأ في كائن المؤلف. أصبح الآن للقراءة فقط (readonly)، وبصفته كائنًا للقراءة فقط، فإنه لا يحتوي على طريقة تسمى push() (أو أي طريقة “تغيير” أخرى). لقد أضفنا قيدًا على كائن المؤلف. قبل ذلك كان نوعًا معينًا (مع جميع طرق “التغيير”)، والآن قمنا بتضييق النوع ليكون هو نفسه تقريبًا، ولكن بدون طرق “التغيير”. هذا هو تضييق الأنواع.

للمتابعة، دعنا نضيف أنواعًا لهذا الكائن. الكتاب (book) والمؤلف (author):

type Book = {
  title: string;
  price: number;
};

type Author = {
  name: string;
  email: string;
  books: Book[];
};

أضف النوع إلى كائن المؤلف:

const author: Author = {
  name: 'Walter Isaacson',
  email: 'walter.isaacson@mail.com',
  books: [
    {
      title: 'Leonardo Da Vinci',
      price: 50.00,
    }
  ]
};

أضف النوع إلى كائن كتاب جديد:

const book: Book = { title: 'Steve Jobs', price: 30 };

والآن يمكننا إضافة الكتاب الجديد إلى المؤلف:

author.name = 'TK';
author.books.push(book);

يعمل بشكل جيد! أريد أن أظهر طريقة أخرى لإضافة عدم القابلية للتغيير في وقت التجميع. يحتوي TypeScript على نوع أداة مساعدة (utility type) يسمى Readonly. يمكنك إضافة readonly لكل خاصية في كائن. شيء كهذا:

type Book = {
  readonly title: string;
  readonly price: number;
};

ولكن يمكن أن يكون هذا متكررًا للغاية. لذا يمكننا استخدام أداة Readonly لإضافة readonly إلى جميع خصائص الكائن:

type Book = Readonly<{ title: string; price: number; }>;

شيء واحد يجب أخذه في الاعتبار هو أنه لا يضيف readonly للخصائص المتداخلة (nested properties). على سبيل المثال، إذا أضفنا Readonly إلى النوع Author، فلن يضيف readonly إلى النوع Book أيضًا.

type Author = Readonly<{ name: string; email: string; books: Book[]; }>;

لا يمكن إعادة تعيين جميع خصائص المؤلف، ولكن يمكنك تغيير قائمة books هنا (باستخدام push()، pop()، …) لأن Book[] ليست للقراءة فقط. دعنا نرى ذلك.

const author: Author = {
  name: 'Walter Isaacson',
  email: 'walter.isaacson@mail.com',
  books: [
    {
      title: 'Leonardo Da Vinci',
      price: 50.00,
    }
  ]
};

const book: Book = { title: 'Steve Jobs', price: 30 };

author.books.push(book);
author.books;
/* =>
 * [
 *   {
 *     title: 'Leonardo Da Vinci',
 *     price: 50.00,
 *   },
 *   {
 *     title: 'Steve Jobs',
 *     price: 30
 *   }
 * ]
 */

ستعمل push() بشكل جيد. إذًا، كيف نفرض أن تكون books للقراءة فقط؟ نحتاج إلى التأكد من أن المصفوفة هي نوع للقراءة فقط. يمكننا استخدام Readonly، أو استخدام أداة مساعدة أخرى من TypeScript تسمى ReadonlyArray. دعنا نرى الطريقتين للقيام بذلك.

باستخدام Readonly:

type Author = Readonly<{ name: string; email: string; books: Readonly<Book[]>; }>;

باستخدام ReadonlyArray:

type Author = Readonly<{ name: string; email: string; books: ReadonlyArray<Book>; }>;

بالنسبة لي، كلاهما يعمل بشكل رائع! ولكن في رأيي، ReadonlyArray أكثر دلالة وأشعر أيضًا أنه أقل إسهابًا (ليس أن Readonly مع مصفوفة كذلك). ماذا سيحدث إذا حاولنا تغيير كائن المؤلف الآن؟

author.name = 'TK'; // Cannot assign to 'name' because it is a read-only property.
author.books.push(book); // Property 'push' does not exist on type 'readonly [{ readonly title: "Leonardo Da Vinci"; readonly price: 50; }]'.

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

الدلالات وقابلية القراءة (Semantics & Readability)

في البداية، شعرت أن TypeScript يمكن أن يكون مطولًا جدًا بسبب الأنواع ويجعل الكود أكثر تعقيدًا مما ينبغي. ويمكن أن يكون كذلك بالفعل. السعي نحو البساطة هو الهدف وهو صعب في نفس الوقت. هذه الفكرة مرتبطة جدًا بالكود النظيف (clean code) وكيف يمكننا كتابة كود سهل القراءة والصيانة. TypeScript لا يختلف. في معظم الحالات، لا نحتاج إلى أنواع معقدة للغاية. دع الأنواع البسيطة تقوم بالعمل.

شيء آخر أجده مفيدًا جدًا هو دلالة الأنواع. تخيل أنك تحتاج إلى إضافة سلسلة نصية إلى sessionStorage لحفظها في المتصفح. تبدو دالتك كالتالي:

function saveMyString(value: string): any {
  sessionStorage.myString = value;
}

تضيف تعليق نوع لمدخل السلسلة النصية، وبما أنك لا تعرف نوع الإرجاع، فمن المحتمل أن تضيف النوع any. ولكن ما المعنى الحقيقي وراء نوع الإرجاع هذا؟ هل يعيد أي شيء؟ إنه فقط يحفظ السلسلة النصية إلى sessionStorage. لا يعيد أي شيء. النوع void هو ما كنت تبحث عنه. كما تقول وثائق TypeScript: “غياب أي نوع على الإطلاق”.

function saveMyString(value: string): void {
  sessionStorage.myString = value;
}

رائع، معنى النوع صحيح الآن. الدقة مهمة جدًا في نظام الأنواع. إنها طريقة لنمذجة بياناتنا، ولكنها تساعد أيضًا في صيانة الأنظمة للمطورين المستقبليين. حتى لو كان المطور … أنت!

تحدثنا سابقًا عن الكود المطول. ويمكننا تحسين الكثير من الكود الخاص بنا باستخدام استنتاج أنواع TypeScript (type inference). بالنسبة لبعض الأكواد، لا نحتاج إلى إضافة تعليق نوع بشكل صريح. سيفهم مجمّع TypeScript ويستنتجه ضمنيًا. على سبيل المثال:

const num: number = 1;

هذا الكود زائد عن الحاجة. يمكننا فقط السماح للمجمّع باستنتاجه هكذا:

const num = 1;

في مثالنا السابق، أضفنا التعليق void إلى دالة saveMyString(). ولكن بما أن الدالة لا تعيد أي قيمة، فإن المجمّع سيستنتج أن نوع الإرجاع هو void ضمنيًا. عندما تعلمت هذا، فكرت في نفسي. ولكن إحدى أكبر مزايا استخدام TypeScript (أو أي نظام أنواع آخر / لغة ذات أنواع ثابتة) هي الأنواع كتوثيق. إذا سمحنا للمجمّع باستنتاج معظم الأنواع، فلن يكون لدينا التوثيق الذي نريده. ولكن إذا مررت مؤشر الماوس فوق كود TypeScript في محرر الكود الخاص بك (يعمل VS Code على الأقل بهذه الطريقة)، يمكنك رؤية معلومات النوع والتوثيق ذي الصلة.

دعنا نرى أمثلة أخرى على الكود الزائد عن الحاجة ونجعل الكود أقل إسهابًا وندع المجمّع يعمل من أجلنا.

function sum(a: number, b: number): number {
  return a + b;
};

لا نحتاج إلى نوع الإرجاع number، لأن المجمّع يعرف أن number + number يساوي نوع number، وهو نوع الإرجاع. يمكن أن يكون:

function sum(a: number, b: number) {
  return a + b;
};

كود ضمني، ولكن مع توثيق، والمجمّع يقوم بالعمل. يعمل استنتاج الأنواع للطرق أيضًا:

function squareAll(numbers: number[]): number[] {
  return numbers.map(number => number * number);
};

تستقبل هذه الدالة قائمة من الأرقام وتجعل كل رقم قيمة مربعة. نوع الإرجاع هو number[]، على الرغم من أن نتيجة map() هي دائمًا قائمة، وبما أن لدينا قائمة من الأرقام، فستكون دائمًا قائمة من الأرقام. لذا نترك المجمّع يستنتج هذا أيضًا:

function squareAll(numbers: number[]) {
  return numbers.map(number => number * number);
};

يعمل هذا بنفس الطريقة للكائنات أيضًا.

const person: { name: string, age: number } = { name: 'TK', age: 24 };

كائن شخص باسم سلسلة نصية وعمر رقمي. ولكن بما أننا نعين هذه القيم، يمكن للمجمّع استنتاج هذه الأنواع.

const person = { name: 'TK', age: 24 };

إذا مررت مؤشر الماوس فوق person، فستحصل على هذا:

const person: {
  name: string;
  age: number;
}

الأنواع موثقة هنا. فائدة أخرى لاستنتاج الأنواع هي أنه يمكننا بسهولة إعادة هيكلة الكود الخاص بنا (refactor). إنه مثال بسيط، ولكنه جيد لتوضيح عملية إعادة الهيكلة. دعنا نأخذ دالة sum() مرة أخرى.

function sum(a: number, b: number): number {
  return a + b;
};

بدلاً من إرجاع الرقم المجموع، نريد إرجاع "Sum: {a + b}". لذا بالنسبة لـ a = 1 و b = 2، نحصل على السلسلة النصية الناتجة "Sum: 3".

function sum(a: number, b: number): string {
  return `Sum: ${a + b}`;
};

sum(1, 2); // Sum: 3

رائع! ولكن الآن دع المجمّع يستنتج هذا.

// function sum(a: number, b: number): number
function sum(a: number, b: number) {
  return a + b;
};

// function sum(a: number, b: number): string
function sum(a: number, b: number) {
  return `Sum: ${a + b}`;
};

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

بالعودة إلى جزء قابلية القراءة، يمكننا استخدام Enum. أداة مساعدة تحدد مجموعة من الثوابت المسماة. إنها طريقة لإعطاء معنى أكبر للبيانات في تطبيقك. في تطبيق Node.js الخاص بك أو تطبيق الواجهة الأمامية، من المحتمل أن تقوم ببعض عمليات الجلب (fetching) لطلب البيانات. تستخدم عادةً كائن fetch لإجراء طلب وفي بعض الأحيان تحتاج إلى تمرير رؤوس القبول (accept headers).

fetch('/pokemons', { headers: { Accept: 'application/json' } });
fetch('/harry-potter/spells', { headers: { Accept: 'application/json' } });

هذا جيد، ولكن يمكننا أيضًا استخدام enum لفصل سلسلة القبول هذه في ثابت وإعادة استخدامها.

enum MediaTypes {
  JSON = 'application/json'
}

fetch('/pokemons', { headers: { Accept: MediaTypes.JSON } });
fetch('/harry-potter/spells', { headers: { Accept: MediaTypes.JSON } });

ويمكننا إضافة المزيد من البيانات المتعلقة بـ MediaTypes مثل PDF:

enum MediaTypes {
  JSON = 'application/json',
  PDF = 'application/pdf'
}

باستخدام Enum، يمكننا تغليف البيانات في كتلة كود ذات معنى. مؤخرًا، كنت أقوم بتنفيذ مكون React “حالة” (state). إنه بشكل أساسي مكون يعرض حالة فارغة (empty state) أو حالة خطأ (error state) بناءً على استجابة الطلب. كانت واجهة المستخدم (UI) للحالات الفارغة وحالات الخطأ متشابهة جدًا. كان الاختلاف الوحيد في العنوان والنص الوصفي ورمز الصورة. لذا فكرت: “لدي طريقتان في ذهني لتنفيذ هذا: إما القيام بالمنطق خارج المكون وتمرير جميع المعلومات المطلوبة أو تمرير ‘نوع حالة’ والسماح للمكون بعرض الرمز والرسائل الصحيحة.”

لذا قمت ببناء enum:

export enum StateTypes {
  Empty = 'Empty',
  Error = 'Error'
};

ويمكنني فقط تمرير هذه البيانات إلى المكون كـ type:

import ComponentState, { StateTypes } from './ComponentState';

<ComponentState type={StateTypes.Empty} />
<ComponentState type={StateTypes.Error} />

في المكون، كان لديه كائن حالة يحتوي على جميع المعلومات المتعلقة بـ title و description و icon.

const stateInfo = {
  Empty: {
    title: messages.emptyTitle,
    description: messages.emptyDescription,
    icon: EmptyIcon,
  },
  Error: {
    title: messages.errorTitle,
    description: messages.errorDescription,
    icon: ErrorIcon,
  },
};

لذا يمكنني فقط استقبال النوع بناءً على enum واستخدام كائن stateInfo هذا مع مكون State من نظام التصميم الخاص بنا:

export const ComponentState = ({ type }) => (
  <State title={stateInfo[type].title} subtitle={stateInfo[type].subtitle} icon={stateInfo[type].icon} />
);

هذه طريقة لاستخدام enum لتغليف البيانات المهمة في كتلة كود ذات معنى في تطبيقك.

ميزة أخرى رائعة من TypeScript هي الخصائص الاختيارية (optional properties). عندما يكون لدينا خصائص من كائن يمكن أن تكون قيمة حقيقية أو undefined، نستخدم خاصية اختيارية لتوضيح صراحة أن الخاصية قد تكون موجودة أو لا تكون موجودة. البنية لذلك هي عامل التشغيل ? بسيط في خاصية الكائن. تخيل هذه الدالة:

function sumAll(a: number, b: number, c: number) {
  return a + b + c;
}

ولكن الآن قيمة c اختيارية:

function sumAll(a: number, b: number, c?: number) {
  return a + b + c;
}

أضفنا ? بعد c. ولكن الآن لدينا خطأ في المجمّع يقول:

(parameter) c: number | undefined Object is possibly 'undefined'.

لا يمكننا جمع قيمة undefined (حسنًا، في الواقع في JavaScript يمكننا، لكننا نحصل على قيمة NaN). نحتاج إلى التأكد من وجود c. تضييق الأنواع!

function sumAll(a: number, b: number, c?: number) {
  if (c) {
    return a + b + c;
  }
  return a + b;
}

إذا كانت c موجودة، فستكون رقمًا ويمكننا جمع الكل. إذا لم تكن كذلك، اجمع قيم a و b فقط. جزء مثير للاهتمام في هذه الخاصية الاختيارية هو أنها undefined وليست null. لهذا السبب عندما نفعل هذا، نحصل على خطأ تجميع:

let number = null;
sumAll(1, 2, number); // Argument of type 'null' is not assignable to parameter of type 'number | undefined'.

نظرًا لأن عامل التشغيل ? لا يتعامل مع قيمة null، اختر استخدام النوع undefined في تطبيقك وهكذا لا يزال بإمكانك استخدام الخاصية الاختيارية وجعل الأنواع متسقة. يمكننا استخدامها هكذا:

let value: number | undefined;
sumAll(1, 2, value); // 3

إذا أضفت قيمة افتراضية للمعامل، فلن تحتاج إلى عامل التشغيل ?. في الواقع، سيقول المجمّع أن “المعامل لا يمكن أن يحتوي على علامة استفهام ومُهيئ” (Parameter cannot have question mark and initializer).

function sumAll(a: number, b: number, c: number = 3) {
  return a + b + c;
}

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

تخيل أننا نجلب بيانات مستخدم وهذه هي الطريقة التي نمذجنا بها نوع الاستجابة:

type UserResponse = {
  name: string;
  email: string;
  username: string;
  age: number;
  isActive: boolean;
};

ولكن في الواقع، البريد الإلكتروني اختياري للمستخدم. قد تعيده نقطة نهاية API أو لا تعيده. لكن نوع UserResponse الذي بنيناه يعامله كخاصية مطلوبة. بعد جلب بيانات المستخدم، نريد معرفة ما إذا كان بريد المستخدم الإلكتروني يتطابق مع نطاق معين.

function matchDomain(email: string) {
  return email.endsWith(domain);
}

نظرًا لأن خاصية email مطلوبة في نوع UserResponse، فإن معامل email سيكون مطلوبًا أيضًا في دالة matchDomain(). هذا هو الخطأ الذي يمكن أن نحصل عليه في وقت التشغيل إذا كان email هو undefined:

// Uncaught TypeError: Cannot read property 'endsWith' of undefined

ولكن ماذا سيحدث لو نمذجنا UserResponse بشكل صحيح؟

type UserResponse = {
  name: string;
  email?: string;
  username: string;
  age: number;
  isActive: boolean;
};

الآن email يمكن أن يكون undefined وهو صريح. ولكن إذا أبقينا دالة matchDomain() كما هي، فسنحصل على خطأ تجميع:

// Argument of type 'undefined' is not assignable to parameter of type 'string'.

وهذا رائع! الآن يمكننا إصلاح معامل email في هذه الدالة باستخدام عامل التشغيل ?:

function matchDomain(email?: string) {
  return email.endsWith('email.com');
}

ولكن الآن نحصل على خطأ تجميع عند تشغيل email.endsWith()، لأنه قد يكون undefined أيضًا:

// (parameter) email: string | undefined // Object is possibly 'undefined'.

تضييق الأنواع! نستخدم كتلة if لإرجاع false عندما يكون email هو undefined. ونشغل طريقة endsWith() فقط إذا كان email سلسلة نصية حقيقية:

function matchDomain(email?: string) {
  if (!email) return false;
  return email.endsWith('email.com');
}

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

تكوين الأنواع (Type Composition)

تكوين الأنواع مفيد جدًا عند محاولة إعادة استخدام الأنواع الموجودة في أجزاء جديدة من الكود. لا نحتاج إلى إعادة كتابة أنواع جديدة، يمكننا إنشاء نوع جديد عن طريق تكوين الأنواع الموجودة.

أحد أمثلة التكوين التي أتعامل معها دائمًا باستخدام Redux أو خطاف useReducer() من React هو فكرة “المختزلات” (reducers). يمكن للمختزل دائمًا استقبال عدد من الإجراءات المختلفة. في هذا السياق، الإجراءات هي كائنات تحتوي على خاصية type على الأقل. تبدو كالتالي:

enum ActionTypes {
  FETCH = 'FETCH'
}

type FetchAction = {
  type: typeof ActionTypes.FETCH;
};

const fetchAction: FetchAction = {
  type: ActionTypes.FETCH
};

يحتوي fetchAction على نوع FetchAction الذي يحتوي على خاصية type وهي typeof ActionTypes.FETCH. ولكن يمكن للمختزل استقبال إجراءات أخرى أيضًا. على سبيل المثال، إجراء إرسال (submit action):

enum ActionTypes {
  FETCH = 'FETCH',
  SUBMIT = 'SUBMIT'
}

type SubmitAction = {
  type: typeof ActionTypes.SUBMIT;
};

const submitAction: SubmitAction = {
  type: ActionTypes.SUBMIT
};

بالنسبة لمكون معين، يمكننا تكوين جميع هذه الإجراءات في نوع واحد فقط واستخدامه كنوع لمعامل المختزل. سيبدو كالتالي:

type Actions = FetchAction | SubmitAction;

function reducer(state, action: Actions) {
  switch (action.type) {
    case ActionTypes.FETCH:
      // fetching action
    case ActionTypes.SUBMIT:
      // submiting action
  }
}

جميع الإجراءات المحتملة هي النوع Actions. ونستخدم نوع الاتحاد (union type) “لجمع” جميع أنواع الإجراءات. يمكن أن يكون الإجراء في المختزل من النوع FetchAction أو SubmitAction.

بصفتي من محبي Harry Potter، لم أستطع تفويت مثال من عالم Harry Potter. أريد بناء دالة بسيطة لاختيار منزل في Hogwarts بناءً على سمة الشخص. دعنا نبدأ بالمنازل أولاً.

type House = {
  name: string;
  traits: string[];
}

const gryffindor: House = {
  name: 'Gryffindor',
  traits: ['courage', 'bravery']
};

const slytherin: House = {
  name: 'Slytherin',
  traits: ['ambition', 'leadership']
};

const ravenclaw: House = {
  name: 'Ravenclaw',
  traits: ['intelligence', 'learning']
};

const hufflepuff: House = {
  name: 'Hufflepuff',
  traits: ['hard work', 'patience']
};

const houses: House[] = [
  gryffindor,
  slytherin,
  ravenclaw,
  hufflepuff
];

أريد أن أبقي الأمر بسيطًا، لذا فإن نوع House يحتوي فقط على name و traits، وهي قائمة بالسمات المحتملة للأشخاص المرتبطين بالمنزل. ثم أنشئ كل منزل وأضفتها جميعًا إلى قائمة houses. رائع! الآن سأبني نوع Person. يمكن أن يكون الشخص ساحرًا (witch) أو عاميًا (muggle).

type Witch = {
  name: string;
  trait: string;
  magicFamily: string;
}

type Muggle = {
  name: string;
  trait: string;
  email: string;
}

وهذا هو الجزء الذي نجمع فيه هذين النوعين المختلفين باستخدام نوع الاتحاد (union type):

type Person = Muggle | Witch;

باستخدام نوع الاتحاد، يحتوي النوع Person على جميع خصائص Muggle أو جميع خصائص Witch. لذا الآن، إذا أنشأت Muggle، أحتاج فقط إلى الاسم والسمة والبريد الإلكتروني:

const hermione: Muggle = {
  name: 'Hermione Granger',
  trait: 'bravery',
  email: 'hermione@mail.com'
};

إذا أنشأت Witch، أحتاج إلى الاسم والسمة واسم العائلة السحرية:

const harry: Witch = {
  name: 'Harry Potter',
  trait: 'courage',
  magicFamily: 'Potter'
};

وإذا أنشأت Person، أحتاج على الأقل إلى خصائص name و trait من Muggle و Witch:

const tk: Person = {
  name: 'TK',
  email: 'tk@mail.com',
  trait: 'learning',
  magicFamily: 'Kinoshita'
};

دالة chooseHouse() بسيطة جدًا. نمرر المنازل والشخص. بناءً على سمة الشخص، ستعيد الدالة المنزل المختار:

function chooseHouse(houses: House[], person: Person) {
  return houses.find((house) => house.traits.includes(person.trait))
}

وبتطبيق جميع الأشخاص الذين أنشأناهم:

chooseHouse(houses, harry); // { name: 'Gryffindor', traits: ['courage', 'bravery'] }
chooseHouse(houses, hermione); // { name: 'Gryffindor', traits: ['courage', 'bravery'] }
chooseHouse(houses, tk); // { name: 'Ravenclaw', traits: ['intelligence', 'learning'] }

رائع! نوع التقاطع (intersection type) مختلف قليلاً، ولكنه يمكن استخدامه أيضًا لدمج الأنواع الموجودة. عندما كنت أقوم بتنفيذ تطبيق ويب لتطبيق دراساتي في تجربة المستخدم (UX)، احتجت إلى إنشاء نوع خاصية لمكون الصورة. كان لدي النوع ImageUrl من نوع المنتج:

type ImageUrl = {
  imageUrl: string;
};

و ImageAttr لتمثيل جميع سمات الصورة:

type ImageAttr = {
  imageAlt: string;
  width?: string;
};

ولكن الخصائص (props) كانت تتوقع جميع هذه المعلومات في المكون. نوع التقاطع للإنقاذ!

type ImageProps = ImageUrl & ImageAttr;

بسيط هكذا. لذا الآن، يحتاج المكون إلى جميع هذه الخصائص. يبدو النوع كالتالي:

type ImageProps = {
  imageUrl: string;
  imageAlt: string;
  width?: string;
};

ويمكننا استخدام هذا النوع بهذه الطريقة:

const imageProps: ImageProps = {
  imageUrl: 'www.image.com',
  imageAlt: 'an image',
};

const imagePropsWithWidth: ImageProps = {
  imageUrl: 'www.image.com',
  imageAlt: 'an image',
  width: '100%'
};

رائع! مفهوم آخر لإعادة استخدام وتكوين الأنواع. أجد أيضًا النوع Pick مثيرًا للاهتمام ومفيدًا جدًا. لدينا أنواع أخرى مثيرة للاهتمام يمكننا كتابتها هنا، ولكن الفكرة هنا هي فهم أنه يمكننا تكوين الأنواع ولا يوجد حد لإعادة استخدام الأنواع. إذا كنت مهتمًا بدراسة أنواع أخرى، ألقِ نظرة على هذا المنشور الذي كتبته: TypeScript Learnings: Interesting Types.

الأدوات (Tooling)

عندما تقوم بتثبيت typescript باستخدام npm install typescript، لا تحصل فقط على المجمّع، بل تحصل على واجهة برمجة تطبيقات خدمة اللغة (language service API)، وهو خادم مستقل يسمى tsserver يمكن للمحررات تشغيله لتوفير الإكمال التلقائي (autocompletion)، الانتقال إلى التعريف (go-to)، وميزات رائعة أخرى. هذه الميزات هي ما يسميه بعض الأشخاص من فريق TypeScript أدوات إنتاجية المطور مثل الأخطاء الذكية عند فحص الأنواع و IntelliSense (إكمال الكود، معلومات التمرير، معلومات التوقيع).

لقد نظرنا في هذه الميزات طوال المقال، لكنني أريد تخصيص موضوع خاص للحديث عنها. مدقق أنواع TypeScript قوي بمعنى أنه يمكنه استنتاج الأنواع وتوفير معلومات حول بعض المشكلات المحتملة. مثال: لقد استنتج أن المدينة هي سلسلة نصية. وتم استخدام uppercase() بطريقة خاطئة. بما أنه يعرف أنها سلسلة نصية، فإنه يحاول أيضًا العثور على طريقة محتملة يبحث عنها المهندس.

const city = 'Tokyo';
city.toUppercase(); // Property 'toUppercase' does not exist on type // 'string'. Did you mean 'toUpperCase'?

في هذه الحالة، المجمّع ذكي حقًا، لأنه يجد بالضبط ما أردناه. يعمل أيضًا للكائنات:

const people = [
  { name: 'TK', age: 24 },
  { name: 'Kaio', age: 12 },
  { name: 'Kazumi', age: 31 },
];

for (const person of people) {
  console.log(person.agi); // Property 'agi' does not exist on type '{ name: string; age: number; }'
}

باستخدام الأنواع الثابتة، يمكن للأدوات توفير تجربة مطور رائعة مع إكمال الكود، ومعلومات التمرير لإظهار الأنواع المعرفة، ومعلومات التوقيع للطرق والبيانات الأخرى. إذا كتبت: 'TK'.، سيعرض المحرر جميع الطرق المحتملة لكائن السلسلة النصية. يعرف المجمّع أنها سلسلة نصية. ويعرف الطرق من النموذج الأولي لـ String. ولكنه يوفر أيضًا توقيع الطريقة. هذا مثير للاهتمام جدًا لأننا لسنا بحاجة بالضرورة للذهاب إلى الوثائق. “الوثائق” موجودة بالفعل في محرر الكود الخاص بنا. إنها تجربة رائعة أثناء كتابة الكود.

تعريف النوع “عند التمرير” هو شيء آخر رأيناه سابقًا في هذا المقال. دع المجمّع يستنتج الأنواع ضمنيًا ولن تفقد توثيق النوع. باستخدام التمرير في الكائن، سيتمكن IDE أو المحرر دائمًا من إظهار تعريف النوع.

شيء آخر مثير للاهتمام هو أن TypeScript لن يشير فقط إلى ما قد يحدث خطأ في وقت التشغيل، بل يساعد أيضًا في العثور على الكود الذي لا يفعل ما تنوي القيام به. تخيل أن لدينا دالة لفتح شريط الإشعارات (snackbar) إذا كان لا يزال مغلقًا. ستتحقق من حالة شريط الإشعارات. إذا كان مغلقًا، فما عليك سوى استدعاء دالة أخرى لفتحه.

const buildSnackbar = (status: SnackbarStatus) => {
  if (status.isClosed) {
    openSnackbar();
  }
};

ومعلومات النوع لشريط الإشعارات هذا هي:

type SnackbarStatus = {
  isClosed: boolean;
};

ماذا سيحدث إذا استدعيت هذه الدالة هكذا:

buildSnackbar({ isclosed: true });

لن يتعطل في وقت التشغيل، لأن كائن status لا يحتوي على خاصية isClosed وكائن undefined هو قيمة خاطئة (falsy value)، لذا سيتخطى شرط if ولن يستدعي دالة openSnackbar(). لا يوجد خطأ في وقت التشغيل. ولكن من المحتمل أن يتصرف بشكل مختلف عن المتوقع. في TypeScript، سيعطي المجمّع بعض التلميحات لجعله يعمل بشكل صحيح. أولاً سيعرض هذا الخطأ:

// Argument of type '{ isclosed: boolean; }' is not assignable to // parameter of type 'SnackbarStatus'.

isclosed بحرف C صغير غير قابل للتعيين للنوع. إنه غير معرف هناك. هذا هو التلميح الأول لتصحيح الكود الخاص بك. والثاني أفضل:

// Object literal may only specify known properties, // but 'isclosed' does not exist in type 'SnackbarStatus'. // Did you mean to write 'isClosed'?

إنه يخبرك بالضبط بما تحتاج على الأرجح للقيام به: إعادة تسمية isclosed إلى isClosed. يمكننا التحدث كثيرًا عن الأدوات، لكنني أعتقد أن هذا هو الجزء الرئيسي. اقتراحي لتعلم المزيد عن هذا هو مجرد كتابة الكود في TypeScript و”إجراء محادثة” مع المجمّع. اقرأ الأخطاء. العب بالتمرير. شاهد الإكمال التلقائي. افهم توقيعات الطرق. إنها حقًا طريقة إنتاجية لكتابة الكود.

نصائح وتعلمات (Tips & Learnings)

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

  • اقرأ خطأ النوع حقًا: سيساعدك هذا على فهم المشكلة والأنواع بشكل أفضل.
  • strictNullChecks و noImplicitAny يمكن أن يكونا مفيدين جدًا في العثور على الأخطاء: قم بتمكينهما في أقرب وقت ممكن في مشروعك.
  • استخدم strictNullChecks لمنع أخطاء وقت التشغيل من نوع “undefined is not an object“:
  • استخدم noImplicitAny لتحديد نوع الكود المصدري لإعطاء المزيد من معلومات النوع للمجمّع:
  • جنبًا إلى جنب مع إعدادات المجمّع، أوصي دائمًا بأن تكون دقيقًا جدًا بشأن أنواعك: خاصة مع القيم التي تحدث فقط في وقت التشغيل مثل استجابة API. الدقة مهمة لاكتشاف أكبر عدد ممكن من الأخطاء في وقت التجميع.
  • افهم الفرق بين وقت التشغيل ووقت التجميع: تؤثر الأنواع فقط في وقت التجميع. يقوم بتشغيل مدقق الأنواع ثم يجمع إلى JavaScript. لا يستخدم الكود المصدري لـ JavaScript أي مراجع للأنواع أو عمليات أنواع.
  • تعرف على أنواع الأدوات المساعدة (utility types): تحدثنا بشكل أكثر تحديدًا عن Readonly في عدم القابلية للتغيير في وقت التجميع، ولكن TypeScript لديه صندوق من المساعدين مثل Required و Pick وغيرها الكثير.
  • إذا أمكن، فضل السماح للمجمّع باستنتاج الأنواع لك: معظم الأنواع وأنواع الإرجاع زائدة عن الحاجة. مجمّع TypeScript ذكي جدًا في هذا المجال.
  • إذا لم يكن ذلك ممكنًا، يمكنك دائمًا إضافة تعليقات الأنواع: واترك تأكيدات الأنواع كخيار أخير.
  • أثناء كتابة الكود، ألقِ نظرة على الأدوات: تصميم الأدوات المتوفرة في IDE مذهل. يوفر IntelliSense وفحص الأنواع تجربة جيدة حقًا.

نُشر هذا المقال في الأصل على مدونة TK. ويمكنك العثور على المزيد من المحتوى المشابه في مدونتي على https://leandrotk.github.io/tk/. يمكنك أيضًا متابعتي على Twitter و GitHub.

المصادر (Resources)

لقد جمعت (بقصد!) مجموعة من المصادر لمساعدتك على تعلم المزيد عن لغات البرمجة، أنظمة الأنواع، ونموذج الأنواع الذهني. أيضًا، إذا وجدت الأمثلة في هذا المنشور مفيدة، فقد أضفتها جميعًا إلى هذا المستودع: Thinking in Types. حتى تتمكن من عمل fork واللعب بها.

أنظمة الأنواع (Type Systems)

القيود (Constraints)

الأدوات وتجربة المطور (Tooling & Developer Experience)

وقت التجميع مقابل وقت التشغيل (Compile time vs Runtime)

أفضل الممارسات (Best Practices)

الكتب (Books)

الخلاصة التقنية

يُعد فهم أنواع TypeScript وتبني النموذج الذهني للتفكير بها حجر الزاوية في بناء تطبيقات ويب قوية وموثوقة. كما أوضح المقال، فإن TypeScript لا يقتصر على إضافة تعليقات الأنواع إلى الكود فحسب، بل يوفر نظامًا بيئيًا كاملاً يساعد المطورين على اكتشاف الأخطاء مبكرًا في وقت التجميع، بدلاً من مواجهتها في وقت التشغيل. إن التمييز بين مساحتي القيمة والنوع، واستخدام تضييق الأنواع، وتكوين الأنواع، والاستفادة من الأدوات المتقدمة، كلها جوانب أساسية تسهم في كتابة كود نظيف، قابل للصيانة، وذو دلالة واضحة.

إن الاستثمار في تعلم TypeScript وتطبيقاته العملية يعود بفوائد جمة على جودة المنتج النهائي وسرعة التطوير، حيث يتحول المجمّع من مجرد أداة لإصدار الأخطاء إلى شريك ذكي يرشد المطور نحو أفضل الممارسات ويحسن من تجربته البرمجية بشكل عام. ولضمان أقصى استفادة، يُنصح دائمًا بالتعمق في وثائق TypeScript الرسمية وتجربة المفاهيم عمليًا في بيئات مثل TypeScript Playground.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *