لنفرض أن لديك متجراً إلكترونياً صغيراً يبيع القهوة والتمور، والواجهة مكتوبة بـ JavaScript منذ سنتين، والكود كبر من ملف واحد إلى عشرات الملفات يعمل عليها أربعة مطورين. وفي صباح أحد الأيام يصلك من قسم المبيعات طلب غريب: عميل اشترى بقيمة 90 ريالاً واستخدم كوبون خصم 10%، فدفع ريالاً واحداً. وفي نفس الأسبوع تظهر في لوحة الطلبات سلة فيها «23» قطعة بينما اختار العميل قطعتين ثم ثلاثاً، ويشتكي عميل آخر أنه أضاف تمراً نفد من المخزون إلى السلة، وتظهر لمطور جديد في الفريق رسالة Cannot read properties of undefined في صفحة الطلبات القديمة. وعندما تبحث عن السبب تجد في كل مرة سطراً واحداً بسيطاً: رقمان مرا إلى دالة بترتيب معكوس، وقيمة جاءت من حقل في النموذج نصاً وليست رقماً، وawait نسيها أحدهم قبل استدعاء دالة غير متزامنة.
وهذه الأخطاء كلها من نوع واحد، وهو أن الكود افترض شيئاً عن شكل القيمة التي بين يديه، ولم يتحقق أحد من هذا الافتراض إلا المستخدم في الإنتاج. وهذا بالضبط ما جاءت TypeScript لحله، فهي تجعل هذه الافتراضات مكتوبة في الكود على شكل أنواع Types، ثم تفحصها قبل أن يعمل الكود. ولكن السؤال الذي يهمك كمطور قبل أن تقنع فريقك بنقل المشروع: أي هذه الأخطاء سوف تكشفها TypeScript فعلاً، وأيها سوف يمر رغم كل الأنواع؟ وهذا المقال من مرحلة هندسة الواجهات وتطبيقات الويب Frontend، وسوف نجيب فيه على السؤال بتجربة حقيقية: نزرع تسعة أخطاء في كود سلة مشتريات، ونشغله، ثم نفحصه بالمترجم tsc بإعدادات مختلفة، ونرى ما الذي أمسكه وما الذي فاته.
وسوف نناقش في هذا المقال ما يلي:
- لماذا ظهرت TypeScript، وما الذي تعد به وما الذي لا تعد به بحسب أهدافها المعلنة.
- كيف يعمل المترجم
tscمن الداخل: الفحص Type Checking والإخراج Emit، والأنواع البنيوية Structural Typing، والاستنتاج Type Inference. - محو الأنواع Type Erasure، ولماذا لا يبقى أي نوع عندما يعمل الكود في المتصفح أو في Node.js.
- الوضع الصارم Strict Mode وخياراته، والتضييق Narrowing الذي يجعل الأنواع المتحدة Union Types مفيدة.
- التجربة العملية: تسعة أخطاء، وأربعة ملفات
tsconfig، والمخرج سطراً بسطر، ثم الإصلاح والإخراج. - ما لا تكشفه الأنواع: البيانات القادمة من خارج الكود، والتحقق وقت التشغيل Runtime Validation.
- نقل مشروع JavaScript قائم إلى TypeScript، وتكلفة ذلك، والأخطاء الشائعة، وقائمة تحقق.
لماذا ظهرت TypeScript؟ JavaScript والأنواع Types
JavaScript لغة ديناميكية الأنواع Dynamically Typed، أي أن المتغير لا يحمل نوعاً ثابتاً، وإنما القيمة هي التي تحمل نوعها وقت التشغيل، فالمتغير price قد يحمل رقماً الآن ونصاً بعد سطرين، ولن يعترض أحد. ولهذا الأسلوب فائدة واضحة في البداية، فأنت تكتب الكود بسرعة ولا تشرح للمترجم شيئاً، ولكن كلما كبر الكود وزاد عدد المطورين صار السؤال «ما شكل هذا الكائن؟» يتكرر في كل ملف، والإجابة الوحيدة المتاحة هي أن تقرأ الكود الذي أنشأه، أو أن تشغله وترى. وأضف إلى ذلك أن JavaScript تحول القيم بين الأنواع تلقائياً Type Coercion في كثير من العمليات، فالجمع بين رقم ونص يعطي نصاً، والضرب بين نص ورقم يعطي رقماً، ولذلك فإن كثيراً من الأخطاء لا ترمي استثناء أصلاً، وإنما تعطي قيمة خاطئة تمر بهدوء إلى قاعدة البيانات أو إلى فاتورة العميل.
وفي 2012 أعلنت Microsoft عن TypeScript كلغة فوق JavaScript، أي أن كل كود JavaScript صحيح هو كود TypeScript صحيح، وتضيف عليه طبقة من الأنواع تفحص قبل التشغيل ثم تحذف. ويشرح فريق المشروع في أهداف التصميم TypeScript Design Goals ما تعد به اللغة وما لا تعد به، ومن أهم ما في هذه الصفحة أن اللغة لا تهدف إلى نظام أنواع «سليم» Sound بشكل كامل، ولا تضيف أي معلومات عن الأنواع وقت التشغيل، وأن الكود الذي تخرجه يجب أن يكون JavaScript نظيفة يمكن قراءتها. وهذان الهدفان هما مفتاح فهم كل ما سوف تراه في التجربة، فالأنواع أداة فحص قبل التشغيل، وليست حارساً يقف عند كل استدعاء دالة.
وقد يتساءل البعض: هل الفائدة كبيرة فعلاً، أم هي موضة؟ والإجابة الأقرب للأرقام جاءت من دراسة To Type or Not to Type المنشورة في مؤتمر ICSE عام 2017، حيث أخذ الباحثون أخطاء حقيقية أصلحت في مشاريع JavaScript مفتوحة المصدر، ورجعوا إلى الكود قبل الإصلاح، وأضافوا إليه الأنواع يدوياً، ثم سألوا: هل كان المترجم سيعترض؟ ووجدوا أن TypeScript 2.0 وFlow كشفا 15% من هذه الأخطاء. ولاحظ أن هذه أخطاء مرت من المراجعة والاختبارات ووصلت إلى المستودع العام، فالرقم الحقيقي أثناء التطوير أعلى بكل تأكيد، ولكن الرقم مهم لأنه يقول بوضوح إن 85% من الأخطاء المنشورة كانت أخطاء منطق لا تعرف عنها الأنواع شيئاً. أي أن TypeScript تقلل نوعاً محدداً من الأخطاء، ولا تغني عن الاختبارات.
ومع ذلك فقد صارت TypeScript هي الاختيار الافتراضي في كثير من الفرق، وبحسب تقرير GitHub Octoverse 2025 فقد تجاوزت TypeScript كلاً من Python وJavaScript في أغسطس 2025 لتصبح أكثر لغة مستخدمة على GitHub، ويذكر التقرير أن أغلب أطر الواجهات الكبيرة تنشئ المشاريع الجديدة بـ TypeScript افتراضياً. وفي 2026 صدر الإصدار TypeScript 7، وهو إعادة كتابة للمترجم بلغة Go بدلاً من JavaScript، ويقول الفريق إنه أسرع من 8 إلى 12 مرة في البناء الكامل، وهذا هو الإصدار الذي استخدمناه في التجربة.

