Narrowing

محدود کردن نوع متغیر به یک نوع خاص تر بر اساس بررسی های منطقی

در تایپ اسکریپت، Narrowing به فرآیندی گفته می‌شود که در آن نوع (type) یک مقدار از یک حالت کلی‌تر (مثل string | number) به حالتی خاص‌تر (مثل فقط string یا فقط number) محدود می‌شود، تا بتوانیم با اطمینان از ویژگی‌های آن نوع استفاده کنیم.

// Ex:
function printPadding(padding: string | number) {
   if (typeof padding === "string") {
     // اینجا padding فقط یک رشته است
     console.log(padding.toUpperCase());
   } else {
     // اینجا padding فقط یک عدد است
     console.log(padding.toFixed(2));
   }
 }

typeof type guards

تمام نوع های زیر توسط عملگر typeof پشتیبانی می شوند. این عملگر نوع یک مقدار را به صورت رشته‌ای بر می‌گرداند.

  • string
  • number
  • bigint
  • boolean
  • symbol
  • undefined
  • object
  • function

این نکته را مدنظر داشته باشید که نوع مقدار null وArray در جاوا اسکریپت object است عملگر هایی مانند == ، === ، != ، !== و دستور switch کمک می کنند که نوع یک مقدار در یک بلوک خاص مشخص شود.

Truthiness narrowing

در جاوااسکریپت هر مقدار در موقعیت هایی مانند if، &&، ||، ! که مقدار boolean لازم هست، به true یا false تبدیل می‌شود. به این ویژگی truthiness می‌گویند.

function getUsersOnlineMessage(numUsersOnline: number) {
  if (numUsersOnline) {
    return `There are ${numUsersOnline} online now!`;
  }
  return "Nobody's here. :(";
}

در جاوااسکریپت، ساختارهای شرطی مثل if ابتدا مقدار شرط را به boolean تبدیل می‌کنند (coercion) و سپس مسیر اجرایی را بر اساس true یا false انتخاب می‌کنند.

مقادیر زیر در شرط بالا به false تبدیل می‌شوند و بقیهٔ مقادیر به true:

  • 0
  • NaN
  • "" (رشتهٔ خالی)
  • 0n (صفر از نوع bigint)
  • null
  • undefined

می‌توانید با تابع Boolean یا با دو عملگر نقیض !! این تبدیل را صراحتاً انجام دهید.(پیشنهاد می‌شود روش دوم را استفاده کنید تا تایپ اسکریپت نوع آن را دقیقا تشخیص دهد)

Boolean("hello"); // مقدار: true,  نوع: boolean,       مقادیر مجاز: true یا false
!!"world";        // مقدار: true,  نوع: literal true,  مقادیر مجاز: true

استفاده از این رفتار بسیار رایج است، به‌ویژه برای کنترل مقادیر null یا undefined. به عنوان مثال تابع printAll را با این روش می‌نویسیم:

function printAll(strs: string | string[] | null) {
  if (strs && typeof strs === "object") {
    for (const s of strs) {
      console.log(s);
    }
  } else if (typeof strs === "string") {
    console.log(strs);
  }
}

با بررسی truthiness بودن strs، از خطای TypeError: null is not iterable جلوگیری کردیم. اما کنترل truthiness بودن مقادیر می‌تواند خطا هم داشته باشد. به مثال زیر دقت کنید:

function printAll(strs: string | string[] | null) {
  if (strs) {
    if (typeof strs === "object") {
      for (const s of strs) {
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);
    }
  }
}

تمام کد ها را در if truthiness قرار دادیم. مشکل اینجاست که یک رشته خالی چون truthy نیست، چاپ نمی شود.

Equality narrowing

TypeScript از دستور switch و عملگرهای برابری (===, !==, ==, !=) نیز برای محدود کردن type استفاده می‌کند. برای مثال:

function example(x: string | number, y: string | boolean) {
  if (x === y) {
    // حالا می‌توانیم روی x یا y از همه متد های رشته استفاده کنیم
    x.toUpperCase();
    y.toLowerCase();
  } else {
    console.log(x);    // x: string | number
    console.log(y);    // y: string | boolean
  }
}

