EduFlow
أنماط TypeScript المتقدّمة
⁦0%⁩

الوحدة 4 · أنماط لقواعد كود حقيقية

تحليل البيانات غير الموثوقة عند الحدود

20 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

عامل كل ما يأتي من خارج برنامجك على أنه unknown، وحوّله إلى أنواع مجال موثوقة بالمحلّلات والـ type predicates ودوال التأكيد، وأبقِ التحويلات خارج كود القلب.

  1. 1ابدأ من unknown
  2. 2محلّل مكتوب يدويًا
  3. 3الـ type predicates
  4. 4دوال التأكيد

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

في تصميم عميل API بأنواع دقيقة، انتهى العميل بـ return result as Routes[R]['output']. ذلك التحويل كان وعدًا بشأن الخادم. وهذا الدرس عن إبقاء ذلك الوعد صادقًا.

لكل برنامج حدود (boundary): المواضع التي تأتي منها البيانات من مكان لا يستطيع الـ compiler رؤيته. استجابات HTTP، وJSON.parse، ومعاملات الروابط، وlocalStorage، وحقول النماذج، والرسائل من نوافذ أخرى، ومتغيّرات البيئة. داخل الحدود، تصف الأنواع قيمًا أنشأها كودك. وعند الحدود، الأنواع مجرّد آمال. وللنمط الذي يتعامل مع ذلك شعار معروف: حلّل، لا تتحقّق فقط (parse, don’t validate). حوّل البيانات غير المحدَّدة الأنواع إلى بيانات محدَّدة الأنواع مرة واحدة، عند الحافة، ودع بقية البرنامج يثق بالأنواع.

على اليسار، مصادر من خارج البرنامج: استجابات fetch، وJSON.parse، ومعاملات الروابط، وlocalStorage، كلها من نوع unknown. تمرّ عبر محلّلات عند الحدود، تُعيد إمّا خطأً أو Order محدَّد النوع. وداخل الحدود، لا يرى كود الدفع الأساسي إلا أنواعًا موثوقة. الخارج: unknown res.json() JSON.parse معاملات URL localStorage parseOrder() افحص، ثم سِم err(…) الداخل: موثوق Order بلا تحويلات ولا فحوص
تصبح البيانات غير الموثوقة بيانات محدَّدة الأنواع عند بوابة واحدة

ابدأ من unknown

JSON.parse تُعيد any، وكذلك Response.json(). وany معدية: أسندها إلى متغيّر نوعه Order فيصدّقك TypeScript. الخطوة الأولى أن ترفض هذه الهدية:

TypeScript
const raw: unknown = await res.json();
raw.status;
// Error: 'raw' is of type 'unknown'.

unknown هو النوع الصادق للبيانات التي لم تفحصها. يمكنك تمريرها، لكن لا يمكنك استخدامها حتى تضيّقها. والتضييق هو ما يفعله المحلّل.

محلّل مكتوب يدويًا

يأخذ المحلّل unknown ويُعيد إمّا قيمة محدَّدة النوع أو وصفًا لما كان خاطئًا. مع نوع Result من الدرس السابق:

TypeScript
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:

TypeScript
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 يضيّق كل ما يلي الاستدعاء:

TypeScript
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) تضيّق كل ما يلي الاستدعاء، ويجب أن ترمي خطأً حين يفشل الفحص.
  • مكتبات المخطّطات تعلن الفحص مرة واحدة وتشتقّ النوع منه، فتُزيل خطر التباعد بين المحلّل والنوع.

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

const data = await res.json(); ما نوع data، وماذا يجب أن تفعل حياله؟

السؤال 2 من 4

ما الخطأ في هذا الـ predicate؟

TypeScript
function isOrder(value: unknown): value is Order {
  return typeof value === 'object' && value !== null && 'id' in value;
}
السؤال 3 من 4

كيف تختلف دالة التأكيد عن الـ type predicate؟

السؤال 4 من 4

أين يجب تحليل القيمة القادمة من localStorage.getItem('cart')؟

تدريب

بيئة تجريب الكود

حلّل الطلب عند الحدود

استبدل التحويل الكاذب في parseOrder بمحلّل حقيقي، وأعطِ الدوال المساعدة أنواعًا حقيقية:

  1. تصبح isRecord وisOrderStatus من نوع type predicates.
  2. تُعيد parseOrder(input: unknown) القيمة ok(order) فقط حين يكون id عددًا صحيحًا، وstatus حالة معروفة، وtotalCents عددًا صحيحًا، وcouponCode نصًا أو null. وإلا فإنها تُعيد err(message)، حيث تبدأ الرسالة باسم الحقل الخاطئ (مثلًا status: …). ويجب ألّا يحتوي الطلب المُعاد إلا هذه الحقول الأربعة.
  3. تصبح assertIsOrder دالة تأكيد تضيّق وسيطها إلى Order وترمي Error تحتوي رسالته Invalid order حين يفشل التحليل.

بلا تحويلات as في parseOrder.

type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
const ok = <T>(value: T): { ok: true; value: T } => ({ ok: true, value });
const err = <E>(error: E): { ok: false; error: E } => ({ ok: false, error });

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): boolean {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isOrderStatus(value: unknown): boolean {
  return typeof value === 'string' && (STATUSES as readonly string[]).includes(value);
}

// This "parser" trusts whatever the server sends.
function parseOrder(input: unknown): Result<Order, string> {
  return ok(input as Order);
}

function assertIsOrder(input: unknown): void {
  const parsed = parseOrder(input);
  if (!parsed.ok) throw new Error(`Invalid order: ${parsed.error}`);
}

// ---- Type tests (do not edit) ----
function typeTests(x: unknown, y: unknown) {
  if (isRecord(x)) {
    type T1 = Expect<Equal<typeof x, Record<string, unknown>>>;
  }
  if (isOrderStatus(x)) {
    type T2 = Expect<Equal<typeof x, OrderStatus>>;
  }
  assertIsOrder(y);
  type T3 = Expect<Equal<typeof y, Order>>;
}

const fromServer: unknown = JSON.parse('{"id":1042,"status":"refunded","totalCents":18900,"couponCode":null}');
const parsed = parseOrder(fromServer);
console.log(parsed.ok ? parsed.value : parsed.error);
جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

الـ console

(لا مخرجات)

الفحوص

  • الملف يجتاز فحص الأنواع بلا أي خطأ، بما فيها كل اختبارات الأنواع
  • يُقبل الطلب الصالح
  • تُرفض الحالة المجهولة برسالة تسمّي status
  • تُرفض أنواع الحقول الخاطئة والمصفوفات وnull برسائل تسمّي الحقل
  • لا تُنسخ الحقول غير المتوقَّعة إلى الطلب المحلَّل
  • assertIsOrder ترمي ‘Invalid order…’ مع البيانات السيئة