• בלוג
  • TypeScript
  • טיפ טייפסקריפט: חתימה של פונקציה מתוך Literal Types

טיפ טייפסקריפט: חתימה של פונקציה מתוך Literal Types

מנגנון ה Literal Types ואוביקטים בטייפסקריפט הוא חמוד במיוחד כשכותבים בספריה כמו רידאקס, כיוון שהוא מאפשר להגדיר מגבלות על אוביקטים שפונקציה יכולה לקבל. דוגמה קלאסית תהיה הגדרת כמה סוגים של אירועים ופונקציה שיודעת לקבל אוביקט "אירוע" שמתאים לאחד מהם. הקוד יראה בערך כך:

type Event =
  | { type: 'login', payload: { username: string }}
  | { type: 'logout'}
  | { type: 'sendMessage', payload: { to: string, text: string }};

function handle(event: Event) {}

ועכשיו שורה כזאת תתקמפל:

handle({ type: 'login', payload: { username: 'yay' }});

אבל שורה כזאת לא תתקמפל:

handle({ type: 'login', payload: { to: 'yay', text: 'abc' }});

כי אוביקט payload לא מתאים למה שהפונקציה מצפה מאירוע לוגין.

מסתבר שאפשר להרחיב את המנגנון ולהשתמש בו גם לבניית פונקציה גנרית - כלומר לכתוב פונקציה שתקבל בתור פרמטר ראשון את ה type של האירוע ופרמטר שני את ה payload, ותבדוק שהכל מתאים. בואו ננסה את זה.

ניסיון ראשון עשוי להיראות כך:

type Event =
  | { type: 'login', payload: { username: string }}
  | { type: 'logout'}
  | { type: 'sendMessage', payload: { to: string, text: string }};

function handle(eventType: Event["type"], eventPayload: Event["payload"]) {}

אבל זה לא מתקמפל כי לא לכל האירועים יש payload. אם אני מוסיף payload גם לאירוע הניתוק אני מצליח לקמפל את הקוד:

type Event =
  | { type: 'login', payload: { username: string }}
  | { type: 'logout', payload: undefined }
  | { type: 'sendMessage', payload: { to: string, text: string }};

function handle(eventType: Event["type"], eventPayload?: Event["payload"]) {}

אבל אני לא מקבל את מה שרציתי - טייפסקריפט יקמפל את השורה הזאת למרות שבאירוע login כן צריך להעביר שם משתמש:

handle("login");

בעצם מה שהקוד שלי עשה זה ליצור פונקציה שמקבלת בתור פרמטר ראשון משהו שמופיע ב type של Event, ובתור פרמטר שני משהו שמופיע ב payload, בלי להתאים ביניהם.

בשביל ההתאמה בין שני הפרמטרים אני רק צריך להפוך את הפונקציה ל Generic. זה ייתן לי גישה לטיפוסים שבאמת עבר לתוך Event["type"] ואז בעזרתו אני יכול לצמצם את האיחוד:

type Event =
  | { type: 'login', payload: { username: string }}
  | { type: 'logout', payload: undefined }
  | { type: 'sendMessage', payload: { to: string, text: string }};

function handle<E extends Event, EventType extends E["type"]>(
  eventType: EventType,
  eventPayload: Extract<Event, { type: EventType }>["payload"]
) {}

handle("login", { username: "ynon" });
handle("logout", undefined);
handle("sendMessage", { to: "ynon", text: "hi ;)"});

עכשיו שלושת הקריאות מתקמפלות, אבל קריאות שלא מתאימות לחתימה לא יתקמפלו. למשל זה לא יעבור:

handle("login", { to: "me", text: "bye" });

המצב טוב ואנחנו כמעט בסיום אבל עדיין יש דבר אחד שלא יושב טוב בקוד שכתבנו - והוא הצורך לכתוב undefined בתוך הקריאה עם logout. בשביל מה זה טוב? למה אני צריך לעבוד כל פעם שמפעיל את הפונקציה רק בשביל שטייפסקריפט יהיה שמח?

עם עוד קצת מאמץ אפשר להיפטר גם מזה ולתקן את הפונקציה כך שהפרמטר השני יהיה אופציונאלי אם לא מוגדר עבורו ערך בטיפוס Event. מה שהייתי רוצה לכתוב זה:

function handle<E extends Event, EventType extends E["type"]>(
  eventType: EventType,
  eventPayload: Extract<Event, { type: EventType }>["payload"] extends undefined
    ? "MAKE EVENT PAYLOAD OPTIONAL"
    : "USE THE VALUE FROM EXTRACT ..."
) {}

וגם זה אפשרי בטייפסקריפט עם קצת וודו, הפעם אני משתמש בסימן שלוש נקודות משמאל למפתח, ומשתמש בערך שהוא מערך של איבר אחד, ואז אני יכול להשתמש בסימן שאלה בתוך אותו מערך. הוספתי גם שם ל Extract בשביל לא לכתוב אותו פעמיים וקיבלתי:

type Event =
  | { type: 'login', payload: { username: string }}
  | { type: 'logout', payload: undefined }
  | { type: 'sendMessage', payload: { to: string, text: string }};

function handle<
  E extends Event,
  EventType extends E["type"],
  EventPayload extends Extract<Event, { type: EventType }>["payload"]
>(
  eventType: EventType,
  ...eventPayload: EventPayload extends undefined
      ? [undefined?] 
      : [EventPayload]
) {}

handle("login", { username: "ynon" });
handle("logout");
handle("sendMessage", { to: "ynon", text: "hi ;)"});

הקוד הזה כבר עובד ממש אחלה. אירועים שמוגדרים עם payload דורשים שנעביר את ה payload בפרמטר השני ובודקים שמה שהעברנו תואם להגדרה בטיפוס האירוע, אירועים שמוגדרים בלי payload אפשר להפעיל רק עם פרמטר אחד.