הטייפ השולל: never
נחזור שוב ל-never לכמה רגעים
הטייפ never הוא סט בלי חברים, טייפ ששום ערך לא יכול להיות בו חבר.
נמצא שלא יקרה אף פעם שלערך אמיתי יהיה טייפ never
אם כן לְמָה זה טייפ שימושי?
שלילת מצב
אחד מהשימושים של טייפ זה הוא לסמן ל-TS שמצב מסויים אמור להיות בלתי אפשרי, ולקבל אישור מ-TS שזה באמת בלתי אפשרי
למשל: אם רוצים לוודא שב-switch עברנו על כל האפשרויות ולא פיספסנו אחד מהם בטעות אפשר לכתוב ככה:
type Color = 'red' | 'blue' | 'green'
function handleColor(color: Color) {
switch (color) {
case 'red':
// ...
break
case 'blue':
// ...
break
case 'green':
// ...
break
default:
throw new Error('Impossible!')
}
}
פה אנחנו בודקים בזמן ריצה שלא הגענו לענף הבלתי אפשרי
אבל עם TS יש לנו אפשרות לבדוק בזמן קימפול שלא שכחנו לטפל בשום אפשרות
מוסיפים שורה זו:
type Color = 'red' | 'blue' | 'green'
function handleColor(color: Color) {
switch (color) {
case 'red':
// ...
break
case 'blue':
// ...
break
case 'green':
// ...
break
default:
const _test:never = color
throw new Error('Impossible!')
}
}
עיין שורה 15. אנחנו מצהירים ל-TS שבשלב הזה מיצינו את כל הערכים האפשריים ש-color יכול לקבל ופה הטייפ חייב להיות never, כלומר זה לא יקרה אף פעם
(להבין יותר איך זה עובד, כדאי להכיר את המושג narrowing, בקיצור נמרץ: מנוע TS עוקב אחרי משתנים תוך כדי זרימת הקוד וכאשר הוא רואה ביטויים מסויימים בקוד, הוא מבין אותם ומוציא מתוך ה-union של הטייפ של המשתנים את אותם הטייפים שהקוד שולל, ובהקשר שלנו, בכל ענף של ה-switch, מנוע TS יכיר בעובדה שהטייפ של color הוא רק הערך שאותו ענף מטפל, ובענף default הטייפ הוא האפשרויות שה-switch לא מיצה)
ברגע שנוסיף עוד אפשרות ל-union של Color נקבל מיד אזהרה שהמשתנה _test קיבל ערך שהטייפ שלו הוא לא never
שלילת ערך
אבל השימוש שמעניין אותנו יותר פה הוא השימוש השני,
כאשר יוצרים טייפ חדש באמצעות ביטוי עם תנאי, ורוצים שענף אחד של התנאי לא יחזיר שום טייפ, או יותר נכון: יחזיר טייפ ריק, במקום כזה בא never לידי שימוש
נדגים את זה על ידי ה-helper המובנה - Exclude
(helper הוא פונקציה שעוזר לבניית טייפ שמבוסס על טייפ קיים. נזכיר: פונקציה ב-TS == generic)
הטייפ Exclude מגיע מובנה ב-TS ושימושו הוא כאשר רוצים ליצור מ-union קיים, טייפ שדומה למקור רק שחסר ממנו אחד או יותר ערכים.
למשל:
type Weekday = 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | 'friday' | 'saturday'
type Workday = Exclude<Weekday, 'friday' | 'saturday'>
כפי שאפשר לראות: ה"פונקציה" Exclude מקבלת כפרמטר ראשון את הטייפ המקורי ובפרמטר השני union של טייפים (או טייפ יחיד) לחסר מהטייפ המקורי.
איך הוא עובד?
הנה קוד המקור
type Exclude<T, U> = T extends U ? never : T
להבין את זה צריך להקדים עוד דבר אחד: ביטוי של תנאי שמריצים על union עובד על כל אבר של ה-union בנפרד
בתיעוד של TS קוראים לזה: Distributive Conditional Types
כלומר זה עובד כמו הקוד פסואודו הבא:
function Conditional(source: Union, condition: Type => Bool, resultIfTrue: Type => Type, resultIfFalse: Type => Type) {
const runConditional = (type: Type) => {
if (condition(type)) {
return resultIfTrue(type)
} else {
return resultIfFalse(type)
}
}
return source.split('|').map(runConditional).join('|')
}
function Exclude(source: Union, exclusions: Union) {
return Conditional(source, t => t.extends(exclusions), t => t, t => never)
}
Exclude(Weekday, 'friday' | 'saturday') // 'sunday' | 'monday' | 'tuesday' | 'wednesday' | 'thursday' | never | never
מקווה שאחרי הפסאודו קוד זה מובן
רק אזכיר כמה נקודות שכבר הזכרנו:
בדיקת extends בודק עם הצד שמאלי כולו כלול בתוך הסט שהצד הימני מייצג
פעולת union עם never לא כמוהו כמו אי-פעולה. הוספת סט ריק לסט המקורי לא מוסיף שום דבר, לכן בתוצאה של Exclude אפשר להשמיט את ה-never בלי שום השלכה
הערה בענין מילת extends
ℹ בדרך אגב, כמה אנשים שאלו שאלה זו: למה המצב שבו טייפ אחד כלול לגמרי בתוך טייפ אחר נקרא בלע"ז extends, הרי המשמעות של extend בלע"ז הוא "הרחבה" ופה מדובר על הצרה?!
התשובה לזה לכאורה (וכן הסכים לזה מתכנת אחר שדיבר איתי על זה) שהמילה "הרחבה" בהקשר זה לא מדבר על הסט שהוא תוצאה מהגדרת הטייפ אלא על הגדרת הטייפ עצמו,
ככל שאתה יותר מאריך בהגדרת הטייפ, כך אתה שולל יותר ויותר מועמדים פוטנציאליים.
לדוגמה: טייפ unknown הוא הטייפ הכי גדול מצד אחד, כי הוא כולל כל הערכים האפשריים, ומצד שני הגדרתו הוא הכי קצר: אין שום הגדרה, כל מה שזז נכנס לתוך הטייפ הזה. ברגע שמרחיבים את ההגדרה, למשל מוסיפים תנאי שיש לו מתודה פלונית, אתה מצר את מעגל החברים בטייפ
"פוקנציית" Omit
עכשיו נוכל להסביר את הטייפ Omit שבו סיימנו את הפוסט הראשון, וזה אמור להיות מובן בקלות כעת
הפונקציה מקבלת בפרמטר הראשונה אובייקט, ובשנייה, union של מפתחות שאתה רוצה להשמיט ממנו
דוגמה לשימוש (מתוך התיעוד הרשמי):
type Todo = {
title: string
description: string
completed: boolean
createdAt: number
}
type TodoPreview = Omit<Todo, "description">;
ℹ שיניתי קצת מהמקור בתיעוד של TS ממילת המפתח interface למילת type רק לשם העקביות כי לא הצגנו interface במאמר זה - יש הבדל קטן בין השניים אבל זה נושא לשיחה אחרת ולא חשוב כעת
פה אני רוצה להתמקד על האינטואיציות המרכזיות ולא על פרטים קטנים
והנה קוד המקור של Omit
type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; }
ננתח את זה לחלקים:
הפרמטרים
Omit<T, K extends keyof any>
הפונקציה מקבלת שני פרמטרים, הראשון ברור. זה יכול להיות כל טייפ שהוא. השני מוגבל לקבל רק טייפ שמקיים בעצמו התנאי extends keyof any
מה זה keyof any? זו דרך קצרה להגיד כל טייפ שחוקי להשתמש בו כמפתח של אובייקט כלשהו, שזה כהיום: string | number | symbol. אז הפרמטר השני הוא טייפ שמורכב רק מדברים שמותרים במפתח אובייקט
ה-body של הפונקציה
{ [P in Exclude<keyof T, K>]: T[P]; }
יש פה שימוש בכמה דברים שהסברנו כבר למעלה, נתחיל מהפנים לחוץ:
keyof T = מחזיר union של כל המפתחות של שדות בטייפ T
Exclude<keyof T, K> = מוציא מתוך זה את השמות של השדות שאתה רוצה להשמיט בטייפ הסופי
מיפוי [P in Exclude<keyof T, K>] מריץ לולאה על כל חבר מבודד מתוך ה-union שהתקבל מהשלב הקודם כאשר הטייפ המבודד מיוצג על ידי המשתנה P
{ [P in Exclude<keyof T, K>]: T[P]; } = מייצג טייפ שבו עבור כל P בלולאה, יש שדה בשם P וערך מהטייפ T[P] = כלומר הטייפ המקורי של שדה זו בטייפ המקורי
פסואודו קוד
והנה פסאודו קוד לקינוח:
function Omit(t: Type, u: UnionOfKeys) {
const res = {}
for (key of Omit(Keyof(t), u).split('|')) {
res[key] = t[key]
}
return res
}
פשוט ביותר!
הנושא המקורי (אחרי כל ההקדמות הארוכות)
נחזור לנושא המקורי שהצגתי בכותרת
(אם חיכית בקוצר רוח לחלק זה, זה באמת לא כל כך מרעיש עולמות... מצטער...)
ולשם הקדמה אביא את הטריגר שהביא אותי להבנה זו:
מישהו שאל: איך אפשר ליצור טייפ שבו מותרים רק שדה x ו-y ולא שום שדה נוסף
נציין פה שיש טעות נפוצה אצל מתחילים ב-TS, שאם טייפ לא כולל שדה מסויים, אז ערך שיש בו שדה זו לא יכול להיות חבר בטייפ. זה לא נכון.
ב-TS, העובדה שטייפ לא כולל שדה מסויים, עדיין לא שולל שהשדה יכול להיות קיים.
למשל:
type HasFoo = { foo: string }
גם אובייקט שיש לו שדה bar יכול להיות חבר בקובצת HasFoo
const hasBar = { bar: 1, foo: 'foo' }
const test:HasFoo = hasBar // no error
ℹ Object literal may only specify known properties
אם הייתי כותב בקיצור בקטע הנ"ל:
const test:HasFoo = { bar: 1, foo: 'foo' }
הייתי מקבל שגיאה זו: Object literal may only specify known properties, and 'bar' does not exist in type 'HasFoo'.(2353)
אל תתנו לשגיאה זו להטעות אותך! העובדה ש-HasFoo לא מצהיר על שדה bar לא מונע מחברי הסט לשאת שדות נוספות!
ההתרעה של TS הוא מסיבה אחרת: מכיון שהמשתנה היחיד שקיבל את הערך של { bar: 1, foo: 'foo' } יש לו טייפ שלא כולל את השדה bar, יוצא שבפועל אין דרך להשתמש בשדה bar. כי הגישה דרך משתנה test אסור כי השדה לא כלול בטייפ שלו. רוב הסיכויים הם שקוד כזה הוא שגיאה של המתכנת, לכן TS מתריע.
לעומת זאת, בדוגמה שהבאתי, ששמרתי את הערך בתוך משתנה אחר לפני ההשמה ל-test, אין על מה להתריע מכיון ששדה bar נשאר נגיש דרך משתנה hasBar
נחזור לשאלה: איך אפשר לשלול שדות לא מוצהרות מטייפ
ספוילר: התשובה היא שאי אפשר. וזה מתועד בתשובות ב-stack overflow ו-issues בגיטהאב
אבל אני כעקשן ניסתי בכל זאת לפתור את זה על ידי הטייפ הבא:
type T = {
x: string
y: number
}
type Exact<T> = {
[K in keyof any]?: K extends keyof T ? T[K] : never
}
מקווה שכעת קל לכם להבין את הקוד
בתרגום לאנגלית, זה אומר ככה:
בטייפ Exact<T>: קח את הטייפ של כל מפתח חוקי אפשרי (keyof any), עבור כל חבר מתוך מרכיביו בלולאה ותבדוק אם הוא מתוך המפתחות של T, אם כן, הערך של המפתח בתוך התוצאה צריך להיות כמו ב-T (המקור); אחרת הטייפ צריך להיות never - דבר שכמובן לא יכול להיות
לכאורה יצא לנו טייפ שאוסר מפתחות מיותרות
התשובה היא מה שכתבתי בכותרת: "טייפ X לא זהה ל-union של כל הערכים האפשריים שהטייפ כולל"
כלומר, הלולאה של K in keyof any שמפרק את keyof any למרכיביו, ומעביר כל מרכיב בודד בלולאה, לא מפרק את הטייפ string לכל מחרוזת אפשרית. בהקשר זה string הוא אטומי. כמו"כ number.
היה אפשר לחשוב אחרת, כי סה"כ הטייפ string הוא סה"כ סט שמחובר מכל המחרוזות האפשריים ביקום. אבל לצערנו TS לא מסתכל על זה ככה. אלא מתייחסים ל-string כאטום. ולכן בלולאה K in keyof any יש התייחסות רק ל-string ו-number ו-symbol ולא לערכים הפרטיים שמרכיבים אותם. כמובן, אם ככה, ההמצאה שלי לא תעבוד כי התנאי K extends keyof T אף פעם לא יתקיים
השלכה נוספת
עוד דבר שאנשים מבקשים מפעם לפעם (עיין דברי ימי שאלות SO ואישיו-אים של GH) הוא טייפ שמייצג כל מחרוזת אפשרית חוץ ממחרוזות ידועות לשמצה. למשל
type NotWeekday = Exclude<string, Weekday>
אנשים מצפים שדבר כזה יעבוד. אבל זה לא עובד. ולפי הנ"ל זה מובן
נזכיר את קוד המקור של Exclude
type Exclude<T, U> = T extends U ? never : T
הטייפ מתבסס על העובדה שלפני בדיקת התנאי יש פירוק של T למרכיביו ובדיקת התנאי רץ על כל מרכיב בנפרד (Distributive Conditional Types)
אבל לצערנו, הטייפ string הוא אטומי. הוא לא מתפרק לכל המחרוזות שמרכיבים אותו. לכן התנאי T extends U אף פעם לא מתקיים
תם ולא נשלם