تایپ‌های وابسته و توابع دوسویه

ویژگی unsound بودن تایپ‌اسکریپت و دوسویه بودن پارامترهای توابع

تایپ‌های وابسته و توابع دوسویه

قبل از اینکه وارد مفهوم پارامترها و توابع دوسویه بشم، باید اول چندتا مفهوم رو توضیح بدم. مفاهیمی که به جنریک‌ها و ماهیت ریاضیاتی توابع مربوط میشن.

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

Covariance و Contravariance

کوواریانس یا Covariance در واقع به وراثت برمی‌گرده. با یک مثال توضیح میدم، اگر تایپ B از A مشتق شده باشه، یعنی B وارث A هست یا به یه شکل دیگه، A کلاس پایه‌ی B محسوب میشه. در نتیجه B تمام متد‌ها و پراپرتی‌های A رو توی خودش داره.

تو این حالت، میشه گفت که B از A بزرگتره. این ایده که B از A بزرگتره، یک ایده‌ی خیلی مهمه!

بذارید یکم ریاضیاتی‌تر توضیح بدم، اگر B از A مشتق میشه، پس میشه گفت A>B (من به جهت فلش خیلی دقت کردم، دقیقا درسته) و B هر آنچه که A داره رو توی خودش داره. حالا چون B همه‌ی چیز‌های A رو داره، ما میتونیم هرجا که از A استفاده شده، از B هم استفاده کنیم. گرچه این ارتباط اصطلاحا یک‌طرفه یا Asymmertical محسوب میشه. این ارتباط به عنوان اصل تعویض لیسکوف یا Liskov Substitution Principle هم شناخته میشه.

توابع

حالا بیاید ببینیم که این قائده چطور روی توابع هم استفاده میشه.

اگه ما یک فانکشن رو تعریف کنیم که شیئی از جنس A رو به عنوان ورودی دریافت کنه، و شیئی از جنس B رو خارج کنه، یا در واقع:

f(A) -> B and A>B

برقرار باشه، طبق همین قانون ما میتونیم به عنوان ورودی تابع شیئی از جنس B رو بدیم، و شیئی از جنس B هم دریافت کنیم. در واقع میشه گفت که f(B) جایزه. میشه نوشت:

class A {}

class B extends A {}

function f(p: A): B {
  return new B()
}

let myA: A = f(new B())

به این معنی که اگه A>B پارامتر ورودی A میتونه B هم باشه چون هرچی که A داره رو اون هم داره. پس با همین تعریف میشه گفت که پارامترهای ورودی کنتراواریانت یا Contravariant هستن و پارامترهای خروجی، کوواریانت یا Covariant.

تایپ‌اسکریپت

حالا بریم یکم در مورد زبان تایپ‌اسکریپ و یکی از ویژگی‌های کمتر شناخته شده‌ی اون صحبت کنیم، strictFunctionTypes! این آرگومان که میشه به کامپایلر پاس داد، به شما کمک میکنه که نوع دیگه‌ای از باگ‌ها رو شناسایی کنید و جلوشون رو بیگیرید.

چه باگ‌هایی رو میتونه بگیره؟

بیاین اول با یه مثال بریم جلو، یک مثال که میشه جلوش رو با استفاده از این آرگومان گرفت.

interface Post {
  title: string
}

function fetchPost(onSuccess: (post: Post) => void) {}

تو مثال بالا، fetchPost یک فانکشنه که یه callback رو قبول میکنه تا بعد از دریافت پست، اجرا بشه.

جالب اینجاست که تایپ‌اسکریپت بدون اشکال (با تنظیمات دیفالت) کد زیر رو کامپایل میکنه:

interface Post {
  title: string
}

function fetchPost(onSuccess: (post: Post) => void) {}

interface PostWithContent extends Post {
  content: string
}

fetchPost((p: PostWithContent) => {
  console.log(p.content.toLowerCase())
})

متاسفانه، این کد میتونه باعث بروز اشکال تو زمان-اجرا یا Runtime بشه. فانکشنی که به عنوان کالبک به fetchPost پاس داده شده، فقط بلده که با تایپ‌های خاصی که از Post مشتق شدن کار کنه. اونایی که خصوصا یک پراپرتی content دارن. با این وجود، fetchPost میتونه همه نوع Post رو دریافت کنه، حتی اونایی که content ندارن رو. که این باعث میشه p.content.toLowerCase() ارور undefined رو بده:

TypeError: undefined is not an object (evaluating 'p.content.toLowerCase')

اینجاست که فعال کردن strictFunctionTypes میتونه به ما کمک کنه! ارور زیر موقع کامپایل اتفاق میوفته:

Argument of type '(p: PostWithContent) => void' is not assignable to parameter of type '(post: Post) => void'.

دوسویه یا Bivariance

اگه فقط میخواستید بدونید که strictFunctionTypes چه کاری انجام میده، میتونید دیگه به خوندن ادامه ندید. با اینحال توصیه میکنم با من همراه باشید تا دلایل این ارورها رو به شما بگم.

اول بیاید یک جنریک بسازیم که نمایان‌گر یه کالبک از جنس خودش باشه:

type CB<T> = (v: T) => void

function fetchPost(onSuccess: CB<Post>) {}

declare const cb: CB<PostWithContent>

fetchPost(cb)

دلیل اینکه fetchPost نباید cb رو قبول کنه اینه که cb خیلی ویژه به یک تایپ خاص اشاره داره. در نتیجه تایپ cb نباید به تایپ onSuccess تخصیص داده یا Assign بشه.

به بیان دیگه، این واقعیت که PostWithContent میتونه به Post تخصیص داده بشه، این رو توجیه نمیکنه که CB<PostWithContent> هم به CB<Post> بتونه تخصیص داده بشه. اگر میشد چنین چیزی رو توجیه کرد، پس باید میگفتیم که Callback کوواریانت محسوب میشه.

تو مثال ما دقیقا عکسش صادقه،CB<Post> قابل تخصیص به CB<PostWothContent> هست. دلیل اینه که یک کال‌بک که میتونه تمام Postها رو هندل کنه، همچنین میتونه PostWithContent رو هم هندل کنه، بنابراین میشه گفت که CB کنتراواریانت یا Contravariant هست.

اگر هردو این اظهارات درست باشن، میشه گفت که CB دو سویه یا Bivariant محسوب میشه.

حالا یه نگاه به تعریف strictFunctionTypes کنیم:

Disable bivariant parameter checking for function types.

خب به این معنی که اگر این آپشن رو فعال کنیم، پارامترها، به شکل تکسویه کنترل میشن و نه دوسویه!

تایپ‌های وابسته و توابع دوسویه

اگر مطلبی که بالاتر گذاشتم رو خوندید، میتونیم وارد یک بحث جدید بشیم. تایپ‌های وابسته.

تایپ وابسته یا Dependent Type چیه؟

یک تایپ در حالت عادی، صرفا یک نماده برای اینکه بگه یک مقدار یا Value چه ویژگی‌هایی داره. این تایپ یا یک تایپ کامله و یا اینکه به یک تایپ دیگه نیاز داره.

برای مثال، ‍Int یک تایپ مستقل یا Standalone هست و Maybe یکی که به یک تایپ دیگه نیازمند.

Maybe برای اینکه کار کنه، نیاز داره تا یک تایپ دیگه بهش پاس داده بشه، که این باعث میشه، Maybe یک تابع باشه که تایپ میگیره و تایپ هم برمی‌گردونه.

اما تایپ‌های وابسته کمی متفاتتر هستن. اونا به چیزهایی بیشتر از صرفا تایپ وابسته هستن. منظورم از چیزهای دیگه، مقادیر هستن، یعنی مثلا تایپ یک مقدار تو یک نقطه، میتونه تابع تایپ یک مقدار دیگه تو نقطه‌ای دیگه از برنامه باشه.

چرا به تایپ‌های وابسته نیاز هست؟

برای این سوال، باید این رو بررسی کنیم که اساسا چرا به تایپ‌چکینگ نیاز داریم. هدف از تایپ‌چکینگ اینه که مطمئن باشیم تو زمان اجرا، مقادیر تایپ درستی دارن، و این درستی رو تو زمان کامپایل مشخص میکنیم.

یک مثال میتونه این باشه: یک فانکشن که عمل تقسیم رو انجام میده. برای اینکه مطمئن باشیم از تقسیم بر صفر جلوگیری میکنیم، میتونیم بگیم که تایپ خروجی، بستگی به مخرج تقسیم داره. اگر مثلا عددی غیر از صفر یا اصطلاحا nonezero بود، بهمون double و در غیر این صورت بهمون never برگردونه. چون در این صورت یک exception رو پرت یا throw میکنه و اجرا هم نمیشه.

شما ۱۰۰٪ باهاشون کار کردین!

پیشنهاد میکنم باز هم که این مطلب امیررضا رو هم بخونید تا در مورد ADTها اطلاعات کسب کنید. اما باور کنید یا نه، شما تا حالا این مدل تایپ‌ها رو تو تایپ‌اسکریپت دیدید. بهشون اصولا Discriminated Unions یا Algebraic Data Types (دیتا تایپهای جبری) هم میگن:

type MyADT =
  | {
      type: "case_1"
      field: number
    }
  | {
      type: "case_2"
      field: string
    }

تو یک ADT در تایپ‌اسکریپت، شما معمولا یک فیلد عمومی دارید که اسمش میتونه type باشه که مقادیر ثابتی رو میتونه دریافت کنه و تایپ‌اسکریپت میتونه فیلدهای دیگه رو بر مبنای مقدار این فیلد در نظر بگیره یا نگیره، اما ما همچنان میتونید از اسم مابقی فیلد‌ها استفاده کنیم!

مثلا تو مثال بالا، field تو هردو موارد وجود داره اما تایپش فرق میکنه. شما میتونید اینطور باهاش کار کنید:

if (MyADT.type === "case_1") {
  return MyADT.field * 3.1415
} else {
  return `${MyADT.field} is an string`
}

Sound or Unsound

در مورد این اصطلاح توضیح زیادی نیست که بدم، اما درواقع میشه گفت یک تایپ‌چکر اصطلاحا Sound هست، اگر، وقتی یک مقدار در اون زبان یک تایپ رو داشته باشه، همیشه و همیشه همون تایپ رو به طور قطع در زمان-اجرا داره. و یک تایپ‌چکر اصطلاحا Unsound هست، اگر تو بعضی شرایط در زمان-اجرا، یک مقدار تایپی که بهش تو زمان کامپایل داده شده رو نداشته باشه.

function messUpTheArray(arr: Array<string | number>): void {
  arr.push(3)
}

const strings: Array<string> = ["foo", "bar"]
messUpTheArray(strings)

const s: string = strings[2]
console.log(s.toLowerCase())

تایپ‌اسکریپت به اشتباه (و از روی عمد) میگه که Array<string> میتونه به Array<string | number> تخصیص داده بشه و شما میتونید یک s رو بسازید که تایپ‌اسکریپت فکر کنه مقدارش استرینگه ولی درواقع عدده.

چطور تو تایپ‌اسکریپت دوسویه بودن و وابستگی حل میشه؟

تایپ‌اسکریپت تو بعضی قسمت‌ها، یک زبان unsound به حساب میاد. دلیلش هم علاقه‌ی این زبان به جلوگیری از تایپ‌کستینگ‌هایه که تاثیری در زمان-اجرا نداشته باشن تا فقط بتونن تایپ‌چکر رو راضی کنن!

این کد، مستقیما از توضیحات سایت تایپ‌اسکریپت برداشته شده:

enum EventType {
  Mouse,
  Keyboard,
}

interface Event {
  timestamp: number
}
interface MyMouseEvent extends Event {
  x: number
  y: number
}
interface MyKeyEvent extends Event {
  keyCode: number
}

function listenEvent(eventType: EventType, handler: (n: Event) => void) {
  /* ... */
}

// Unsound, but useful and common
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y))

// Undesirable alternatives in presence of soundness
listenEvent(EventType.Mouse, (e: Event) =>
  console.log((e as MyMouseEvent).x + "," + (e as MyMouseEvent).y)
)
listenEvent(EventType.Mouse, ((e: MyMouseEvent) =>
  console.log(e.x + "," + e.y)) as (e: Event) => void)

// Still disallowed (clear error). Type safety enforced for wholly incompatible types
listenEvent(EventType.Mouse, (e: number) => console.log(e))

از اونجایی که تایپ‌اسکریپ از تایپ‌های وابسته هم پشتیبانی میکنه، تغییر مثال بالا به پایین مشکلی رو برای ما ایجاد نمیکنه:

// Assuming EventType, Event, MyMouseEvent, and MyKeyEvent as above

type GenericEvent<E extends EventType> = E extends EventType.Mouse
  ? MyMouseEvent
  : MyKeyEvent

function listenEvent<E extends EventType>(
  eventType: E,
  handler: (n: GenericEvent<E>) => void
): void
function listenEvent(
  eventType: EventType,
  handler: (n: GenericEvent<typeof eventType>) => void
): void {
  /* ... */
}

// Valid
listenEvent(EventType.Mouse, (e: MyMouseEvent) => console.log(e.x + "," + e.y))
// Valid
listenEvent(EventType.Keyboard, (e: MyKeyEvent) => console.log(e.keyCode))
// Invalid
listenEvent(EventType.Mouse, (e: MyKeyEvent) => console.log(e.keyCode))
// Invalid
listenEvent(EventType.Mouse, (e: Event) => console.log(e.x + "," + e.y))

خب تا اینجا سعی کردم این مفاهیم رو تا حد امکان ساده بگم. اگر سوالی بود میتونید تو کامنت‌ها برای من بنویسید.

با آرزوی موفقیت.