وقتی بررسی کردیم که x و y برابرند، TypeScript فهمید نوع آن‌ها نیز باید یکسان باشد. از آنجا که string تنها نوع مشترک بین x و y است، پس در شاخهٔ اول قطعاً هر دو string خواهند بود.

در بخش قبلی که دربارهٔ «narrowing با truthiness» صحبت کردیم، تابع printAll را نوشتیم که به‌خاطر استفاده از if (strs) رشتهٔ خالی ("") را اشتباهاً کنار می‌گذاشت.برای رفع این مشکل، به‌جای تکیه به truthiness، می‌توانیم به‌طور خاص فقط null را حذف کنیم. با این کار مقدار null را از لیست type های strs حذف می‌کنیم و فقط string | string[] باقی می‌ماند.

function printAll(strs: string | string[] | null) {
  if (strs !== null) {
    if (typeof strs === "object") {
      for (const s of strs) {

(parameter) strs: string[]
        console.log(s);
      }
    } else if (typeof strs === "string") {
      console.log(strs);

(parameter) strs: string
    }
  }
}

The in operator narrowing

این عملگر وجود یک پراپرتی را در type مدنظر بررسی می کند، و true یا false را بر می گرداند.

در مثال زیر بررسی شده است که آیا پراپرتی swim در object animal وجود دارد یا خیر. با این کار مشخص می شود که داخل بلوک if ، object animal از نوع Fish است و به پس بنابراین میتوان گفت که عملیات narrowing در این قسمت انجام شده است.

  type Fish = { swim: () => void };
  type Bird = { fly: () => void };

  function move(animal: Fish | Bird) {
    if ("swim" in animal) {
      return animal.swim();
    }

    return animal.fly();
  }

instanceof narrowing

در جاوا اسکریپت عملگر instanceof بررسی می‌کند که مقدار مورد نظر، نمونه ای از نوع مشخص شده هست یا نه.

در مثال زیر ، جاوااسکریپت، x instanceof Foo بررسی می‌کند که آیا x نمونه‌ای از Foo است یا نه. در TypeScript، این بررسی به TypeScript کمک می‌کند تا نوع x را درون بلاک if محدودتر کند.

  function logValue(x: Date | string) {
    if (x instanceof Date) {
      // x به عنوان Date شناخته شده و متدهای Date در دسترس‌اند
      console.log(x.toUTCString());
    } else {
      // x به عنوان string شناخته شده
      console.log(x.toUpperCase());
    }
  }

Assignments

تایپ اسکریپت همیشه هنگام مقدار دهی های بعدی متغیر ها ، بررسی می‌کند که مقدار اختصاص یافته به آن، با نوع اصلی متغیر هنگام تعریفش مطابقت دارد یا نه.

  let x = Math.random() < 0.5 ? 10 : "hello world!";

  x = 1;
  x = "goodbye!";
  x = true;  // در این قسمت از کد خطا ارسال میشود. چون که متغیر x فقط میتواند مقادیر number و string را قبول کند

Control flow analysis

تایپ اسکریپت فقط به یک شرط نگاه نمی‌کند بلکه کل جریان منطقی اجرا را نگاه می‌کند (مانند return, if-else, switch-case, break, continue...) تا در هر موقعیتی دقیق ترین نوع مقدار یا متغیر را تشخیص دهد.

Using type predicate

Type Predicate در تایپ اسکریپت عبارتی است که به کامپایلر می‌گوید اگر یک تابع مقدار true برگرداند، نوع یک متغیر را به‌صورت دقیق‌تر (narrowed) در نظر بگیرد. یعنی بعد از return true, نوع بازگشتی تابع ، نوعی است که در type predicate مشخص شده است.

  function functionName(param: SomeType): param is NarrowedType {
    // logic to check if param is NarrowedType
}