كيف يعمل المترجم tsc؟ الفحص Type Checking والإخراج Emit
عندما تشغل الأمر tsc فإنه يقوم بعملين منفصلين تماماً، ومن المهم أن تفصل بينهما في ذهنك. العمل الأول هو الفحص Type Checking، حيث يقرأ المترجم كل الملفات ويحللها إلى شجرة Syntax Tree، ثم يربط كل اسم بتعريفه Binding، ثم يحسب نوع كل تعبير في الكود ويقارنه بالنوع المتوقع في مكانه، وإذا وجد تعارضاً يطبع رسالة خطأ مع رقمها، مثل TS2322. والعمل الثاني هو الإخراج Emit، حيث يكتب ملفات JavaScript بعد حذف كل ما هو نوع، وقد يحول بعض الصيغ الحديثة إلى صيغ أقدم إذا طلبت منه ذلك في الخيار target.
والنقطة التي يتفاجأ بها كثير من المطورين أن العمل الثاني لا يعتمد على نتيجة الأول، فالمترجم يخرج ملفات JavaScript حتى لو وجد أخطاء في الأنواع، لأن الأنواع لا تغير معنى الكود، وإذا أردت أن يتوقف الإخراج عند أي خطأ فعليك بتفعيل الخيار noEmitOnError. وفي أغلب المشاريع الحديثة لا يقوم tsc بالإخراج أصلاً، فأداة البناء مثل Vite أو esbuild تحذف الأنواع بسرعة دون أن تفحصها، ويعمل tsc --noEmit في خط CI كخطوة فحص فقط. وهذا التقسيم منطقي لأن حذف الأنواع عملية بسيطة تتم ملفاً بملف، أما الفحص فيحتاج أن يرى المشروع كله ليعرف نوع كل دالة مستوردة.
الأنواع البنيوية Structural Typing: الشكل وليس الاسم
في لغات مثل Java وC# يكون النوع مرتبطاً باسمه، فالكلاس Product لا يقبل مكانه كلاس آخر له نفس الحقول إلا إذا ورثه أو طبق نفس الواجهة صراحة، وهذا ما يسمى الأنواع الاسمية Nominal Typing. أما TypeScript فتقارن الشكل فقط، فإذا كانت الدالة تنتظر CartItem، وهو كائن فيه productId نصي وquantity رقمي، فإن أي كائن له هذان الحقلان بهذين النوعين يقبل، مهما كان اسمه ومن أين جاء. ويشرح دليل اللغة هذا في صفحة توافق الأنواع Type Compatibility.
وهذا الاختيار مناسب جداً لـ JavaScript، لأن الكود فيها مليء بالكائنات التي تنشأ مباشرة بالأقواس {} دون كلاس، وبالردود التي تأتي من JSON. ولكن له ثمن سوف تراه في الخطأ B8 في التجربة، فالدالة applyCoupon(total: number, percent: number) تقبل أي رقمين بأي ترتيب، لأن «المجموع» و«النسبة» في نظر المترجم شيء واحد اسمه number. فإذا أرسل أحدهم النسبة مكان المجموع فلن يعترض المترجم أبداً، وسوف نرى بعد قليل كيف تتجنب هذا.
الاستنتاج Type Inference: لماذا لا تكتب النوع في كل سطر؟
لا تحتاج في TypeScript أن تكتب النوع بجانب كل متغير، فعندما تكتب let total = 0 يستنتج المترجم أن النوع number، وعندما تكتب products.find(...) يعرف أن النتيجة Product | undefined لأن تعريف find في ملفات المكتبة القياسية يقول ذلك. ولهذا السبب ترى الكود المكتوب جيداً بـ TypeScript قريباً جداً من JavaScript، فالأنواع تكتب صراحة في الحدود فقط: معاملات الدوال، وأشكال البيانات المشتركة، وما ترجعه الدوال العامة. وفي التجربة لاحظ أن المترجم قرأ 67 ملفاً و56,283 سطراً لكي يفحص أربعة ملفات صغيرة، والسبب أن أغلب هذه الأسطر هي ملفات التعريف Declaration Files الخاصة بالمكتبة القياسية والـ DOM، وهي التي تعرف المترجم بأن toFixed موجودة على الأرقام وغير موجودة على النصوص.
ويوجد استثناء مهم للاستنتاج، وهو معاملات الدوال، فالمترجم لا يعرف نوع value في function formatPrice(value) لأنه لا يرى من سيستدعيها، فيعطيها النوع any، وهذا النوع يعني «لا تفحص شيئاً». وفي الوضع المتساهل يمر هذا بصمت، وفي الوضع الصارم يظهر الخطأ TS7006 كما سوف تجد في مخرج التجربة، وهذا من أهم أسباب تفعيل الوضع الصارم.
محو الأنواع Type Erasure: لا يبقى شيء وقت التشغيل
الأنواع في TypeScript تختفي تماماً بعد الإخراج، فلا يوجد في ملف JavaScript الناتج أي أثر للـ interface أو لنوع المعامل أو لنوع ما ترجعه الدالة. ويسمي الدليل هذا الأنواع الممحوة Erased Types. وفي التجربة صار ملف types.ts الذي فيه كل الأشكال، وحجمه 417 بايت، ملفاً من 11 بايت فقط هو السطر export {};، لأن كل ما فيه أنواع. والصورة التالية تبين ما يحدث لدالة واحدة:

ولهذا المحو نتيجتان عمليتان يجب أن تتذكرهما دائماً. الأولى أنك لا تستطيع أن تسأل عن النوع وقت التشغيل، فلا يوجد شيء اسمه if (x instanceof CartItem) عندما يكون CartItem واجهة interface، لأنها غير موجودة في الكود الذي يعمل، وكل فحص وقت التشغيل تكتبه بنفسك بـ typeof وArray.isArray وأمثالها. والثانية أن كل كود لا يمر على المترجم لا يستفيد من الأنواع إطلاقاً، سواء كان كود JavaScript قديماً يستدعي مكتبتك، أو رداً من سيرفر، أو قيمة قرأتها من localStorage. وهذا ما سوف نختبره في الخطوة الرابعة من التجربة.
ويوجد اليوم طريق آخر لتشغيل TypeScript بدون tsc، فمنذ Node.js 22.6 يوجد الخيار --experimental-strip-types الذي يحذف الأنواع ثم يشغل الملف مباشرة، وفي إصدارات Node.js الأحدث صار مفعلاً افتراضياً. وهذا الخيار لا يفحص أي شيء، وإنما يستبدل الأنواع بمسافات فارغة ويشغل الباقي، ولذلك فإنه أوضح دليل على أن الأنواع لا تحمي الكود وقت التشغيل، وسوف نستخدمه في أول خطوة من التجربة لنرى ماذا يفعل الكود الذي فيه الأخطاء التسعة. ويوجد أيضاً مقترح لإضافة صيغة الأنواع إلى JavaScript نفسها كتعليقات يتجاهلها المحرك، وهو مبني على نفس الفكرة: الأنواع للأدوات، وليست للمحرك.
الوضع الصارم Strict Mode: ماذا يفعل كل خيار؟
الخيار strict في ملف tsconfig.json ليس خياراً واحداً، وإنما مفتاح يفعل مجموعة من الخيارات دفعة واحدة، وكل واحد منها يغلق باباً تمر منه أخطاء معينة. وأهمها ما يلي:
- التحقق من القيم الفارغة strictNullChecks: بدونه يعتبر المترجم أن
nullوundefinedقيمة مقبولة لأي نوع، فالدالة التي ترجعProduct | undefinedتعامل كأنها ترجعProductدائماً، وتمر كل الأخطاء من نوع «Cannot read properties of undefined». وهذا الخيار وحده هو الذي كشف B1 وB4 وB5 في التجربة. - منع any الضمني noImplicitAny: يرفض أي معامل أو متغير لا يستطيع المترجم استنتاج نوعه فيعطيه
anyبصمت، وهو الذي كشف B3. - فحص أنواع الدوال strictFunctionTypes: يفحص معاملات الدوال التي تمرر كقيم بشكل أدق، فلا تمر دالة تنتظر
Productمكان دالة ستستقبل أي كائن. - الأخطاء في catch كـ unknown useUnknownInCatchVariables: يجعل المتغير في
catch (e)من النوعunknown، لأن JavaScript تسمح برمي أي قيمة وليسErrorفقط، فلا تستطيع أن تكتبe.messageقبل أن تتحقق. - تهيئة خصائص الكلاس strictPropertyInitialization: يرفض خاصية في الكلاس نوعها لا يقبل
undefinedولا تعطيها قيمة في الـ constructor.
وفي الإصدار TypeScript 6.0 تغير شيء مهم، فقد صار strict مفعلاً افتراضياً، ويقول الفريق إن أغلب المشاريع الجديدة تريده مفعلاً، ومن كان يعتمد على القيمة القديمة false فعليه أن يكتبها صراحة. وهذا التغيير انتقل إلى TypeScript 7، وظهر لنا في التجربة كما سوف ترى، فالملف الذي لا يذكر strict أعطى نفس نتيجة الوضع الصارم تماماً.
خيار إضافي خارج strict: noUncheckedIndexedAccess
بعض الخيارات المفيدة لا يفعلها strict، وأهمها noUncheckedIndexedAccess. فعندما تكتب order.items[0] يعتبر المترجم افتراضياً أن النتيجة من النوع CartItem، مع أن المصفوفة قد تكون فارغة، وفي هذه الحالة تكون النتيجة undefined. ومع هذا الخيار يصبح نوع أي وصول بالفهرس CartItem | undefined، فيجبرك المترجم أن تتعامل مع حالة الفراغ. والسبب في أنه ليس ضمن strict أنه مزعج في الكود الذي يمر على المصفوفة بحلقة for بالفهرس، حيث يعرف المطور أن الفهرس صحيح ولا يعرفه المترجم، ولكنه في كود الواجهات الذي يأخذ أول عنصر من نتيجة بحث أو آخر رسالة في محادثة يكشف أخطاء حقيقية، كما كشف B7 في التجربة.
التضييق Narrowing والأنواع المتحدة Union Types
النوع المتحد Union Type مثل Product | undefined أو "pending" | "paid" | "shipped" | "cancelled" هو الطريقة التي تقول بها للمترجم إن القيمة قد تكون واحداً من عدة أشكال. ولكن هذا وحده يجعل الكود مزعجاً، فكيف تصل إلى product.price إذا كان product قد يكون undefined؟ والحل هو التضييق Narrowing، أي أن المترجم يتتبع الشروط في الكود سطراً بسطر، فبعد if (!product) throw ... يعرف أن الفرع الذي فيه undefined قد خرج من الدالة، فيضيق النوع في الأسطر التالية إلى Product. ويشرح فصل التضييق في الدليل كل الطرق التي يفهمها المترجم: typeof وinstanceof وin والمقارنة بقيمة محددة والدوال التي ترجع x is Product.

