הטייפ השולל: 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
אף פעם לא מתקיים
תם ולא נשלם