به مثال زیر توجه کنید:

  interface Car {
    drive: () => void;
    wheels: number;
  }

  interface Bike {
    pedal: () => void;
    wheels: number;
  }

  // این قسمت مهم‌ترین بخش است: type predicate. یعنی اگر این تابع true برگرداند، TypeScript می‌فهمد که vehicle از نوع Car است.
  function isCar(vehicle: Car | Bike): vehicle is Car {
    // این یک بررسی ساده است: اگر ویژگی drive در vehicle وجود داشته باشد، پس حتماً vehicle از نوع Car است. چون فقط Car این ویژگی را دارد.
    return (vehicle as Car).drive !== undefined;
  }

  // استفاده از آن
  function useVehicle(vehicle: Car | Bike) {
    if (isCar(vehicle)) {
      vehicle.drive(); // اینجا vehicle از نوع Car است
    } else {
      vehicle.pedal(); // اینجا vehicle از نوع Bike است
    }
  }

Assertion Functions

در تایپ‌اسکریپت، توابع تایید (Assertion Functions) توابع خاصی هستند که اگر شرط خاصی برقرار نباشد، بلافاصله با پرتاب یک AssertionError اجرای برنامه را متوقف می‌کنند. این کار در Node.js با تابع آمادهٔ assert شناخته می‌شود:

import assert from 'node:assert/strict';
assert(someValue === 42);

در این مثال اگر someValue با مقدار 42 برابر نباشد، خطای AssertionError رخ می‌دهد تا از ادامه اجرای اشتباه جلوگیری شود.

هدف تایپ‌اسکریپت این است که کمترین تغییر را در کدهای جاوااسکریپت موجود ایجاد کند. از این‌رو در TypeScript 3.7 مفهومی جدیدی به نام امضاء های تایید (assertion signature) معرفی شد.

روش اول: شبیه assert در Node.js

این روش تضمین می‌کند که تا پایان محدوده فعلی، آن شرط حتماً درست است.

نحوه نگارش این روش:

function assert(condition: any, msg?: string): asserts condition {
  if (!condition) {
    throw new AssertionError(msg);
  }
}

عبارت asserts condition می‌گوید اگر تابع بدون خطا برگردد، پس شرط condition قطعاً درست است. در نتیجه در ادامه‌ی کد، کامپایلر می‌تواند روی درست بودن آن حساب کند.

function yell(str) {
  assert(typeof str === "string");
  return str.toUppercase();
  //         ~~~~~~~~~~~
 //  خطا: ویژگی 'toUppercase' در نوع 'string' وجود ندارد.
 // آیا منظور شما 'toUpperCase' بود؟
}

اینجا برخلاف مثال قبلی، تایپ‌اسکریپت خطای املای متد را می‌گیرد، چون پس از assert می‌داند که str یک string است.

روش دوم: بررسی نوع متغیر

این روش تعیین می‌کند که یک متغیر، type مشخصی دارد.

function assertIsString(val: any): asserts val is string {
  if (typeof val !== "string") {
    throw new AssertionError("Not a string!");
  }
}

asserts val is string تضمین می‌کند که بعد از فراخوانی این تابع، متغیری که به آن پاس داده ایم از نوع string خواهد بود.

function yell(str: any) {
  assertIsString(str);
  // از اینجا به بعد، تایپ‌اسکریپت می‌داند که 'str' یک string است'.
  return str.toUppercase();
  //         ~~~~~~~~~~~
 //  خطا: ویژگی 'toUppercase' در نوع 'string' وجود ندارد.
 //  آیا منظور شما 'toUpperCase' بود؟
}

این روش خیلی شبیه به پیش‌بین های نوع (type predicates) است.

The never type

در فرآیند narrowing (محدودسازی نوع)، ممکن است یک نوع union (اتحاد چند نوع) آنقدر محدود شود که دیگر هیچ گزینه‌ای باقی نماند. در چنین حالتی، تایپ‌اسکریپت از نوع ویژه‌ای به نام never استفاده می‌کند.

برای اطمینان از پوشش تمام حالات یک union، از نوع never استفاده می‌کنیم:

type Status = "loading" | "success" | "error";
function handleStatus(status: Status) {
  if (status === "loading") { /* ... */ }
  else if (status === "success") { /* ... */ }
  else if (status === "error") { /* ... */ }
  else {
    // خطا اگر حالتی جا مانده باشد
    const _exhaustiveCheck: never = status;
  }
}