والتطبيق الأجمل للتضييق هو فحص اكتمال الحالات Exhaustiveness Checking. فالدالة statusLabel في التجربة تحول حالة الطلب إلى نص عربي، والمطور الأول كتب ثلاث حالات ونسي الرابعة "cancelled". وفي الوضع الصارم يرفض المترجم الدالة لأن نوعها المعلن string ولكن أحد الطرق فيها يصل إلى نهايتها دون return، أي يرجع undefined. والإصلاح الأفضل من مجرد إضافة الحالة هو أن تضيف فرع default يسند القيمة إلى متغير من النوع never، فإذا أضاف زميلك بعد شهر حالة جديدة مثل "refunded" إلى النوع OrderStatus ونسي هذه الدالة، فإن البناء يتوقف عند هذا السطر بالضبط، لأن القيمة لم تعد never. وبهذا الشكل تحول المترجم إلى قائمة مراجعة تمر على كل مكان في الكود يعتمد على الحالات.
التجربة العملية Lab: تسعة أخطاء Bugs في سلة المشتريات
الآن سوف نختبر كل ما سبق. المشروع أربعة ملفات صغيرة تمثل جزءاً من واجهة المتجر: الأنواع، والكتالوج، ودوال السلة، وملف يشغل كل حالة ويطبع النتيجة. وزرعنا في دوال السلة تسعة أخطاء من النوع الذي يظهر في المشاريع الحقيقية، وكل خطأ معلم بتعليق من B1 إلى B9. والتجربة تحتاج Node.js 22.6 أو أحدث، والحزمة typescript، ولا تحتاج أي شيء آخر.
المشروع وملفات الإعداد tsconfig
نبدأ بالأنواع، وهي الأشكال التي تتفق عليها كل الملفات:
// Shapes of the store's data. These lines vanish completely when the code runs.
export type OrderStatus = "pending" | "paid" | "shipped" | "cancelled";
export interface Product {
id: string;
name: string;
price: number; // riyals
stock: number;
}
export interface CartItem {
productId: string;
quantity: number;
}
export interface Order {
id: string;
status: OrderStatus;
items: CartItem[];
}ثم الكتالوج، وفيه بحث عن منتج، وفحص للمخزون بدالة غير متزامنة لأنها في الواقع تسأل السيرفر، ودالة تقرأ الكتالوج من نص JSON:
import type { Product } from "./types.ts";
const products: Product[] = [
{ id: "p1", name: "قهوة عربية", price: 45, stock: 10 },
{ id: "p2", name: "تمر سكري", price: 30, stock: 0 },
];
export function findProduct(id: string): Product | undefined {
return products.find((p) => p.id === id);
}
export async function inStock(id: string): Promise<boolean> {
return (findProduct(id)?.stock ?? 0) > 0;
}
// The server sends the catalogue as JSON text
export function parseProducts(json: string): Product[] {
return JSON.parse(json);
}ثم دوال السلة، وهي الملف الذي فيه الأخطاء:
import type { CartItem, Order, OrderStatus, Product } from "./types.ts";
import { findProduct, inStock } from "./catalog.ts";
// B1: a product id that is not in the catalogue
export function cartTotal(items: CartItem[]): number {
let total = 0;
for (const item of items) {
const product = findProduct(item.productId);
total += product.price * item.quantity;
}
return total;
}
// B2: a typo in a property name
export function itemCount(items: CartItem[]): number {
return items.reduce((n, item) => n + item.quantiy, 0);
}
// Correct code, used to show B6
export function totalQuantity(items: CartItem[]): number {
return items.reduce((n, item) => n + item.quantity, 0);
}
// B3: a parameter without a type
export function formatPrice(value) {
return value.toFixed(2) + " ر.س";
}
// B4: a status the switch forgot
export function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "بانتظار الدفع";
case "paid": return "مدفوع";
case "shipped": return "تم الشحن";
}
}
// B5 and B6: a forgotten await, and the quantity as typed in the form
export async function addToCart(items: CartItem[], productId: string, qty: string) {
if (inStock(productId)) {
items.push({ productId, quantity: qty });
}
}
// B7: the first item of an order that may be empty
export function firstProductId(order: Order): string {
return order.items[0].productId;
}
// B8: two numbers in the wrong order
export function applyCoupon(total: number, percent: number): number {
return total - (total * percent) / 100;
}
// B9: the sum of prices that came from JSON
export function catalogueValue(products: Product[]): number {
return products.reduce((sum, p) => sum + p.price, 0);
}وأخيراً الملف الذي يشغل الحالات، وكل سطر run فيه يمثل استخداماً حقيقياً لدالة من الدوال السابقة:
import { parseProducts } from "./catalog.ts";
import { addToCart, applyCoupon, cartTotal, catalogueValue, firstProductId, formatPrice, itemCount, statusLabel, totalQuantity } from "./cart.ts";
import type { CartItem } from "./types.ts";
function run(name: string, fn: () => unknown) {
try {
const v = fn();
console.log(name.padEnd(4), "=>", typeof v === "string" ? JSON.stringify(v) : String(v));
} catch (e) {
console.log(name.padEnd(4), "=> THROWS", (e as Error).constructor.name + ":", (e as Error).message);
}
}
const cart: CartItem[] = [{ productId: "p1", quantity: 2 }];
run("B1", () => cartTotal([...cart, { productId: "p9", quantity: 1 }]));
run("B2", () => itemCount(cart));
run("B3", () => formatPrice("90"));
run("B4", () => statusLabel("cancelled"));
await addToCart(cart, "p2", "3"); // p2 is out of stock
run("B5", () => cart.length);
run("B6", () => totalQuantity(cart));
run("B7", () => firstProductId({ id: "o1", status: "pending", items: [] }));
run("B8", () => applyCoupon(10, 90)); // meant: 10% off 90 riyals
run("B9", () => catalogueValue(parseProducts('[{"id":"p1","name":"قهوة","price":"45","stock":3},{"id":"p3","name":"هيل","price":"20","stock":5}]')));أما الإعدادات فهي ملف أساسي مشترك، ثم أربعة ملفات صغيرة يختلف كل منها في خيار واحد. والملف الأساسي كما يلي:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"rewriteRelativeImportExtensions": true,
"outDir": "dist",
"noEmit": true
},
"include": ["src"]
}وفي الإعداد أعلاه لاحظ التالي:
"module": "nodenext"يجعل المترجم يتبع قواعد Node.js في الاستيراد، ومنها أن الملف المستورد يكتب بامتداده.- الاستيراد في الملفات مكتوب بالامتداد
.ts، وهذا ما يحتاجه Node.js عندما يشغل الملفات مباشرة، والخيار rewriteRelativeImportExtensions يحول هذا الامتداد إلى.jsعند الإخراج، حتى يعمل نفس الكود بالطريقتين. "noEmit": trueلأننا في الملفات الأولى نريد الفحص فقط، وسوف نغيره في خطوة الإصلاح.
والملفات الأربعة هي tsconfig.loose.json وفيه "strict": false، وtsconfig.default.json الذي لا يذكر strict أصلاً، وtsconfig.strict.json وفيه "strict": true، وtsconfig.max.json وفيه strict مع noUncheckedIndexedAccess. وكل التجربة يشغلها السكربت run.sh الموجود مع المقال، والأوامر الأساسية فيه كما يلي:
npm i -D [email protected]
node --no-warnings --experimental-strip-types src/main.ts
npx tsc -p tsconfig.loose.json --pretty false
npx tsc -p tsconfig.default.json --pretty false
npx tsc -p tsconfig.strict.json --pretty false
npx tsc -p tsconfig.max.json --pretty falseالخطوة الأولى: الكود يعمل كأنه JavaScript
قبل أن نفحص أي شيء، نشغل الكود كما هو بالأمر node --experimental-strip-types، أي أن Node.js يحذف الأنواع ويشغل الباقي، وهذا بالضبط ما يحدث في مشروع JavaScript لا يوجد فيه فحص. والمخرج سوف يكون كما يلي:
## node: v22.14.0 tsc: Version 7.0.2
## 1) the buggy code runs as plain JavaScript (types stripped)
B1 => THROWS TypeError: Cannot read properties of undefined (reading 'price')
B2 => NaN
B3 => THROWS TypeError: value.toFixed is not a function
B4 => undefined
B5 => 2
B6 => "23"
B7 => THROWS TypeError: Cannot read properties of undefined (reading 'productId')
B8 => 1
B9 => "04520"وفي المخرج أعلاه لاحظ التالي:
- ثلاثة أخطاء فقط رمت استثناء، وهي B1 وB3 وB7، وهذه في الحقيقة الأخطاء «المحظوظة»، لأنها تظهر في سجل الأخطاء ويراها أحد.
- B2 أعطى
NaN، فالخاصيةquantiyغير موجودة وقيمتهاundefined، و0 + undefinedتعطيNaN، وهذه القيمة سوف تظهر للعميل كعدد القطع في السلة دون أي خطأ. - B4 أرجع
undefined، أي أن الطلب الملغي سوف يظهر في الواجهة بدون حالة، أو بكلمة «undefined» إذا طبعته الواجهة مباشرة. - B5 أعطى 2، مع أن التمر نفد من المخزون، والسبب أن
inStock(productId)بدونawaitترجع Promise، وأي Promise في شرطifتعتبر قيمة صحيحة Truthy، فالشرط صحيح دائماً. وإذا أردت أن تفهم لماذا لا ينتظر الكود هنا، فقد شرحنا الوعود Promises وترتيب تنفيذها في مقال حلقة الأحداث Event Loop. - B6 أعطى النص
"23"، لأن الكمية جاءت من النموذج نصاً"3"، و2 + "3"في JavaScript تعطي"23". - B8 أعطى 1، وهذا هو خطأ الكوبون من القصة، فالمقصود خصم 10% من 90 ريالاً، أي 81، ولكن الرقمين مرا بترتيب معكوس، فحسبت الدالة خصم 90% من 10 ريالات.
- B9 أعطى النص
"04520"، فالسيرفر أرسل الأسعار نصوصاً"45"و"20"، فجمعتreduceالصفر مع النصين كنصوص.
أي أن ستة أخطاء من تسعة لا ترمي أي استثناء، وإنما تعطي قيمة خاطئة تمر إلى الخطوة التالية، وهذا النوع هو الأخطر في المتاجر والأنظمة المالية، لأن أحداً لا يلاحظه حتى يراجع حساباً أو يشتكي عميل.
الخطوة الثانية: tsc بالوضع المتساهل ثم الصارم
الآن نفحص نفس الكود بالمترجم، ونبدأ بالوضع المتساهل "strict": false:
## 2) tsc -p tsconfig.loose.json
src/cart.ts(16,45): error TS2551: Property 'quantiy' does not exist on type 'CartItem'. Did you mean 'quantity'?
src/cart.ts(41,29): error TS2322: Type 'string' is not assignable to type 'number'.
exit code: 1خطآن فقط من تسعة: الخطأ الإملائي B2، والمترجم يقترح الاسم الصحيح لأنه يعرف حقول CartItem، والكمية النصية B6، لأن الحقل quantity معرف كرقم والقيمة qty معرفة كنص. وكل رسالة تبدأ بالملف ثم السطر والعمود بين قوسين، ثم رقم الخطأ، وهذا الرقم مفيد عندما تبحث عن شرح الخطأ. وقيمة exit code: 1 مهمة في CI، لأنها تجعل خطوة الفحص تفشل فيتوقف الدمج.
ثم الوضع الصارم "strict": true:
## 2) tsc -p tsconfig.strict.json
src/cart.ts(9,14): error TS18048: 'product' is possibly 'undefined'.
src/cart.ts(16,45): error TS2551: Property 'quantiy' does not exist on type 'CartItem'. Did you mean 'quantity'?
src/cart.ts(25,29): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/cart.ts(30,51): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
src/cart.ts(40,7): error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined.
src/cart.ts(41,29): error TS2322: Type 'string' is not assignable to type 'number'.
exit code: 1صارت ستة أخطاء، والأربعة الجديدة كلها بسبب خيارين فقط من خيارات strict:
TS18048في السطر 9:findProductترجعProduct | undefined، والكود يقرأproduct.priceدون أن يتحقق، وهذا هو B1، وكشفهstrictNullChecks.TS7006في السطر 25: المعاملvalueبدون نوع، فهوanyضمنياً، وهذا هو B3، وكشفهnoImplicitAny. ولاحظ أن الخطأ ظهر على تعريف الدالة، وليس على السطر الذي يرسل لها النص"90"، لأن المترجم لا يعرف ما الذي يجب أن يعترض عليه في الاستدعاء ما دام المعاملany.TS2366في السطر 30: الدالة تعلن أنها ترجعstring، ولكن يوجد طريق يصل إلى نهايتها، وهذا هو B4.TS2801في السطر 40: هذا من أجمل رسائل المترجم، فهو يقول إن الشرط صحيح دائماً لأن الـ Promise موجودة دائماً، وهذا هو B5، أي الـawaitالمنسية.
وأخيراً الإعداد الأقصى مع noUncheckedIndexedAccess:
## 2) tsc -p tsconfig.max.json
src/cart.ts(9,14): error TS18048: 'product' is possibly 'undefined'.
src/cart.ts(16,45): error TS2551: Property 'quantiy' does not exist on type 'CartItem'. Did you mean 'quantity'?
src/cart.ts(25,29): error TS7006: Parameter 'value' implicitly has an 'any' type.
src/cart.ts(30,51): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
src/cart.ts(40,7): error TS2801: This condition will always return true since this 'Promise<boolean>' is always defined.
src/cart.ts(41,29): error TS2322: Type 'string' is not assignable to type 'number'.
src/cart.ts(47,10): error TS2532: Object is possibly 'undefined'.
exit code: 1الخطأ السابع TS2532 في السطر 47 هو B7، أي order.items[0] على طلب قد يكون فارغاً. وبهذا يكون المترجم قد أمسك سبعة أخطاء من تسعة قبل أن يعمل سطر واحد من الكود، أما B8 وB9 فلم يظهرا في أي إعداد، وسوف نخصص لهما قسماً كاملاً بعد التجربة. والصورة التالية تجمع النتيجة:

الخطوة الثالثة: الإصلاح ثم الإخراج Emit
الآن نصلح الأخطاء في مجلد منفصل fixed، ونفعل الإخراج حتى نرى ملفات JavaScript الناتجة. والتغييرات المهمة في cart.ts كما يلي:
export function cartTotal(items: CartItem[]): number {
let total = 0;
for (const item of items) {
const product = findProduct(item.productId);
if (!product) throw new Error(`unknown product ${item.productId}`);
total += product.price * item.quantity;
}
return total;
}
export function statusLabel(status: OrderStatus): string {
switch (status) {
case "pending": return "بانتظار الدفع";
case "paid": return "مدفوع";
case "shipped": return "تم الشحن";
case "cancelled": return "ملغي";
default: {
const unreachable: never = status; // a new status added later fails here at compile time
return unreachable;
}
}
}
export async function addToCart(items: CartItem[], productId: string, qty: string) {
const quantity = Number.parseInt(qty, 10);
if (!Number.isInteger(quantity) || quantity < 1) throw new Error(`bad quantity ${qty}`);
if (await inStock(productId)) {
items.push({ productId, quantity });
}
}
export function firstProductId(order: Order): string | undefined {
return order.items[0]?.productId;
}
// Named fields: the caller cannot swap two numbers by accident
export function applyCoupon({ total, percent }: { total: number; percent: number }): number {
return total - (total * percent) / 100;
}ثم نشغل المترجم بالملف tsconfig.fixed.json، وفيه strict وnoUncheckedIndexedAccess و"noEmit": false، ثم نشغل ملفات JavaScript الناتجة مباشرة بـ node:
npx tsc -p tsconfig.fixed.json --pretty false
node dist/main.jsوالمخرج سوف يكون كما يلي:
## 3) fixed version: compile, then run the emitted JavaScript
exit code: 0
B1 => THROWS Error: unknown product p9
B2 => 2
B3 => "90.00 ر.س"
B4 => "ملغي"
B5 => 1
B6 => 2
B7 => undefined
B8 => 81
B9 => THROWS Error: catalogue item 0 is not a Product: {"id":"p1","name":"قهوة","price":"45","stock":3}وفي المخرج أعلاه لاحظ التالي:
- B1 ما زال يرمي استثناء، ولكنه الآن استثناء نحن كتبناه برسالة واضحة
unknown product p9، بدلاً منCannot read properties of undefinedالذي لا يقول لك أي منتج هو. والمترجم لم يجبرنا على رمي استثناء تحديداً، وإنما أجبرنا أن نقرر ماذا نفعل عندما لا يوجد المنتج. - B5 صار 1، أي أن التمر الذي نفد لم يضف إلى السلة، وB6 صار رقماً لأن الكمية تحولت إلى رقم وتم التحقق منها قبل أن تدخل السلة.
- B7 صار
undefinedبدلاً من استثناء، لأن نوع ما ترجعه الدالة صارstring | undefined، وكل من يستدعيها سوف يجبره المترجم أن يتعامل مع الطلب الفارغ. - B8 صار 81، والإصلاح هنا ليس من الأنواع، وإنما من شكل الدالة نفسها، فصارت تأخذ كائناً بحقلين مسميين
{ total, percent }، ومن المستحيل أن تكتبpercent: 90وأنت تقصد المجموع دون أن تلاحظ. - B9 صار يرمي استثناء عند أول عنصر في الكتالوج، وهذا جاء من دالة تحقق كتبناها بأنفسنا، وسوف نشرحها في القسم القادم.
أما حجم الملفات قبل الإخراج وبعده فكان كما يلي، والأرقام بالبايت:
## 4) bytes: TypeScript source vs emitted JavaScript
types 417 -> 11
catalog 1141 -> 1013
cart 1891 -> 1689
main 1241 -> 1144والملف types.ts اختفى تقريباً، لأن كل ما فيه أنواع، والملفات الأخرى صغرت بمقدار الأنواع المكتوبة فيها فقط، وهذا يعني أن TypeScript لا تضيف أي كود إلى ما يصل إلى المتصفح، فلا تدفع شيئاً في حجم الصفحة مقابل الأنواع. وإذا فتحت dist/cart.js فسوف تجد نفس الكود الذي كتبته بدون الأنواع، حتى التعليقات بقيت في مكانها.
الخطوة الرابعة: كود JavaScript قديم يستدعي الكود المترجم
الآن نتأكد من فكرة المحو بأنفسنا، فنكتب ملفاً بـ JavaScript عادية يمثل جزءاً قديماً من المشروع لم ينقل بعد، ويستدعي الدوال المترجمة بقيم خاطئة:
// Old JavaScript code calling the compiled module: nothing checks the types here
import { formatPrice, applyCoupon } from "./dist/cart.js";
try { console.log(formatPrice("90")); } catch (e) { console.log("THROWS", e.message); }
console.log(applyCoupon({ total: "90", percent: 10 }));والمخرج سوف يكون كما يلي:
## 5) old JavaScript calling the compiled module
THROWS value.toFixed is not a function
81الدالة formatPrice معرفة في TypeScript بأنها تأخذ number، ولكن هذا التعريف لم يعد موجوداً في dist/cart.js، فاستقبلت النص ورمت نفس الخطأ القديم. أما applyCoupon فأعطت 81 مع أن المجموع نص، والسبب أن الطرح والضرب في JavaScript يحولان النص إلى رقم تلقائياً، أي أن النتيجة صحيحة هنا بالصدفة فقط، ولو كان في الدالة جمع لظهر نص غريب كما في B9. والدرس أن أي مكتبة تنشرها لغيرك، وأي دالة يستدعيها كود لا يمر على المترجم، تحتاج تحققاً وقت التشغيل في مدخلاتها العامة إذا كان الخطأ فيها مكلفاً.
مشكلتان واجهناهما أثناء التجربة
المشكلة الأولى ظهرت في أول تشغيل، فقد كان الملف tsconfig.loose.json في البداية لا يذكر strict أصلاً، على أساس أن القيمة الافتراضية هي false كما كانت في كل الإصدارات القديمة، فأعطى ستة أخطاء بدلاً من اثنين، وهي نفس نتيجة الوضع الصارم. والسبب كما ذكرنا أن TypeScript 6.0 جعلت strict مفعلاً افتراضياً، وانتقل ذلك إلى 7.0. لذلك صار في التجربة ملفان: tsconfig.default.json الذي لا يذكر الخيار، وtsconfig.loose.json الذي يكتب false صراحة. وإذا كنت ترقي مشروعاً قديماً من TypeScript 5 فانتبه لهذا، لأن أول بناء بعد الترقية قد يعطيك مئات الأخطاء الجديدة، وهي ليست أخطاء في الترقية، وإنما هي الأخطاء التي كان الوضع المتساهل يخفيها. والحل المؤقت أن تكتب "strict": false صراحة، ثم تفعل الخيارات واحداً بعد الآخر كما سوف نشرح في قسم النقل.
والمشكلة الثانية كانت في كود التجربة نفسه، فالدالة run كانت في البداية تطبع النتيجة بـ JSON.stringify، فظهرت نتيجة B2 هكذا: null. والسبب أن JSON.stringify(NaN) يعطي null، لأن صيغة JSON لا تعرف NaN أصلاً، أي أن أداة الطباعة نفسها أخفت الخطأ الذي نبحث عنه. فغيرنا الدالة لتطبع النصوص بـ JSON.stringify حتى تظهر علامات التنصيص حولها، والقيم الأخرى بـ String. ولاحظ أن هذه المشكلة نفسها تحدث في الإنتاج، فالـ API الذي يرسل NaN في JSON يرسل null، والواجهة التي تستقبله لا تعرف أن الأصل كان حساباً خاطئاً.
ماذا يحدث إذا استخدمت enum مع Node.js؟
قلنا إن الأنواع تحذف ولا يبقى منها شيء، ولكن يوجد في TypeScript بعض الصيغ التي ليست أنواعاً فقط، وإنما تولد كوداً وقت التشغيل، وأشهرها enum. لنأخذ هذا الملف الصغير:
export enum Status { Pending = "pending", Paid = "paid" }
export const label = (s: Status) => (s === Status.Paid ? "مدفوع" : "بانتظار الدفع");
console.log(label(Status.Paid));عندما نشغله بـ node --experimental-strip-types، وعندما نفحصه بـ tsc مع الخيار --erasableSyntaxOnly، يكون المخرج كما يلي:
## 6) what if: an enum
TypeScript enum is not supported in strip-only mode
whatif/status.ts(1,13): error TS1294: This syntax is not allowed when 'erasableSyntaxOnly' is enabled.
exit code: 1رفض Node.js تشغيل الملف، والسبب أن وضع الحذف فقط Strip-Only لا يستطيع أن «يحذف» الـ enum، لأنها تحتاج كوداً جديداً يمثلها. وإذا أخرجنا نفس الملف بـ tsc فسوف نجد هذا الكود في الناتج:
export var Status;
(function (Status) {
Status["Pending"] = "pending";
Status["Paid"] = "paid";
})(Status || (Status = {}));أي أن enum تتحول إلى كائن حقيقي يعمل وقت التشغيل، وهذا يخالف القاعدة التي بنينا عليها المقال. ولهذا أضافت TypeScript 5.8 الخيار erasableSyntaxOnly، الذي يرفض كل صيغة لا يمكن حذفها ببساطة، مثل enum وnamespace التي فيها كود، وخصائص الـ constructor المختصرة مثل constructor(private x: number). والبديل العملي للـ enum هو ما استخدمناه في التجربة أصلاً: نوع متحد من نصوص "pending" | "paid"، فهو يعطيك نفس الفحص ونفس الإكمال التلقائي في المحرر، ويختفي كلياً بعد الإخراج. وإذا كان فريقك يشغل TypeScript مباشرة في Node.js، أو يستخدم أداة بناء تحذف الأنواع ملفاً بملف، ففعل هذا الخيار من أول يوم.
ما لا تكشفه الأنواع: الحدود والتحقق وقت التشغيل Runtime Validation
الخطآن B8 وB9 مرا من كل الإعدادات، ولكل واحد منهما سبب مختلف، وفهم السببين هو أهم ما في هذا المقال، لأنه يحدد أين تحتاج أدوات أخرى غير الأنواع.
الخطأ B8 مر لأن الكود صحيح من ناحية الأنواع، فالدالة تنتظر رقمين وأخذت رقمين. والمترجم لا يعرف أن الرقم الأول «مجموع» والثاني «نسبة»، لأن الأنواع البنيوية لا تفرق بين رقمين. وهناك ثلاث طرق لتقليل هذا النوع من الأخطاء. الأولى ما فعلناه في الإصلاح: معاملات مسماة في كائن، وهي أبسط الطرق وأوضحها لمن يقرأ الكود. والثانية الأنواع المميزة Branded Types، حيث تعرف نوعاً مثل type Riyals = number & { __brand: "Riyals" } فلا يقبل المترجم رقماً عادياً مكانه إلا بعد أن يمر على دالة تنشئه، وهي مفيدة في المبالغ والمعرفات IDs التي تتشابه أنواعها. والثالثة، وهي التي لا غنى عنها، الاختبار Test الذي يقول «خصم 10% من 90 يعطي 81»، فهذا الاختبار يكشف B8 وكل أخطاء المنطق الأخرى التي لا تعرف عنها الأنواع شيئاً، مثل أن يكتب أحدهم النسبة 0.15 وهو يقصد 15%. وقد شرحنا أنواع الاختبارات ومتى تكتب كلاً منها في مقال أنواع الاختبارات.
أما B9 فمر لسبب أخطر، وهو أن الدالة parseProducts تقول للمترجم إنها ترجع Product[]، والمترجم صدقها، لأن JSON.parse ترجع النوع any، وany يقبل أن يسند إلى أي نوع دون فحص. أي أن الكود قال «أعدك أن هذه منتجات»، والسيرفر أرسل أسعاراً نصية، ولا أحد تحقق. وهذا هو الحد Boundary بين كودك والعالم الخارجي، وكل قيمة تعبر هذا الحد لا يعرف عنها المترجم إلا ما تقوله له أنت. والصورة التالية تبين أهم هذه الحدود في تطبيق الواجهة:

