الواجهات في TypeScript
مقدّمة
تركيز التحقق من الأنواع (type-checking) على شكل (shape) القيم من مبادئ TypeScript الأساسية. يُصطلَح عليه أحيانًا بالتعبير duck typing أو التحقق من الأنواع الفرعيّة هيكليًّا (structural subtyping). تعمل الواجهات في TypeScript على تسمية هذه الأنواع، وهي طريقة قويّة لتعريف عقود (contracts) داخل شيفرتك أو عقود مع شيفرةٍ خارج مشروعك.
واجهة بسيطة
لنبدأ بمثال بسيط لنفهم كيفيّة عمل الواجهات:
function printLabel(labelledObj: { label: string }) {
console.log(labelledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);
سيتحقّق مدقّق الأنواع (type-checker) من استدعاء الدالة printLabel
. تمتلك الدالة printLabel
معاملًا واحدًا يتطلب (require) أن يكون الكائن المرّر محتويًا على خاصيّة باسم label
نوعها سلسلة نصيّة string
. لاحظ أن الكائن الذي مرّرناه إلى الدالة عند استدعائها يملك خاصيّات أكثر من المطلوب، لكن المُترجم (compiler) يتحقّق من أنّ الخاصيات المطلوبة موجودة على الأقل وأنها توافق النوع المطلوب. هناك بعض الحالات التي لا تسمح فيها TypeScript بأن يكون الكائن مختلفًا قليلًا عمّا هو مطلوب (مثل زيادة خاصيّة)، وسنغطي ذلك بعد قليل.
يُمكننا كتابة نفس المثال مجدّدا، لكن هذه المرة سنستخدم واجهةً لوصف متطلب امتلاك خاصيّة باسم label
نوعها سلسلة نصيّة:
interface LabelledValue {
label: string;
}
function printLabel(labelledObj: LabelledValue) {
console.log(labelledObj.label);
}
let myObj = {size: 10, label: "Size 10 Object"};
printLabel(myObj);
يُمكننا الآن استعمال اسم الواجهة LabelledValue
لوصف المتطلب في المثال السابق. ولا يزال هذا المتطلب يُمثّل امتلاك خاصيّة باسم label
نوعها سلسلة نصيّة. لاحظ أننا لم نحتج إلى تحديد أن الكائن الذي سيُمرّر إلى printLabel
يُطبّق (implements) هذه الواجهة كما قد نحتاج إلى ذلك في لغات أخرى. والمهم هنا هو شكل الكائن فقط. إن كان الكائن الذي يُمرّر إلى الدالة متوافقًا مع المتطلبات المحدّدة فسيُسمَح بذلك.
لاحظ كذلك أن التحقق من الأنواع لا يتطلب أن تكون هذه الخاصيات مرتّبة بأي ترتيب محدّد، كل ما يتطلبه هو أن تكون الخاصيات المطلوبة في الواجهة موجودة في الكائن وأنها من النوع المطلوب.
الخاصيات الاختيارية
يُمكن لبعض الخاصيات في الواجهة ألا تكون مطلوبة. بحيث يكون بعضها موجودًا في حالات معيّنة ويُمكن ألا تكون موجودة بتاتًا. هذه الخاصيات الاختيارية شائعة عند إنشاء أنماط مثل "أكياس الخيارات (option bags)" التي يُمرّر فيها كائن إلى دالّة لديها قيم بعض الخاصيات مسبقًا.
إليك مثالًا لهذا النمط:
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): {color: string; area: number} {
let newSquare = {color: "white", area: 100};
if (config.color) {
newSquare.color = config.color;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});
تُكتَب الواجهات ذات خاصيات اختيارية كما تُكتَب الواجهات الأخرى، مع إلحاق المحرف ?
إلى نهاية اسم الخاصيّة الاختياريّة في التصريح.
ما يُميّز الخاصيات الاختياريّة هو أنك تستطيع وصف هذه الخاصيات التي يُمكن لها أن تكون موجودة في نفس الوقت الذي تمنع فيه استعمال خاصيات غير مُعرّفة في الواجهة. على سبيل المثال، لو أخطأنا في كتابة الخاصيّة color
في createSquare
، لحصلنا على رسالة خطأ تُنبّهنا إلى أن اسم الخاصية خطأ:
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
let newSquare = {color: "white", area: 100};
if (config.clor) {
// Error: Property 'clor' does not exist on type 'SquareConfig'
// خطأ: الخاصية
// 'clor'
// غير موجودة في النوع
// 'SquareConfig'
newSquare.color = config.clor;
}
if (config.width) {
newSquare.area = config.width * config.width;
}
return newSquare;
}
let mySquare = createSquare({color: "black"});
الخاصيّات القابلة للقراءة فقط (Readonly properties)
يجب على بعض الخاصيّات أن تكون قابلة للتغيير فقط عندما يُنشئ الكائن في أوّل مرّة. يُمكنك تحديد الخاصيات على أنها غير قابلة للتغيير، وأنها قابلة للقراءة فقط عبر وضع الكلمة المفتاحيّة readonly
قبل اسم الخاصيّة:
interface Point {
readonly x: number;
readonly y: number;
}
يُمكنك بناء كائن من النوع Point
عبر تعيين قيمة كائن حرفيّة (object literal). لا يُمكن تغيير قيمتي x
وy
بعد التعيين.
let p1: Point = { x: 10, y: 20 };
p1.x = 5; // خطأ، لا يُمكنك تعيين قيمة جديدة لخاصيةٍ قابلة للقراءة فقط
تُوفِّر TypeScript النوع ReadonlyArray<T>
المُشابه للنوع Array<T>
مع حذف جميع التوابع المُغيّرة (mutating methods)، لذا يُمكنك التيقّن من عدم تغيير المصفوفات بعد إنشائها:
let a: number[] = [1, 2, 3, 4];
let ro: ReadonlyArray<number> = a;
ro[0] = 12; // خطأ
ro.push(5); // خطأ
ro.length = 100; // خطأ
a = ro; // خطأ
يُمكنك أن تُلاحظ في آخر سطر أعلاه أنّ إعادة تعيين كامل المصفوفة من النوع ReadonlyArray
إلى مصفوفة عاديّة لا يجوز. لكنّك لا تزال تستطيع استبدالها باستعمال إثبات نوع (type assertion):
a = ro as number[];
readonly
مقابل const
أسهل طريقة لتذكّر متى يجب استعمال readonly
مقابل const
هو أن تسأل ما إذا كنت تعمل مع متغيّر أو خاصيّة. المتغيرات تستعمل const
والخاصيات تستعمل readonly
.
التحقق من الخاصيات الزائدة (Excess Property Checks)
في مثال الواجهات الأول أعلاه، سمحت لنا TypeScript بتمرير { size: number; label: string; }
إلى شيء لم يتوقّع إلا خاصية ذات المواصفات { label: string; }
. وتعرّفنا قبل قليل على الخاصيات الاختيارية وكيف أنها مفيدة عند وصف ما يُطلق عليه بأكياس الخيارات.
لكن دمج كلا المبدأين دون تفكير سيؤدي إلى وجع رأس كما قد يحدث لو كنت تستخدم JavaScript. مثلًا، خذ مثال createSquare
السابق:
interface SquareConfig {
color?: string;
width?: number;
}
function createSquare(config: SquareConfig): { color: string; area: number } {
// ...
}
let mySquare = createSquare({ colour: "red", width: 100 });
لاحظ أنّ المعامل المرر للدالة createSquare
مكتوب على شكل colour
عوضًا عن color
. ستمرّ علّة كهذه في JavaScript بصمت دون أي إنذار.
قد تقول بأن هذا البرنامج صحيح لأن خاصيتا width
متوافقتان، ولا يوجد خاصية باسم color
، والخاصية colour
الزائدة لن تؤثّر.
لكنّ TypeScript تعتقد بأن هناك علّة في هذه الشيفرة. إذ أن قيم الكائنات الحرفيّة (object literals) تمرّ عبر تحقّق من الخاصيات الزائدة عند تعيينها لمتغيّرات أخرى، أو عند تمريرها كمُعاملات. إن كانت قيمة كائن حرفيّة تملك خاصيّة لا يملكها النوع الهدف (target type)، فستحصل على خطأ:
// خطأ، الخاصيّة
// 'colour'
// غير مُتوقَّعة في النوع
// 'SquareConfig'
let mySquare = createSquare({ colour: "red", width: 100 });
تخطّي التحقق من الخاصيات الزائدة هذا بسيط للغاية. أسهل طريقة لفعل ذلك هو باستخدام إثبات نوع (type assertion) كما يلي:
let mySquare = createSquare({ width: 100, opacity: 0.5 } as SquareConfig);
لكن قد تكون هناك طريقة أفضل إذا كنت متأكّدًا من أن الكائن قد يملك خاصيّات زائدة ستُستعمل بشكل خاص، وذلك عبر إضافة توقيع فهرس سلسلة نصيّة (string index signature) إن أمكن للواجهة SquareConfig
أن تملك الخاصيتان color
وwidth
ذات الأنواع أعلاه، لكن قد تملك كذلك أي عدد من الخاصيات الأخرى، عندها يُمكننا تعريف الواجهة كما يلي:
interface SquareConfig {
color?: string;
width?: number;
[propName: string]: any;
}
سنتحدث عن توقيعات الفهرس بعد قليل، المهم أنّنا نقوم هنا بتحديد أن SquareConfig
قد تملك أي عدد من الخاصيات، وما دام أنها ليست color
أو width
، فنوعها لا يهم.
أخيرًا، يُمكن تعيين الكائن إلى متغيّر آخر لتجاوز هذا التحقق، وهي طريقة قد تكون مفاجأة قليلًا، لأنّ squareOptions
لن يمرّ بالتحقق من الخاصيات الزائدة، والمترجم (compiler) لن يطلق أي خطأ:
let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);
تذكّر بأنه لا يتوجّب عليك ربما أن تتجاوز هذه التحقّقات في حالة كانت الشيفرة بسيطة كما في المثال أعلاه. لكن قد تحتاج إلى هذه التقنيات عند التعامل مع كائنات معقّدة تملك توابع وتحمل حالة (state)، لكن معظم أخطاء الخاصيات الزائدة تكون عللًا في حقيقة الأمر. هذا يعني بأنّك إذا حدث وأن حصلت على مثل هذه الأخطاء عند الاعتماد على أكياس الخيارات أو ما شابه، فقد تحتاج إلى مراجعة تصريحات الأنواع (type declarations) الخاصة بك. في هذه الحالة، إن لم يُسبّب تمرير كائن ذو خاصيّة باسم color
أو colour
إلى createSquare
أية مشاكل، فعليك إصلاح تعريف SquareConfig
لتُوضّح ذلك.
أنواع الدوال (Function Types)
يُمكن للواجهات أن تصف نطاقًا واسعًا من الأشكال التي قد تكون عليها كائنات JavaScript. وتصف الواجهات أنواع الدوال إضافة لوصف كائن وخاصياته.
لوصف نوع دالة باستخدام واجهة، نمنح للواجهة توقيع استدعاء (call signature). وهو شبيه بالتصريح عن دالة (function declaration) مع منح قائمة المعاملات ونوع القيمة المعادة فقط. يتطلب كل معامل في قائمة المعاملات اسمًا ونوعًا:
interface SearchFunc {
(source: string, subString: string): boolean;
}
بعد تعريف واجهة نوع الدالة (function type interface) هذه، يُمكننا استعمالها كما نستعمل أي واجهة أخرى. المثال التالي يُوضّح كيفيّة إنشاء متغيّر يحمل نوع دالة وتعيينه لقيمة دالة (function value) من نفس النوع:
let mySearch: SearchFunc;
mySearch = function(source: string, subString: string) {
let result = source.search(subString);
return result > -1;
}
لا تحتاج أسماء المعاملات إلى أن تكون متطابقة ليُتحقّق من أنواع الدوال بشكل صحيح. كان يُمكننا مثلا أن نكتب المثال أعلاه بالشكل التالي:
let mySearch: SearchFunc;
mySearch = function(src: string, sub: string): boolean {
let result = src.search(sub);
return result > -1;
}
يُتحقَّقُ من معاملات الدوال واحدًا بواحد، ويُتحقّق من النوع في موقع المعامل مع نوع المعامل المقابل له. إن لم ترغب بتحديد الأنواع، يُمكن للتحقق من الأنواع السياقيّ (contextual typing) في TypeScript أن يستنتج أنواع المعاملات لأن قيمة الدالة قد عُيِّنت مباشرة لمتغيّر من النوع SearchFunc
. ونوع القيمة المعادة في تعبير الدالة هنا يُقرَّر من القيمة التي تُعيدها (القيمة false
أو القيمة true
في هذه الحالة). لو أعاد تعبير الدالة عددًا أو سلسلة نصيّة، لنبّهَنا مُدقّق الأنواع بأن القيمة المعادة لا توافق القيمة المعادة الموصوفة في الواجهة SearchFunc
.
let mySearch: SearchFunc;
mySearch = function(src, sub) {
let result = src.search(sub);
return result > -1;
}
الأنواع القابلة للفهرسة (Indexable Types)
يُمكننا بطريقة مُشابهة وصفُ الأنواع التي يُمكننا استعمال الفهرسة معها مثل a[10]
أوageMap["daniel"]
. تملك الأنواع القابلة للفهرسة توقيع فهرس (index signature) يصف الأنواع التي يُمكننا استعمالها لفهرسة (index into) الكائن، إضافةً إلى الأنواع المعادة عند الفهرسة. لنلق نظرة على المثال التالي:
interface StringArray {
[index: number]: string;
}
let myArray: StringArray;
myArray = ["Bob", "Fred"];
let myStr: string = myArray[0];
لدينا في المثال أعلاه واجهة باسم StringArray
ذات توقيع فهرس. يُشير توقيع الفهرس هذا بأنّه إن استخدمت الفهرسة مع مصفوفة من النوع StringArray
باستعمال عددٍ (number
)، فستعيد سلسلةً نصيّة (string
).
هناك نوعان من توقيعات الفهارس: السلاسل النصيّة والأعداد. من الممكن دعم كلا نوعي المُفهرسات (indexers)، لكن يجب على النوع المُعاد من مُفهرس عددي أن يكون نوعًا فرعيًّا من النوع المعاد من مُفهرس السلسلة النصيّة (string indexer). هذا لأنه عند الفهرسة بعدد (number
)، فستُحوّله لغة JavaScript إلى سلسلة نصيّة (string
) قبل فهرسة الكائن. هذا يعني بأن الفهرسة بالعدد 100
هي نفسها الفهرسة باستخدام السلسلة النصيّة "100"
، لذا يجب على كليهما أن يتناسقا:
class Animal {
name: string;
}
class Dog extends Animal {
breed: string;
}
// خطأ، الفهرسة بسلسلة نصيّة عددية قد يجلب نوعًا مختلفًا من الصنف
// Animal
interface NotOkay {
[x: number]: Animal;
[x: string]: Dog;
}
ورغم أن توقيعات الفهرس النصيّة (string index signatures) طريقة مُجدية لوصف نمط القواميس (dictionary)، إلا أنها تقضي بوجوب توافق جميع الخاصيات مع نوع القيمة المعادة الخاصة بها. هذا لأن الفهرس النصي (string index) يُصرّح بأنّ obj.property
متاح كذلك على الشكل obj["property"]
.
في المثال التالي، نوع name
لا يوافق نوع الفهرس النصي، لذا فمدقّق الأنواع يُطلق خطأ:
interface NumberDictionary {
[index: string]: number;
length: number; // الطول عددٌ، وهذا مسموح به
// error, the type of 'name' is not a subtype of the indexer
// خطأ، نوع
// 'name'
// ليس نوعًا فرعيًّا للمُفهرس
name: string;
}
أخيرًا، يُمكنك جعل توقيعات الفهرس قابلة للقراءة فقط (readonly) لمنع تعيين قيم لفهارسها:
interface ReadonlyStringArray {
readonly [index: number]: string;
}
let myArray: ReadonlyStringArray = ["Alice", "Bob"];
myArray[2] = "Mallory"; // خطأ
لا يُمكنك تعيين قيمة للعنصر myArray[2]
لأن توقيع الفهرس قابل للقراءة فقط.
أنواع الأصناف (Class Types)
تطبيق واجهة (Implementing an interface)
اقتضاءُ وجوب توافق صنفٍ مع عقد (contract) معيّن من الاستعمالات الشائعة للواجهات في لغات مثل C# وJava، وهذا ممكن كذلك في TypeScript.
interface ClockInterface {
currentTime: Date;
}
class Clock implements ClockInterface {
currentTime: Date;
constructor(h: number, m: number) { }
}
يُمكن كذلك وصف التوابع في الواجهة التي يُطبقها الصنف، كما هو موضح بالتابع setTime
في المثال التالي:
interface ClockInterface {
currentTime: Date;
setTime(d: Date);
}
class Clock implements ClockInterface {
currentTime: Date;
setTime(d: Date) {
this.currentTime = d;
}
constructor(h: number, m: number) { }
}
تصف الواجهات الجانب العموميّ (public side) من الصنف عوضًا عن كلا الجانبين العمومي والخاص (private side). ما يمنع من استعمال الواجهات للتحقّق من أن صنفًا يملك كذلك نوعًا معيّنًا على الجانب الخاص من نسخة الصنف (the class instance).
الفرق بين الجانب الساكن (static) وجانب النّسخة (instance) للأصناف
عند العمل مع الأصناف والواجهات، من المُفيد تذكّر أنّ للصّنف نوعين مختلفين: نوع الجانب الساكن ونوع جانب النسخة. قد تُلاحظ بأنّك إن أنشأت واجهة بتوقيع بِناء (construct signature) وحاولت إنشاء صنف يُطبّق هذه الواجهة فستحصل على خطأ:
interface ClockConstructor {
new (hour: number, minute: number);
}
class Clock implements ClockConstructor {
currentTime: Date;
constructor(h: number, m: number) { }
}
هذا لأنّه عندما يُطبّق صنف واجهة معيّنة، فلا يُتحقَّقُ إلا من جانب النسخة. ولأن البنّاء (constructor) موجود على الجانب الساكن فلن يشمله هذا التحقّق.
ستحتاج عوضًا عن هذا إلى العمل مع الجانب الساكن من الصنف مباشرةً. في هذا المثال، نُعرّف واجهتين، الأولى ClockConstructor
للبنّاء وClockInterface
لتوابع النسخة (instance methods). بعدها نُعرّف دالّة بانيّة (constructor function) باسم createClock
لتسهيل إنشاء نسخ من النوع المُمرّر إليها.
interface ClockConstructor {
new (hour: number, minute: number): ClockInterface;
}
interface ClockInterface {
tick();
}
function createClock(ctor: ClockConstructor, hour: number, minute: number): ClockInterface {
return new ctor(hour, minute);
}
class DigitalClock implements ClockInterface {
constructor(h: number, m: number) { }
tick() {
console.log("beep beep");
}
}
class AnalogClock implements ClockInterface {
constructor(h: number, m: number) { }
tick() {
console.log("tick tock");
}
}
let digital = createClock(DigitalClock, 12, 17);
let analog = createClock(AnalogClock, 7, 32);
بسبب أنّ معامل الدالة createClock
الأول من النوع ClockConstructor
، فسيُتحقَّق من أنّ AnalogClock
يمتلك توقيع بنّاء صحيحًا عند الاستدعاء createClock(AnalogClock, 7, 32)
.
توسيع الواجهات
يُمكن للواجهات أن تُوسّع (extend) بعضها البعض مثل الأصناف. يسمح هذا بنسخ عناصر واجهة إلى أخرى، ما يُعطي مرونة أكثر في كيفيّة تقسيم واجهاتك إلى مكونات يُمكن إعادة استعمالها.
interface Shape {
color: string;
}
interface Square extends Shape {
sideLength: number;
}
let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
يُمكن لواجهة أن تُوسّع عدّة واجهات، مُنشِأةً مزيجًا من جميع الواجهات التي توسّعها:
interface Shape {
color: string;
}
interface PenStroke {
penWidth: number;
}
interface Square extends Shape, PenStroke {
sideLength: number;
}
let square = <Square>{};
square.color = "blue";
square.sideLength = 10;
square.penWidth = 5.0;
الأنواع الهجينة (Hybrid Types)
يُمكن للواجهات -كما سبق ذكره- أن تصف الأنواع الغنيّة الموجودة في لغة JavaScript على أرض الواقع. وبسبب طبيعة JavaScript الديناميكيّة والمرنة، فقد تجد أحيانًا كائنًا يعمل كمزيج من بعض الأنواع الموصوفة أعلاه.
الكائنات التي تعمل مثل دالة وكائن في نفس الوقت من الأمثلة على ذلك، إضافةً إلى خاصيات إضافيّة.
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}
function getCounter(): Counter {
let counter = <Counter>function (start: number) { };
counter.interval = 123;
counter.reset = function () { };
return counter;
}
let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;
قد تحتاج إلى استعمال أنماط مثل النمط أعلاه عند استعمال شيفرة JavaScript من طرف ثالث (3rd-party)لوصف شكل النوع كاملًا.
توسيع الأصناف بالواجهات
عندما يُوسّع نوعُ واجهةٍ (interface type) نوعَ صنفٍ (class type)، فسيرث (inherit) عناصر الصنف لكنّه لن يرث الواجهات التي يُطبّقها الصنف. وكأنّ الواجهة تُعرّف جميع عناصر الصنف دون توفير أي تطبيق (implementation). ترث الواجهات حتى عناصر الصنف الأساس التي تكون خاصّة (private) أو محميّة (protected). ما يعني أنه عند إنشاء واجهة تُوسّع صنفا ذا عناصر خاصّة أو محميّة، فنوع الواجهة قابل لتطبيقه فقط من هذا الصنف أو صنف فرعي له.
هذا مُفيد إن أردت إنشاء شجرة وراثة (inheritance hierarchy) كبيرة وأردت أن تعمل الشيفرة فقط مع الأصناف الفرعيّة ذات خاصيّات مُعيّنة. ولا تحتاج الأصناف الفرعيّة إلى أن تكون مرتبطةً مع بعضها، المهم فقط أن ترث من الصنف الأساس. مثلًا:
class Control {
private state: any;
}
interface SelectableControl extends Control {
select(): void;
}
class Button extends Control implements SelectableControl {
select() { }
}
class TextBox extends Control {
select() { }
}
// خطأ، الخاصيّة
// 'state'
// مفقودة في النوع
// 'Image'
class Image implements SelectableControl {
select() { }
}
class Location {
}
في المثال أعلاه، يحتوي SelectableControl
على جميع عناصر Control
، بما فيها الخاصيّة الخاصّة state
. ولأنّ state
عنصر خاصّ فمن الممكن فقط لأولاد (descendants) الصنف Control
أن يُطبّقوا الواجهة SelectableControl
. وهذا لأن أولاد Control
هم فقط من سيملك عنصر state
خاصًّا نابعًا من نفس التصريح، والذي يُعدّ متطلبًا لتكون العناصر الخاصّة متوافقة.
من الممكن الوصول إلى العنصر الخاصّ state
داخل الصنف Control
عبر نسخة من SelectableControl
. عمليًّا، يتصرّف SelectableControl
مثل صنف Control
معروفٍ بأنّه يملك تابعًا باسم select
. الصّنف Button
والصّنف TextBox
نوعان فرعيّان من SelectableControl
(لأنّ كلاهما يرث من Control
ويملك تابعًا باسم select
)، لكنّ الصنفين Image
وLocation
ليسا نوعين فرعيّين منها.