در این مثال همه حالت های union پوشش داده شده است. اگر روزی یک status جدید اضافه شود که ما آن را پوشش نداده باشیم، خطای کامپایل ظاهر می‌شود؛ چون که status با یک مقدار string به متغیر _exhaustiveCheck از نوع never پاس داده شده است. با وجود این خطا متوجه می‌شویم که باید مقدار جدید status را در if ها پوشش دهیم.

Discriminated unions

در تایپ‌اسکریپت، Discriminated Unions به شما اجازه می‌دهند تا با استفاده از یک ویژگی مشترک (Discriminant)، اعضای مختلف یک Union Type را از هم تفکیک کنید. این کار به کامپایلر تایپ‌اسکریپت کمک می‌کند تا بداند در هر بخش از کد، با کدام نوع از اعضای union سر و کار دارد و از بروز خطاهای احتمالی جلوگیری می‌کند.

فرض کنید می‌خواهیم شکل هایی مثل دایره و مربع را مدل سازی کنیم:

  • دایره شعاع(radius) دارد.
  • مربع طول ضلع(sideLength) دارد.

برای تشخیص نوع شکل از یک فیلد به نام kind استفاده می‌کنیم. رویکرد اول(اشتباه) این است که تمام اَشکال را به صورت union در kind قرار داده و ویژگی های آنها را به صورت اختیاری(optional) در یک interface تعریف کنیم:

interface Shape {
  kind: "circle" | "square";
  radius?: number;
  sideLength?: number;
}

مشکل اصلی زمانی است که می‌خواهیم تابع محاسبه مساحت را بسازیم:

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    return Math.PI * shape.radius ** 2;
    // ❌ 'shape.radius' is possibly 'undefined'.
  }
}

با وجود اینکه نوع شکل بررسی شده است که حتما circle باشد، اما چون تایپ اسکریپت نمی‌داند radius حتما در shape وجود دارد؛ بنابراین خطا می‌دهد.

رویکرد صحیح این است که شکل های مختلف را با مشخصات مختلف از یکدیگر جدا کنیم.

interface Circle {
  kind: "circle";
  radius: number;
}

interface Square {
  kind: "square";
  sideLength: number;
}

حالا تمام اَشکال را در یک union قرار می‌دهیم. با این کار تایپ اسکریپت به خوبی متوجه می‌شود که هر شکل شامل چه ویژگی هایی است:

type Shape = Circle | Square;

در این مرحله تایپ‌اسکریپت می‌تواند از ویژگی kind به عنوان تفکیک کننده(discriminant) استفاده کند.

وقتی که ما kind را بررسی می‌کنیم، تایپ‌اسکریپت به صورت هوشمند نوع شکل را محدود(narrowing) می‌کند و بعد از آن تمام ویژگی های متعلق به شکل را می‌شناسد.

function getArea(shape: Shape) {
  if (shape.kind === "circle") {
    // در این بخش، تایپ‌اسکریپت می‌داند که shape از نوع Circle است
    return Math.PI * shape.radius ** 2;
  }
  // ...
}

Exhaustiveness checking

در تایپ اسکریپت ، بررسی کامل (exhaustiveness checking) به ما کمک می کند که مطمئن شویم در یک ساختار کنترلی مانند switch همه حالت ها برای یک نوع union کنترل شود.

  type Shape =
    | { kind: "circle"; radius: number }
    | { kind: "square"; sideLength: number };

  function getArea(shape: Shape): number {
    switch (shape.kind) {
      case "circle":
        return Math.PI * shape.radius ** 2;
      case "square":
        return shape.sideLength ** 2;
      default:
        // اگر همه‌ی حالت‌ها بررسی شده باشند، shape از نوع never خواهد بود
        const _exhaustiveCheck: never = shape; // اگر حتی یک حالت جا مانده باشد، خطا می‌دهد
        return _exhaustiveCheck;
    }
  }

بروز خطا در قسمت default به ما اطلاع می‌دهد که مقدار جدید shape.kind را در switch پوشش دهیم.