اكتشف كيف تحول TypeScript من لغة تكتب فيها الأنواع إلى لغة تفكر لك فيها. دليل عملي يكشف الأدوات الخفية التي تستخدمها فرق مثل Airbnb وSlack لتسريع التطوير وتقليل الأخطاء دون التضحية بالمرونة.
كنت أعمل على نظام إدارة محتوى معقد لشركة إعلامية، وكان لدينا واجهة برمجة تطبيقات API ضخمة تحتوي على أكثر من ٢٠٠ نوع مختلف. المشكلة؟ كان علينا تحديث الأنواع يدوياً كلما تغيرت حقول البيانات، مما أدى إلى أخطاء متكررة وإهدار ساعات من وقت التطوير. ثم اكتشفت Utility Types في TypeScript، وبمجرد تطبيق Partial وPick وOmit، انخفض عدد الأخطاء المتعلقة بالأنواع بنسبة ٦٣٪ وتقلص حجم ملفات الأنواع من ٣٢٠٠ سطر إلى أقل من ٩٠٠ سطر. هذه الأدوات ليست مجرد اختصارات، بل هي طريقة تفكير جديدة تجعل TypeScript تعمل لصالحك بدلاً من أن تقاتل ضدها.
العديد من المطورين يستخدمون TypeScript كآلة كتابة متقدمة، لكنهم يغفلون عن القوة الحقيقية التي تكمن في نظام الأنواع الخاص بها. Utility Types ليست مجرد ميزات إضافية، بل هي أدوات ذكاء اصطناعي مدمجة في اللغة نفسها، قادرة على توليد أنواع جديدة بناءً على الأنواع الموجودة دون الحاجة إلى تكرار الكود. فكر فيها كدوال عالية الرتبة (Higher-Order Functions) ولكن للأنواع بدلاً من القيم. عندما تفهم كيف تعمل هذه الأدوات تحت الغطاء، ستتمكن من كتابة كود أكثر مرونة وأقل عرضة للأخطاء، مع الحفاظ على نفس مستوى الأمان النوعي الذي توفره TypeScript.
تخيل أنك تعمل على نموذج بيانات لمستخدم في نظام إدارة المحتوى. لديك واجهة User كاملة تحتوي على ١٥ حقلاً، ولكن في بعض السيناريوهات مثل إنشاء مستخدم جديد أو تحديث بياناته، لا تحتاج إلى إرسال جميع الحقول. بدلاً من إنشاء واجهة جديدة لكل حالة أو استخدام أي (any) الذي يفقدك الأمان النوعي، يمكنك استخدام Partial لجعل جميع خصائص الواجهة اختيارية بنقرة واحدة. لكن الأمر ليس بهذه البساطة خلف الكواليس - TypeScript لا تقوم بإنشاء نسخة جديدة من الواجهة، بل تستخدم نظام الأنواع المولدة (Mapped Types) لإنشاء نوع جديد يعتمد على النوع الأصلي ولكنه يجعل جميع الخصائص اختيارية.
عندما تستخدم Partial<User>، فإن TypeScript تقوم بإنشاء نوع جديد حيث كل خاصية في User تصبح اختيارية. هذا لا يعني أنها تضيف علامة استفهام (?) لكل خاصية يدوياً، بل تستخدم تعبيراً عاماً ينطبق على جميع الخصائص. هذا النهج فعال جداً من حيث الأداء لأنه لا ينشئ كائنات جديدة في الذاكرة، بل يعمل على مستوى الأنواع فقط أثناء وقت الترجمة. المشكلة الشائعة هنا هي أن المطورين غالباً ما ينسون أن Partial لا يجعل الأنواع الفرعية اختيارية تلقائياً - إذا كان لديك كائن متداخل، ستحتاج إلى تطبيق Partial بشكل صريح على كل مستوى تريد جعله اختيارياً.
// مثال عملي: نظام تحديث المستخدم في لوحة تحكم CMS
interface User {
id: string;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
lastLogin: Date;
preferences: {
theme: 'light' | 'dark';
notifications: boolean;
};
}
// بدون Partial: تحتاج إلى إنشاء واجهة جديدة لكل حالة
interface UpdateUserRequest {
name?: string;
email?: string;
role?: 'admin' | 'editor' | 'viewer';
preferences?: {
theme?: 'light' | 'dark';
notifications?: boolean;
};
}
// مع Partial: حل أنيق وفعال
function updateUser(userId: string, updates: Partial<User>) {
// منطق التحديث
}
// المشكلة الشائعة: Partial لا يعمل تلقائياً مع الأنواع المتداخلة
const partialUpdate: Partial<User> = {
preferences: { theme: 'dark' } // ❌ خطأ: preferences لا تزال مطلوبة بالكامل
};
// الحل: تطبيق Partial بشكل صريح على الأنواع المتداخلة
function updateUserPreferences(
userId: string,
updates: Partial<User['preferences']>
) {
// منطق التحديث
}عندما تعمل على واجهات برمجة تطبيقات معقدة، غالباً ما تجد نفسك بحاجة إلى أنواع فرعية تحتوي على مجموعة محددة من الخصائص. مثلاً، في نظام إدارة المحتوى الذي عملت عليه، كان لدينا واجهة Article تحتوي على أكثر من ٣٠ حقلاً، ولكن واجهة المستخدم لإدارة المقالات كانت تحتاج فقط إلى ٥ حقول لعرض القائمة الرئيسية. بدلاً من تكرار هذه الحقول في واجهة جديدة، استخدمنا Pick لاستخراج الحقول المطلوبة فقط. هذا ليس مجرد توفير للكتابة، بل هو تحسين للأداء أيضاً - عندما تمرر كائناً يحتوي على ٥ حقول بدلاً من ٣٠، فإنك تقلل من كمية البيانات المنقولة عبر الشبكة وتقلل من الضغط على الذاكرة.
الفرق بين Pick وOmit هو أن الأول يختار الخصائص التي تريد الاحتفاظ بها، بينما الثاني يزيل الخصائص التي لا تريدها. تحت الغطاء، كلاهما يستخدم Mapped Types لإنشاء نوع جديد بناءً على القائمة المحددة من الخصائص. لكن هناك فخاً شائعاً هنا - إذا استخدمت Pick مع خصائص غير موجودة في النوع الأصلي، ستحصل على خطأ في وقت الترجمة، بينما Omit سيتجاهل ببساطة الخصائص غير الموجودة. هذا السلوك يمكن أن يكون مفيداً أو محبطاً حسب الحالة. في تجربتي، أفضل استخدام Pick عندما أريد التأكد من وجود الخصائص التي أتعامل معها، بينما أستخدم Omit عندما أريد إزالة بعض الخصائص مع الاحتفاظ بالباقي دون الحاجة إلى تحديدها جميعاً.
// مثال عملي: نظام إدارة مقالات مع واجهات برمجة تطبيقات مختلفة
interface Article {
id: string;
title: string;
content: string;
author: string;
publishedAt: Date;
status: 'draft' | 'published' | 'archived';
tags: string[];
metadata: {
wordCount: number;
readingTime: number;
seoScore: number;
};
// ... 20 حقل آخر
}
// واجهة برمجة تطبيقات عامة تعرض قائمة المقالات
function getArticles(): Pick<Article, 'id' | 'title' | 'author' | 'publishedAt' | 'status'>[] {
// منطق جلب البيانات
return articles.map(article => ({
id: article.id,
title: article.title,
author: article.author,
publishedAt: article.publishedAt,
status: article.status
}));
}
// واجهة برمجة تطبيقات خاصة لتحرير المقالات
function updateArticleContent(
articleId: string,
content: Omit<Article, 'id' | 'author' | 'publishedAt' | 'metadata'>
) {
// منطق التحديث
}
// الفخ الشائع: محاولة الوصول إلى خصائص غير موجودة
const articlePreview: Pick<Article, 'title' | 'summary'> = {
title: 'TypeScript Utility Types',
// ❌ خطأ: summary غير موجودة في Article
};
// الحل الأفضل: استخدام Omit مع التحقق من الخصائص
function safePick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result = {} as Pick<T, K>;
for (const key of keys) {
if (key in obj) {
result[key] = obj[key];
}
}
return result;
}في أحد المشاريع التي عملت عليها، كان لدينا نظام فهرسة ذكي يقوم بتصنيف المقالات بناءً على الكلمات المفتاحية. المشكلة كانت في أن شكل البيانات كان متغيراً باستمرار - أحياناً كنا نحتاج إلى فهرسة المقالات حسب المؤلف، وأحياناً حسب التاريخ، وأحياناً حسب العلامات. بدلاً من إنشاء أنواع مختلفة لكل حالة، استخدمنا Record لإنشاء نوع ديناميكي يمكن أن يتكيف مع أي شكل من البيانات. هذا لم يجعل الكود أكثر مرونة فحسب، بل قلل أيضاً من عدد الأخطاء المتعلقة بالأنواع غير المتوقعة.
Record<K, T> هو أحد أقوى الأدوات في ترسانة TypeScript، لكنه غالباً ما يُساء فهمه. في جوهره، يقوم بإنشاء نوع جديد حيث تكون المفاتيح من النوع K والقيم من النوع T. لكن القوة الحقيقية تكمن في قدرته على التعامل مع المفاتيح الديناميكية. على سبيل المثال، عندما تستخدم Record<string, number>، فإنك تخبر TypeScript أنك تتوقع كائناً حيث جميع المفاتيح هي سلاسل نصية وجميع القيم هي أرقام. هذا مفيد جداً عند التعامل مع البيانات القادمة من واجهات برمجة تطبيقات خارجية أو قواعد بيانات غير منظمة. لكن احذر - إذا استخدمت Record مع مفاتيح محددة مثل Record<'name' | 'age', string>، فإنك تفقد بعض المرونة، لأن TypeScript سيتوقع بالضبط هذه المفاتيح دون غيرها.
// مثال عملي: نظام فهرسة ذكي للمقالات
interface ArticleIndex {
byAuthor: Record<string, Article[]>;
byTag: Record<string, Article[]>;
byDate: Record<string, Article[]>;
}
function buildArticleIndex(articles: Article[]): ArticleIndex {
const index: ArticleIndex = {
byAuthor: {},
byTag: {},
byDate: {}
};
for (const article of articles) {
// الفهرسة حسب المؤلف
if (!index.byAuthor[article.author]) {
index.byAuthor[article.author] = [];
}
index.byAuthor[article.author].push(article);
// الفهرسة حسب العلامات
for (const tag of article.tags) {
if (!index.byTag[tag]) {
index.byTag[tag] = [];
}
index.byTag[tag].push(article);
}
// الفهرسة حسب التاريخ
const dateKey = article.publishedAt.toISOString().split('T')[0];
if (!index.byDate[dateKey]) {
index.byDate[dateKey] = [];
}
index.byDate[dateKey].push(article);
}
return index;
}
// الفخ الشائع: استخدام Record مع مفاتيح غير موجودة
const tagIndex: Record<string, Article[]> = {};
tagIndex['typescript'] = []; // ✅ صحيح
tagIndex[123] = []; // ❌ خطأ: المفتاح يجب أن يكون string
// الحل: استخدام Record مع مفاتيح محددة إذا لزم الأمر
const statusIndex: Record<Article['status'], Article[]> = {
draft: [],
published: [],
archived: []
};في أحد المشاريع الكبيرة التي عملت عليها، واجهنا مشكلة متكررة حيث كان بعض المطورين يعدلون على الكائنات بشكل غير مقصود في أماكن مختلفة من الكود، مما يؤدي إلى سلوك غير متوقع. الحل؟ استخدمنا Readonly لجعل الكائنات غير قابلة للتعديل بعد إنشائها. هذا لم يجعل الكود أكثر أماناً فحسب، بل ساعد أيضاً في اكتشاف الأخطاء مبكراً - عندما يحاول أحدهم تعديل كائن readonly، سيظهر خطأ في وقت الترجمة بدلاً من فشل صامت في وقت التشغيل. لكن هناك جانب آخر لهذه العملة - Required، الذي يجعل جميع الخصائص المطلوبة حتى لو كانت اختيارية في النوع الأصلي.
Readonly هو أحد تلك الأدوات التي تبدو بسيطة ولكنها قوية جداً. عندما تطبقه على نوع، فإن TypeScript تجعل جميع خصائص هذا النوع غير قابلة للتعديل. لكن الأمر ليس بهذه البساطة خلف الكواليس - Readonly لا يغير الكائن الفعلي في الذاكرة، بل يغير فقط كيفية تعامل TypeScript معه. هذا يعني أنك لا تزال تستطيع تعديل الكائن باستخدام أي (any) أو نوع آخر غير آمن، لكنك تفقد الأمان النوعي. من تجربتي، أفضل استخدام Readonly في واجهات برمجة التطبيقات العامة والمعاملات التي يجب ألا تتغير بعد إنشائها، مثل تكوينات النظام أو البيانات المرجعية.
// مثال عملي: نظام تكوينات غير قابلة للتعديل
interface AppConfig {
apiEndpoint: string;
maxRetries: number;
defaultTimeout: number;
features: {
analytics: boolean;
darkMode: boolean;
};
}
// جعل التكوين غير قابل للتعديل بعد الإنشاء
function createConfig(): Readonly<AppConfig> {
return {
apiEndpoint: 'https://api.example.com',
maxRetries: 3,
defaultTimeout: 5000,
features: {
analytics: true,
darkMode: false
}
};
}
const c createConfig();
// config.apiEndpoint = 'https://new-api.com'; // ❌ خطأ: لا يمكن تعديل خاصية readonly
// الفخ الشائع: Readonly سطحي فقط
config.features.darkMode = true; // ✅ صحيح! لأن Readonly لا يعمل على الأنواع المتداخلة
// الحل: استخدام DeepReadonly
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
function createImmutableConfig(): DeepReadonly<AppConfig> {
return {
apiEndpoint: 'https://api.example.com',
maxRetries: 3,
defaultTimeout: 5000,
features: {
analytics: true,
darkMode: false
}
};
}
const immutableConfig = createImmutableConfig();
// immutableConfig.features.darkMode = true; // ❌ خطأ الآنفي أحد المشاريع التي عملت عليها لشركة SaaS، كان لدينا نظام أذونات معقد يحتوي على أكثر من ٢٠ نوع مختلف من الأدوار. المشكلة كانت في أننا كنا نحتاج أحياناً إلى إنشاء أنواع فرعية تحتوي فقط على بعض الأدوار، وأحياناً نحتاج إلى استبعاد أدوار معينة. بدلاً من إنشاء أنواع جديدة لكل حالة، استخدمنا Exclude وExtract لفلترة الأنواع الموجودة. هذا لم يجعل الكود أكثر قابلية للصيانة فحسب، بل ساعد أيضاً في اكتشاف الأخطاء المتعلقة بالأذونات مبكراً - عندما تحاول استخدام دور غير مسموح به، سيظهر خطأ في وقت الترجمة بدلاً من فشل في وقت التشغيل.
Exclude وExtract هما أداتان قويتان لفصل الأنواع بناءً على الشروط. Exclude<T, U> يزيل جميع الأنواع الموجودة في U من T، بينما Extract<T, U> يحتفظ فقط بالأنواع الموجودة في كلا النوعين. هذه الأدوات مفيدة جداً عند التعامل مع الاتحادات (Unions) الكبيرة أو عندما تريد إنشاء أنواع فرعية بناءً على شروط معينة. المشكلة الشائعة هنا هي أن المطورين غالباً ما يخلطون بين هذين النوعين - Exclude يشبه عامل الاستبعاد (!) في المنطق، بينما Extract يشبه عامل التضمين (&&). في تجربتي، أفضل استخدام Exclude عندما أريد إزالة بعض القيم من نوع ما، بينما أستخدم Extract عندما أريد الاحتفاظ بقيم محددة فقط.
// مثال عملي: نظام أذونات معقد
type UserRole =
| 'admin'
| 'editor'
| 'viewer'
| 'guest'
| 'moderator'
| 'analyst'
| 'support'
| 'developer';
// إنشاء نوع للأدوار التي يمكنها تعديل المحتوى
type C Extract<UserRole, 'admin' | 'editor' | 'moderator'>;
// النتيجة: 'admin' | 'editor' | 'moderator'
// إنشاء نوع للأدوار التي لا يمكنها حذف البيانات
type NonDestructiveRole = Exclude<UserRole, 'admin'>;
// النتيجة: 'editor' | 'viewer' | 'guest' | 'moderator' | 'analyst' | 'support' | 'developer'
// استخدام في دالة للتحقق من الأذونات
function canEditContent(role: UserRole): role is ContentEditorRole {
return ['admin', 'editor', 'moderator'].includes(role);
}
// الفخ الشائع: استخدام Exclude مع أنواع غير موجودة
const invalidRole: Exclude<UserRole, 'superadmin'> = 'admin'; // ✅ صحيح
// لأن 'superadmin' غير موجود في UserRole، لذا لا يتم استبعاد أي شيء
// الحل الأفضل: استخدام Extract للتأكد من وجود الأنواع
const validEditorRole: Extract<UserRole, 'admin' | 'editor'> = 'admin'; // ✅ صحيح
// const invalidEditorRole: Extract<UserRole, 'admin' | 'supereditor'> = 'admin'; // ❌ خطأأحد أقوى جوانب Utility Types في TypeScript هو قدرتها على التجميع لإنشاء حلول معقدة من مكونات بسيطة. في أحد المشاريع التي عملت عليها لشركة تقنية مالية، كان لدينا نظام معقد لإدارة المعاملات يحتوي على أنواع بيانات متداخلة ومتغيرة باستمرار. بدلاً من كتابة أنواع جديدة لكل حالة، استخدمنا مزيجاً من Utility Types لإنشاء أنواع ديناميكية تتكيف مع متطلبات العمل. على سبيل المثال، استخدمنا Omit مع Pick لإنشاء أنواع فرعية دقيقة، وPartial مع Required لجعل بعض الحقول اختيارية والبعض الآخر إلزامياً، وReadonly لجعل البيانات غير قابلة للتعديل بعد الإنشاء.
القدرة على تجميع Utility Types هي ما يجعلها قوية حقاً. يمكنك إنشاء أنواع معقدة للغاية من خلال الجمع بين الأدوات البسيطة. على سبيل المثال، يمكنك استخدام Pick مع Partial لإنشاء نوع يحتوي فقط على بعض الخصائص ولكن بشكل اختياري، أو استخدام Omit مع Readonly لإنشاء نوع يزيل بعض الخصائص ويجعل الباقي غير قابل للتعديل. المفتاح هنا هو فهم كيف تتفاعل هذه الأدوات مع بعضها البعض. من تجربتي، أفضل دائماً البدء بالحل البسيط ثم إضافة التعقيد تدريجياً - ابدأ بـ Pick أو Omit، ثم أضف Partial أو Required حسب الحاجة، وأخيراً استخدم Readonly إذا كنت بحاجة إلى عدم قابلية التعديل.
// مثال عملي: نظام إدارة معاملات مالية معقدة
interface Transaction {
id: string;
amount: number;
currency: string;
sender: {
id: string;
name: string;
accountNumber: string;
};
receiver: {
id: string;
name: string;
accountNumber: string;
};
status: 'pending' | 'completed' | 'failed' | 'reversed';
createdAt: Date;
completedAt?: Date;
metadata: Record<string, unknown>;
}
// نوع لطلب إنشاء معاملة جديدة
// نريد جميع الحقول المطلوبة باستثناء id وstatus وcreatedAt وcompletedAt
// ونريد أن تكون metadata اختيارية
// ونريد أن يكون sender وreceiver غير قابلين للتعديل
// الحل: تجميع عدة Utility Types
type CreateTransacti Readonly<{
amount: Transaction['amount'];
currency: Transaction['currency'];
sender: Readonly<Omit<Transaction['sender'], 'id'>>;
receiver: Readonly<Omit<Transaction['receiver'], 'id'>>;
metadata?: Transaction['metadata'];
}>;
// نوع لعرض قائمة المعاملات
// نريد فقط بعض الحقول مع إضافة حقل افتراضي للوقت المستغرق
// ونريد أن تكون جميع الحقول غير قابلة للتعديل
type TransactionListItem = Readonly<{
id: Transaction['id'];
amount: Transaction['amount'];
currency: Transaction['currency'];
senderName: Transaction['sender']['name'];
receiverName: Transaction['receiver']['name'];
status: Transaction['status'];
createdAt: Transaction['createdAt'];
duration?: string; // وقت المعالجة
}>;
// نوع لتحديث حالة المعاملة
// نريد فقط الحقول التي يمكن تحديثها
type UpdateTransactionStatus = {
status: Extract<Transaction['status'], 'completed' | 'failed' | 'reversed'>;
completedAt?: Transaction['completedAt'];
};
// دالة لإنشاء معاملة جديدة
function createTransaction(request: CreateTransactionRequest): Transaction {
// منطق الإنشاء
return {
...request,
id: generateTransactionId(),
status: 'pending',
createdAt: new Date(),
sender: {
...request.sender,
id: generateUserId()
},
receiver: {
...request.receiver,
id: generateUserId()
}
};
}على الرغم من قوة Utility Types، إلا أنها ليست خالية من الأخطاء. أحد أكثر الأخطاء شيوعاً التي أراها هو سوء فهم أن هذه الأدوات تعمل على مستوى الأنواع فقط، وليس على مستوى القيم. على سبيل المثال، عندما تستخدم Partial، فإنك لا تغير الكائن الفعلي في الذاكرة، بل تغير فقط كيفية تعامل TypeScript معه. هذا يعني أنك لا تزال تستطيع تعديل الكائن باستخدام أي (any) أو نوع آخر غير آمن. خطأ آخر شائع هو نسيان أن بعض الأدوات مثل Partial وReadonly تعمل بشكل سطحي فقط - فهي لا تؤثر على الأنواع المتداخلة تلقائياً. لذلك إذا كان لديك كائن يحتوي على كائنات فرعية، ستحتاج إلى تطبيق هذه الأدوات بشكل صريح على كل مستوى تريد التأثير عليه.
مشكلة أخرى شائعة هي الإفراط في استخدام Utility Types عندما لا تكون ضرورية. على سبيل المثال، استخدام Partial عندما تكون جميع الخصائص مطلوبة بالفعل، أو استخدام Omit عندما يكون Pick أكثر وضوحاً. هذا يمكن أن يجعل الكود أكثر تعقيداً دون داعٍ. أيضاً، يجب أن تكون حذراً عند استخدام Utility Types مع الأنواع الديناميكية - على سبيل المثال، استخدام Record مع مفاتيح غير محددة يمكن أن يؤدي إلى أنواع واسعة جداً تفقدك بعض فوائد الأمان النوعي. من تجربتي، أفضل دائماً البدء بالحل البسيط ثم إضافة التعقيد فقط عندما يكون ضرورياً. إذا وجدت نفسك تستخدم أكثر من ثلاث Utility Types في نوع واحد، فمن المحتمل أنك بحاجة إلى إعادة التفكير في نهجك.
بعد أكثر من عقد من العمل مع TypeScript في مشاريع متنوعة، هذه هي نصائحي الذهبية لاستخدام Utility Types بفعالية: ابدأ دائماً بتحديد المشكلة التي تحاول حلها قبل اختيار الأداة المناسبة. إذا كنت بحاجة إلى جعل بعض الخصائص اختيارية، استخدم Partial. إذا كنت بحاجة إلى إزالة بعض الخصائص، استخدم Omit. إذا كنت بحاجة إلى الاحتفاظ ببعض الخصائص فقط، استخدم Pick. لا تحاول استخدام Utility Types لحل مشاكل التصميم السيئ - إذا كانت أنواعك معقدة جداً، ربما تحتاج إلى إعادة هيكلتها بدلاً من محاولة تغطيتها بـ Utility Types.
أيضاً، تذكر أن Utility Types هي مجرد أدوات - القوة الحقيقية تأتي من كيفية استخدامها معاً. لا تتردد في تجميعها لإنشاء حلول معقدة، ولكن افعل ذلك تدريجياً. ابدأ بالحل البسيط ثم أضف التعقيد حسب الحاجة. وأخيراً، لا تنسَ أن تختبر أنواعك جيداً - استخدم typeof وinstanceof للتحقق من أن الأنواع الناتجة هي بالضبط ما تتوقعه. في النهاية، الهدف من Utility Types ليس فقط كتابة كود أقل، بل كتابة كود أكثر أماناً ومرونة وسهولة في الصيانة.
TypeScript ليست مجرد لغة تكتب فيها الأنواع، بل هي لغة تفكر لك فيها. عندما تتقن Utility Types، ستجد نفسك لا تكتب الكود فحسب، بل تصمم نظاماً كاملاً من الأنواع التي تعمل معاً بسلاسة.
— مطور TypeScript مجهول في مؤتمر JSConf