والإصلاح الذي في المجلد fixed يتكون من أمرين. الأول أن نتيجة JSON.parse تسند إلى متغير من النوع unknown وليس any، والفرق بينهما كبير، فـ any يعني «لا تفحص شيئاً»، أما unknown فيعني «لا أعرف ما هذا، فلا تسمح لي باستخدامه حتى أتحقق». والثاني دالة تحقق من نوع خاص يسمى حارس النوع Type Guard، ترجع x is Product، فإذا رجعت true يضيق المترجم النوع إلى Product:
// A type guard: checks at run time what the type only promises at compile time
function isProduct(x: unknown): x is Product {
if (typeof x !== "object" || x === null) return false;
const o = x as Record<string, unknown>;
return typeof o.id === "string" && typeof o.name === "string"
&& typeof o.price === "number" && Number.isFinite(o.price)
&& Number.isInteger(o.stock);
}
export function parseProducts(json: string): Product[] {
const data: unknown = JSON.parse(json);
if (!Array.isArray(data)) throw new Error("catalogue is not an array");
data.forEach((x, i) => {
if (!isProduct(x)) throw new Error(`catalogue item ${i} is not a Product: ${JSON.stringify(x)}`);
});
return data;
}ولاحظ أن حارس النوع نفسه وعد يقطعه المطور، فإذا كتبت x is Product ونسيت فحص حقل، فسوف يصدقك المترجم. لذلك فإن كتابة هذه الدوال يدوياً لكل شكل في المشروع متعبة وعرضة للنسيان، وفي المشاريع الحقيقية تستخدم الفرق مكتبة تحقق مثل Zod، حيث تكتب المخطط Schema مرة واحدة، فتحصل منه على دالة التحقق وعلى النوع معاً، فلا يمكن أن يختلفا. وأياً كانت الطريقة، فالقاعدة واحدة: تحقق من البيانات مرة واحدة عند الحد، ثم ثق بالأنواع داخل الكود.
وأين تقع هذه الحدود في تطبيق واجهة عادي؟ أولها ردود الـ API، وهي الأهم، لأن السيرفر قد يتغير دون أن تتغير الواجهة، وقد شرحنا شكل الطلب والرد في مقال HTTP. وثانيها حقول النماذج، فكل قيمة في input.value نص، حتى لو كان الحقل type="number". وثالثها localStorage ورابط الصفحة، لأن المستخدم يستطيع تعديلهما بيده، وقد تكون القيمة المحفوظة من إصدار قديم من تطبيقك. ورابعها مكتبات JavaScript التي لا تأتي بملفات تعريف، أو تأتي بملفات تعريف خاطئة، وهذا يحدث أكثر مما تتوقع.
as في TypeScript ليست تحويلاً، وإنما أمر للمترجم بأن يتوقف عن الفحص. فعندما تكتب await res.json() as Product[] فأنت لم تتحقق من شيء، وإنما قلت للمترجم «صدقني»، وهذا بالضبط ما حدث في B9. ابحث في مشروعك عن as وany و! بعد المتغيرات، فكل واحدة منها مكان قد يمر منه خطأ دون أن يراه المترجم.كيف تنقل مشروع JavaScript قائماً إلى TypeScript؟
بالعودة إلى المتجر في بداية المقال، فالفريق لا يستطيع أن يتوقف شهراً ليعيد كتابة كل شيء، والخبر الجيد أن TypeScript مصممة للنقل التدريجي، ويشرح دليلها هذا في صفحة النقل من JavaScript. والترتيب الذي ينصح به كما يلي:
- أضف الفحص إلى JavaScript نفسها أولاً. فعل
allowJsوcheckJsفيtsconfig.json، أو ضع التعليق// @ts-checkفي أعلى ملف واحد، وسوف يفحص المترجم ملفات.jsمستنتجاً الأنواع من الكود ومن تعليقات JSDoc مثل/** @param {number} total */، وهذا يعطيك جزءاً من الفائدة دون أن تغير امتداد أي ملف. ويشرح الدليل ذلك في صفحة TypeScript لملفات JavaScript. - ابدأ بالأشكال المشتركة. اكتب ملف الأنواع الذي يصف بيانات النظام: المنتج والطلب والمستخدم وردود الـ API، فهذا الملف وحده يغطي أغلب الأخطاء من نوع B2، لأن كل ملف يستورده يصبح محكوماً بنفس الأسماء.
- انقل الملفات من الأطراف إلى المركز. ابدأ بالدوال المساعدة التي لا تعتمد على غيرها، ثم الوحدات التي تستخدمها، فكل ملف تنقله يجعل ما يعتمد عليه أدق.
- فعل الخيارات الصارمة واحداً بعد الآخر. ابدأ بـ
noImplicitAny، ثمstrictNullChecksوهو الأكثر فائدة والأكثر أخطاء، ثمstrictكاملاً. وإذا كان عدد الأخطاء كبيراً فبعض الفرق تفعل الخيار للملفات الجديدة فقط، ثم تنقل الملفات القديمة على مراحل. - أضف
tsc --noEmitإلى CI من اليوم الأول، حتى لو كان مع خيارات متساهلة، فالفحص الذي لا يوقف الدمج لا يلتزم به أحد بعد أسبوعين.
ولا تنس ملفات التعريف Declaration Files الخاصة بالمكتبات، فأغلب المكتبات اليوم تأتي بأنواعها داخلها، والباقي له حزمة منفصلة باسم @types/اسم-المكتبة. ويشرح الدليل كيف تكتب ملف .d.ts لمكتبة ليس لها أنواع في صفحة ملفات التعريف، وتذكر أن هذا الملف وعد آخر لا يتحقق منه أحد، فإذا كتبته خطأ فسوف يصدقه المترجم.
كم تكلف TypeScript؟ نقاط القوة ونقاط الضعف
لكل أداة ثمن، ومن الأمانة أن نذكر ثمن TypeScript بجانب فائدتها، حتى يكون قرار فريقك مبنياً على الصورة كاملة.
نقاط القوة: أولها ما رأيته في التجربة، فسبعة أخطاء من تسعة ظهرت قبل التشغيل مع رقم السطر وشرح المشكلة، وبعضها مثل الـ await المنسية صعب جداً أن تراه بالعين. وثانيها أن الأنواع توثيق لا يتقادم، فتعريف CartItem يقول للمطور الجديد شكل البيانات، والمترجم يضمن أنه ما زال صحيحاً، وهذا لا يحدث مع التعليقات وملفات التوثيق. وثالثها إعادة التسمية Refactoring الآمنة، فعندما تغير اسم حقل في النوع يظهر لك المترجم كل مكان يجب تعديله، وفي مشروع من مئات الملفات هذا وحده يكفي لكثير من الفرق. ورابعها أدوات المحرر، فالإكمال التلقائي والانتقال إلى التعريف والبحث عن الاستخدامات كلها تعتمد على نفس محلل الأنواع.
نقاط الضعف: أولها خطوة بناء إضافية، فالمتصفح لا يفهم TypeScript، ولا بد من أداة تحذف الأنواع قبل التشغيل، مع ملفات إعداد يجب أن يفهمها أحد في الفريق. وثانيها وقت الفحص في المشاريع الكبيرة، وهذا ما جاء TypeScript 7 لحله، حيث يذكر الفريق في إعلان الإصدار أن فتح ملف فيه خطأ في مشروع VS Code كان يستغرق قرابة 17.5 ثانية حتى يظهر الخطأ، وصار أقل من 1.3 ثانية. وثالثها منحنى التعلم، فالأنواع البسيطة سهلة، ولكن أنواع المكتبات المتقدمة مثل الأنواع المشروطة Conditional Types والأنواع المعممة Generics المتداخلة قد تصبح لغة برمجة كاملة داخل اللغة، ويضيع فيها المطور وقتاً في إقناع المترجم بدلاً من حل المشكلة. ويشرح الدليل هذه الأدوات في فصل الأنواع من الأنواع، والأنسب أن تستخدمها في المكتبات المشتركة فقط، وأن يبقى كود التطبيق بسيطاً. ورابعها الثقة الزائدة، وهي أخطر نقطة، فالفريق الذي يرى المترجم ناجحاً قد يقلل الاختبارات أو يترك التحقق عند الحدود، ثم يتفاجأ بخطأ مثل B9 في الإنتاج.
ومتى لا تحتاج TypeScript؟ سكربت صغير من مئة سطر يكتبه شخص واحد ويشغله مرة، أو نموذج أولي Prototype سوف يرمى بعد أسبوع، وفي هذه الحالات قد يكفيك // @ts-check مع تعليقات JSDoc إذا أردت بعض الفحص دون خطوة بناء. أما مشروع واجهة يعمل عليه أكثر من مطور، ويتوقع أن يعيش أكثر من بضعة أشهر، فالحساب في صالح TypeScript بكل تأكيد.
أخطاء شائعة
- استخدام
anyلإسكات المترجم. كلanyيطفئ الفحص في كل مكان تصل إليه القيمة، وليس في السطر الذي كتبتها فيه فقط. وإذا لم تعرف النوع فاستخدمunknown، وتحقق قبل الاستخدام. - الثقة بـ
asعلى ردود الـ API.res.json() as Orderلا يتحقق من شيء، وهذا هو B9 بالضبط. تحقق عند الحد بدالة أو مكتبة تحقق. - علامة التعجب
!بعد كل قيمة قد تكون فارغة.findProduct(id)!.priceيقول للمترجم «أنا متأكد أنها موجودة»، فيعود B1 كما كان. استخدم الشرط أو?.مع قيمة بديلة. - إطفاء
strictبعد الترقية بسبب كثرة الأخطاء ثم نسيانه. هذه الأخطاء موجودة في الكود أصلاً، والإطفاء يخفيها فقط. أطفئه مؤقتاً مع خطة لتفعيل الخيارات واحداً بعد الآخر. - الاعتماد على
tscفي المحرر فقط. المحرر يفحص الملفات المفتوحة، أما الخطأ الذي يظهر في ملف آخر بسبب تغييرك فلن تراه إلا إذا شغلتtsc --noEmitعلى المشروع كله، والمكان الصحيح لذلك هو CI. - أداة البناء تحذف الأنواع ولا تفحصها. Vite وesbuild وNode.js بوضع الحذف تشغل الكود حتى لو كان فيه أخطاء أنواع، فإذا لم يكن في خط البناء خطوة
tscمنفصلة فأنت تكتب الأنواع للمحرر فقط. - رقمان أو نصان متجاوران بنفس النوع. مثل
transfer(from: string, to: string)، فالمترجم لا يفرق بينهما. استخدم كائناً بأسماء، أو أنواعاً مميزة للمعرفات. - تنسيق المبالغ يدوياً.
toFixed(2) + " ر.س"في التجربة يعمل، ولكنه لا يتبع قواعد تنسيق الأرقام في اللغة العربية، والأفضلIntl.NumberFormatكما شرحنا في مقال التعريب i18n، والأنواع لن تنبهك لهذا أبداً.
قائمة تحقق Checklist: هل يستفيد مشروعك من الأنواع Types فعلاً؟
الجدول التالي يجمع ما رأيناه، ومن يمسك كل نوع من الأخطاء:
| نوع الخطأ | من يمسكه | الإعداد أو الأداة |
|---|---|---|
| اسم خاصية خطأ، أو نوع خاطئ صريح | المترجم في أي وضع | tsc --noEmit |
قيمة قد تكون undefined أو null | المترجم | strictNullChecks |
| معامل بدون نوع | المترجم | noImplicitAny |
await منسية في شرط | المترجم | strictNullChecks |
حالة منسية في switch | المترجم | نوع الإرجاع، أو never في default |
| عنصر غير موجود في مصفوفة | المترجم | noUncheckedIndexedAccess |
| قيمتان بنفس النوع بترتيب معكوس | شكل الدالة والاختبارات | معاملات مسماة، أنواع مميزة، Unit Tests |
| بيانات من الخارج بشكل مختلف | التحقق وقت التشغيل | unknown مع Type Guard أو Zod |
| خطأ في المنطق أو في الحساب | الاختبارات والمراجعة | Unit وIntegration Tests |
وقبل أن تقول إن مشروعك «مكتوب بـ TypeScript» راجع النقاط التالية:
- هل
strictمفعل، أو توجد خطة مكتوبة لتفعيله؟ - هل خطوة
tsc --noEmitتعمل في CI وتوقف الدمج عند الفشل؟ - هل كل رد من الـ API وكل قيمة من
localStorageتمر على تحقق قبل أن تأخذ نوعها؟ - كم مرة تظهر
anyوasو!في الكود، وهل كل واحدة منها لها سبب مكتوب؟ - هل
erasableSyntaxOnlyمفعل إذا كان الكود يعمل بأداة تحذف الأنواع ملفاً بملف؟ - هل يوجد اختبار لكل حساب مالي، بغض النظر عن الأنواع؟
تدريب عملي
- أضف الحالة
"refunded"إلى النوعOrderStatusفي المجلدfixed، وشغلtsc، واقرأ رسالة الخطأ عند السطر الذي فيهnever. - اكتب نوعاً مميزاً
RiyalsونوعاًPercent، وغيرapplyCouponلتأخذهما، ثم حاول أن تمرر الرقمين بترتيب معكوس، ولاحظ أين يعترض المترجم. - استبدل دالة
isProductبمخطط Zod، وقارن عدد الأسطر، وما يحدث إذا أضفت حقلاً جديداً إلىProductونسيت تحديث التحقق في كل طريقة. - غير
JSON.parse(json)في النسخة الخاطئة إلىJSON.parse(json) as unknownثم أسنده إلىProduct[]، وشغلtsc، ولاحظ أن الخطأ B9 صار مكشوفاً.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- TypeScript تفحص افتراضاتك عن شكل البيانات قبل التشغيل، ثم تحذف كل الأنواع، فما يعمل في المتصفح أو Node.js هو JavaScript عادية ليس فيها أي فحص.
- في التجربة رمت ثلاثة أخطاء فقط من تسعة استثناء عند التشغيل، والستة الباقية أعطت قيمة خاطئة بصمت، مثل كوبون جعل فاتورة 90 ريالاً بريال واحد.
- الوضع المتساهل كشف خطأين، والوضع الصارم ستة، ومع
noUncheckedIndexedAccessسبعة، وstrictصار مفعلاً افتراضياً منذ TypeScript 6.0. - الخطآن اللذان فاتا كل الإعدادات هما رقمان بنفس النوع بترتيب معكوس، وبيانات JSON بشكل غير متوقع، والأول علاجه شكل الدالة والاختبارات، والثاني التحقق وقت التشغيل عند الحدود.
anyوasو!أماكن يتوقف فيها الفحص، وunknownمع حارس نوع هو الطريقة الصحيحة لاستقبال ما لا تعرفه.- الصيغ التي تولد كوداً مثل
enumلا تعمل مع أدوات الحذف فقط، والخيارerasableSyntaxOnlyيمنعها من البداية.
وفي مرحلة هندسة الواجهات وتطبيقات الويب تجد المقالات التي تكمل هذا المقال، من حلقة الأحداث التي تفسر الـ await المنسية إلى أطر الواجهات التي تبنى اليوم بـ TypeScript:
دليلحلقة الأحداث Event Loop: لماذا تتأخر النقرة في صفحتك؟كيف ترتب JavaScript المهام والوعود والرسم على خيط واحد، ولماذا تنتظر النقرة خلف مهمة طويلة، وكيف تقسم العمل لتبقى الصفحة سريعة.
دليلكيف يرسم المتصفح صفحتك؟ من HTML إلى البكسلاتماذا يفعل المتصفح بين وصول HTML وظهور الصفحة، ولماذا تتجمد صفحة فيها ألفا صف، وكيف تقيس كل مرحلة في Chrome وتصلحها.
دليلReact و Vue و Svelte: إدارة الحالة State وكلفة كل نقرةبنينا نفس التطبيق العربي بألف منتج في React وVue وSvelte وقسنا حجم الحزمة وعدد مرات إعادة الرسم مع كل نقرة، لتعرف أين تضع الحالة وكيف تختار الإطار.
دليلالتعريب i18n للمطورين: لماذا ينقلب النص العربي في الواجهة؟لماذا ينقلب النص المختلط في الواجهة العربية وكيف تعزله، وكيف تنسق الأرقام والتواريخ وصيغ الجمع العربية بأدوات المتصفح نفسها.
دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.
دليلكيف تصمم API يبقى متوافقاً مع عملائه بعد سنةمبادئ تصميم API يتطور دون أن يعطل التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة بالمترجم TypeScript 7.0.2 وNode.js v22.14.0 على Windows 11.