الوحدة 4 · أنماط لقواعد كود حقيقية
تحليل البيانات غير الموثوقة عند الحدود
20 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
عامل كل ما يأتي من خارج برنامجك على أنه unknown، وحوّله إلى أنواع مجال موثوقة بالمحلّلات والـ type predicates ودوال التأكيد، وأبقِ التحويلات خارج كود القلب.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
في تصميم عميل API بأنواع دقيقة، انتهى العميل بـ return result as Routes[R]['output']. ذلك التحويل كان وعدًا بشأن الخادم. وهذا الدرس عن إبقاء ذلك الوعد صادقًا.
لكل برنامج حدود (boundary): المواضع التي تأتي منها البيانات من مكان لا يستطيع الـ compiler رؤيته. استجابات HTTP، وJSON.parse، ومعاملات الروابط، وlocalStorage، وحقول النماذج، والرسائل من نوافذ أخرى، ومتغيّرات البيئة. داخل الحدود، تصف الأنواع قيمًا أنشأها كودك. وعند الحدود، الأنواع مجرّد آمال. وللنمط الذي يتعامل مع ذلك شعار معروف: حلّل، لا تتحقّق فقط (parse, don’t validate). حوّل البيانات غير المحدَّدة الأنواع إلى بيانات محدَّدة الأنواع مرة واحدة، عند الحافة، ودع بقية البرنامج يثق بالأنواع.
ابدأ من unknown
JSON.parse تُعيد any، وكذلك Response.json(). وany معدية: أسندها إلى متغيّر نوعه Order فيصدّقك TypeScript. الخطوة الأولى أن ترفض هذه الهدية:
const raw: unknown = await res.json();
raw.status;
// Error: 'raw' is of type 'unknown'.unknown هو النوع الصادق للبيانات التي لم تفحصها. يمكنك تمريرها، لكن لا يمكنك استخدامها حتى تضيّقها. والتضييق هو ما يفعله المحلّل.
محلّل مكتوب يدويًا
يأخذ المحلّل unknown ويُعيد إمّا قيمة محدَّدة النوع أو وصفًا لما كان خاطئًا. مع نوع Result من الدرس السابق:
const STATUSES = ['processing', 'shipped', 'delivered', 'cancelled', 'returned'] as const;
type OrderStatus = (typeof STATUSES)[number];
type Order = { id: number; status: OrderStatus; totalCents: number; couponCode: string | null };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isOrderStatus(value: unknown): value is OrderStatus {
return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
}
function parseOrder(input: unknown): Result<Order, string> {
if (!isRecord(input)) return err('order: expected an object');
const { id, status, totalCents, couponCode } = input;
if (typeof id !== 'number' || !Number.isInteger(id)) return err('id: expected an integer');
if (!isOrderStatus(status)) return err(`status: unknown value ${JSON.stringify(status)}`);
if (typeof totalCents !== 'number') return err('totalCents: expected a number');
if (couponCode !== null && typeof couponCode !== 'string') return err('couponCode: expected a string or null');
return ok({ id, status, totalCents, couponCode });
}انظر إلى السطر الأخير: لا يوجد تحويل. كل فحص ضيّق متغيّرًا واحدًا، وفي النهاية يستطيع TypeScript التحقّق من أن الكائن الحرفي يطابق Order. وإن اكتسب النوع حقلًا ونسيه المحلّل، يفشل سطر ok(…) في الترجمة. وهذا أيضًا هو المكان الذي تسِم فيه القيم، باستدعاء toOrderId(id) من الـ Branded Types والفحوص الشاملة.
سدّ الفجوة في عميل الـ API
عودةً إلى العميل، إصلاح ذلك التحويل الأخير هو جدول من المحلّلات، واحد لكل مسار، يُفحص مقابل خريطة المسارات بـ mapped type وsatisfies:
const parsers = {
'GET /orders/:id': parseOrder,
'GET /orders': parseOrderPage,
'POST /orders/:id/cancel': parseCancelledOrder,
} satisfies { [R in keyof Routes]: (input: unknown) => Result<Routes[R]['output'], string> };انسَ مسارًا فتبلّغ satisfies عن المفتاح الناقص؛ وأعِد النوع الخاطئ من محلّل فتبلّغ عن ذلك أيضًا. داخل call، تمرّ الاستجابة الآن عبر parsers[route] قبل إعادتها. لا يستطيع TypeScript ربط الـ R العام بالصف المطابق في الجدول، فيبقى تعليق نوعي واحد هناك، لكنه يقع الآن على بيانات فُحصت فعلًا: أنواع مخرجات العميل مكتسَبة لا مُدّعاة.
الـ type predicates
isRecord وisOrderStatus هما type predicates (دوال حارسة للنوع): دوال نوع إرجاعها value is T. حين تُعيد إحداها true، يضيّق TypeScript الوسيط في موضع الاستدعاء. ومنذ TypeScript 5.5، تُستنتج البسيطة منها مثل (x) => x !== null، كما رأيت في القسم 1، لكن أي شيء فيه منطق حقيقي يحتاج إلى التعليق النوعي.
دوال التأكيد
أحيانًا تريد التضييق والتوقّف إن كانت البيانات سيئة، دون if في كل موضع استدعاء. دالة التأكيد (assertion function) لا تُعيد شيئًا وترمي خطأً عند الفشل، ونوع إرجاعها asserts يضيّق كل ما يلي الاستدعاء:
function assertIsOrder(input: unknown): asserts input is Order {
const parsed = parseOrder(input);
if (!parsed.ok) throw new Error(`Invalid order: ${parsed.error}`);
}
const raw: unknown = JSON.parse(cachedJson);
assertIsOrder(raw);
raw.status; // OrderStatusتناسب دوال التأكيد الأماكن التي تكون فيها البيانات السيئة خطأً برمجيًا لا حالة متوقَّعة: دوال الاختبار المساعدة، والبيانات التي عرضها خادمك في الصفحة، والثوابت. أما لاستجابات HTTP، ففضّل محلّلًا يُعيد Result، كي ينتج تغيّر الـ API خطأً مُعالَجًا لا انهيارًا. وهناك غرابة واحدة: يجب أن تُستدعى دالة التأكيد عبر اسم له نوع صريح، فـ const assertIsOrder = (x: unknown): asserts x is Order => … تحتاج إلى تعليق نوعي على الـ const نفسه، وإلا يبلّغ TypeScript عن “Assertions require every name in the call target to be declared with an explicit type annotation”.
في التمرين ستكتب محلّل الطلبات في Cartwheel وpredicate ودالة تأكيد، وتستبدل تحويلًا ظل يكذب منذ اليوم الأول. والدرس القادم يبني باعثات أحداث وبنّائين محدَّدي الأنواع فوق كل ما في هذا القسم.
أهم النقاط
JSON.parseوresponse.json()وlocalStorageتعطيكany؛ علّق نتائجها بـunknownكي يجبرك الـ compiler على الفحص.- المحلّل يأخذ
unknownويُعيد قيمة محدَّدة النوع أو خطأً، فلا يرى بقية الكود إلا بيانات فُحصت. - الـ type predicate (
value is T) يضيّق النوع في موضع الاستدعاء، لكن TypeScript يثق بجسمه، فالـ predicate الخاطئ تحويل غير مفحوص. - دالة التأكيد (
asserts value is T) تضيّق كل ما يلي الاستدعاء، ويجب أن ترمي خطأً حين يفشل الفحص. - مكتبات المخطّطات تعلن الفحص مرة واحدة وتشتقّ النوع منه، فتُزيل خطر التباعد بين المحلّل والنوع.