צלילה לעומק TS: טייפ X לא זהה ל-union של כל הערכים האפשריים שהטייפ כולל
-
בפוסט זה אני הולך לשתף דבר שלמדתי לאחרונה ב-TS שהיה חידוש עבורי
אני משתמש בנושא זה כקרש קפיצה לכתוב מבוא כללי ל-TS. כדי להסביר את החידוש שגיליתי, אסביר בצורה מתומצתת את המושגים שצריכים להכיר כדי להבין את החידוש
(אם מישהו יתחיל להשתמש בשפה בזכותי והיה זה שכרי. פשוט חבל שיש כל כך הרבה מתכנתים מוכשרים בקהילה שלנו שלא משתמשים ב-TS מחוסר הכירות...)מכיון שאני כותב את הפוסט בהמשכים אשמח אם התגובות יהיו בנושא נפרד
התוכנית:
- קורס מזורז ב-TS
- קטעי הקוד המוקשים לכאורה
- התשובה
מקווה שיהיה קל לקריאה ולתועלת
ובכן, אעביר אותך קורס מזורז מאוד ב-TS:
מה זה טייפ
דרך אחת להסתכל על טייפ הוא ככה:
טייפ הוא סט (set - כן, ההוא מתורת הקבוצות...) של ערכים.
לדוגמה:- הטייפ
number
מייצג את הסט שכולל בתוכו את כל המספרים - הטייפ
1
מייצג סט שכולל בתוכו ערך אחד: המספר 1, כמו"כ כל ערך ב-JS הוא גם שם של טייפ שכולל חבר אחד - הערך הזה בעצמו - הטייפ
{ foo: string; bar number }
מייצג את הסט שמכיל כל האובייקטים האפשריים שמכילים שדהfoo
מסוגstring
ושדהbar
מסוגnumber
- הטייפ
never
מייצג סט ריק. סט בלי חברים. - הטייפ
unknown
מייצג את הסט של כל הערכים האפשריים. "סט סטי הסטים". הסט הכי גדול ביקום הטייפים. לכן אין הרבה מה לעשות עם ערך מסוג unknown כי אתה לא יודע איזה פעולות מותרים לעשות איתו.
דרך נוספת לחשוב על טייפים:
הטייפ של הערך אומר לנו איזה פעולות מותרות על הערך
למשל:- הטייפ
number
אומר לנו שמותר לעשות פעולת חיבור וחיסור על ערך זה עם מספרים אחרים - הטייפ
string
אומר לנו שמותר לנו לקרוא לפונקציה בשםstartsWith
על הערך שלנו
וכן הלאה
זו גם דרך נכונה לחשוב על טייפים.
בהמשך המאמר נתמקד יותר בפן הראשון: טייפ של ערך מייצג חברות בסט של ערכים.מתמטיקה עם טייפים:
TS נותן שני כלים אריתמטיים לבניית טייפים מורכבים מטייפים אחרים:
חיבור - UNION
האופרטור
|
ב-TS עושה את פעולת החיבור באריתמטיקה של טייפים
למשל:- טייפ
1 | 2
מייצג חיבור של שני הסטים1
ו-2
. התוצאה היא סט (טייפ) שכולל שני חברים, 1 ו-2 string | number
מחבר את שני הסטים והתוצאה היא סט גדול שכולל בתוכו כל המחרוזות האפשריים וכל המספרים האפשריים- חיבור עם
never
(להזכירכם: זהו הסט עם 0 חברים) הוא כמו פעולת חיבור מספרי של מספר כלשהו עם 0. הפעולה לא משנה את הערך המקורי.
חיסור - INTERSECTION
למדנו איך להרחיב סט עם אופרטור
|
עכשיו נלמוד איך להצר סט עם אופרטור&
.
האופרטור&
מקבל שני סטים משני צידי האופרטור ומחזיר את הסט שכולל רק את החברים בשני הסטים גם יחד
למשל:{ foo: string } & { bar:number }
לוקח את הסט שכולל כל האובייקטים האפשריים שיש להם שדהfoo
מסוג string, וכן את הסט שכולל כל האובייקטים האפשריים שיש להם שדהbar
מסוג number, והוא מחזיר סט של כל האובייקטים האפשריים שחברים בשתי הקבוצות: דהיינו אובייקטים שיש להם גם שדה foo וגם שדה bar- פעולת
& unknown
לא עושה כלום כי כל ערך אפשרי נמצא בתוך סט ה-unknown
תכנות עם טייפים
TS נותן כלים יותר משוכללים לבניית טייפים על ידי "תכנות"
פונקציות - GENERICS
תחביר ה-generics מקביל לפונקציה המוכרת מתכנות רגיל.
אבל בעוד שפונציקה רגילה פועלת על ערכים, ה-generic פועל על טייפים.
זה פונקציה שמקבל כארגומנט טייפ, ופולט את הטייפ הסופי לפי הלוגיקה של ה-body של הפונקציה.
לדוגמה:- פוקנציה שמקבל נוסח תפילה ופולט את הטייפ של היהודי עם נוסח תפילה זו:
// הצהרת טייפ שכולל כל נוסחאות התפילה האפשריים type Nusach = 'Ashkenaz' | 'Sefarad' | 'Edot Hamizrach' // הצהרת טייפ גנרי שמקבל נוסח תפילה ומחזיר טייפ של יהודי שמתפלל בנוסח הנ"ל type Jew<T extends Nusach> = { name: string nusach: T } // ערך עם שדה שם ושדה נוסח const aJew = { name: 'Zundel', nusach: 'Ashkenaz' } // נסיון השמה למשתנה מסוג Jew<'Sefarad'> // TS מתריע על אי התאמה בין הטייפ לערך const test: Jew<'Sefarad'> = aJew
השורה האחרונה תפלוט שגיאה, כי טייפ מסוג
Jew<'Sefarad'>
לא יכול להתפלל נוסח אשכנזמילת המפתח
extends
בדוגמה דלעיל נתקלנו לראשונה (בקורס הזה) במילת המפתח
extends
, ב-TS הכוונה שלextends
הוא (לפי צורת החשיבה שהצגנו בתחילת דברינו) שהטייפ שבצד שמאלי כולו חבר בסט שמיוצג על ידי הצד הימני.
ובדוגמה הנ"ל:T extends Nusach
הכוונה הוא שהפרמטרT
רק מקבל טייפים שהם חברים בסטNusach
אם ננסה ליצור יהודי כזה:Jew<'foo'>
נקבל שגיאה שהמחרוזתfoo
הוא לא חבר בקבוצתNusach
נפגוש שוב את המילה
extends
בהמשךתנאים
התחביר לכתיבת תנאי ב-TS:
T extends U ? V : W
שימו לב לנקודות הבאות:
בתכנות רגיל אנחנו מורגלים לבדיקת המון דברים בתנאים, וכן לשלוט על פעולות מגוונות על ידי תנאים אלו
ב-TS לעומת זאת:- הפרט היחיד שאפשר לבדוק בתנאי הוא extends - כלומר, חברות של הצד השמאלי בצד הימני
- הדבר היחיד שאתה יכול לתלות בתנאי הזה הוא הטייפ של התוצאה של הביטוי
דוגמה לשימוש בתנאי ופונקציה ביחד:
type LabelOf<T> = T extends { label: string } ? T['label'] : 'no name'
Index Signatures
אפשר לתאר את ה-shape של אובייקט בלי לפרט בדיוק את השם של השדות, במקום זה כותבים את הטייפ של המפתחות והטייפ של הערכים
לדוגמה:type X = { [key: string]: number }
הכוונה בהצהרה זו הוא שמדובר באובייקט שיש בו מפתחות מסוג string וערכים מסוג number
לולאות - Mapped Types
TS לוקח את התחביר הזה שלב נוסף קדימה באמצעות mapped types
בתחביר זה אפשר לעבור בלולאה על כל הערכים האפשריים הכלולים במפתח (key) ולקבוע את הטייפ של הערך עבור אותו מפתח לפי כל לוגיקה שתבחר
דוגמה:type MyMap<T> = { [K in keyof T]: K extends `${string | undefined}foo${number}` ? string : number } type Test = MyMap<{ foo1: any, a_foo2: any, bar3: any }>
keyof
כאן אנחנו נתקלים לראשונה במילת המפתח
keyof
:
זה אופרטור שמחזיר union של כל שמות השדות של האופרנד שלו
למשל:keyof {foo:any, bar:any}
שווה ל-'foo' | 'bar'
ה"פוקנציה" (יותר מקובל לקרוא לו generic type)
MyMap
מקבל פרמטרT
(מכיון שלא הגבלנו אותו על ידי מילת המפתחextends
- הוא יכול להיות מכל סוג שהוא), ומחזיר אובייקט שיש לו אותם שדות של האובייקט המקורי, אבל אם התבנית של שם השדה מתאים לתבנית מחרוזת אופציונלית + foo + מספרי כלשהו, אז הערך של השדה חייב להיות string, אחרת הערך חייב להיות numberיותר מקובל ושימושי להשתמש ב-mapped types עם תנאי שבודק את הטייפ של ה_ערך_ של האובייקט המקורי במקום תנאי שבודק את הטייפ של ה_מפתח_ של האובייקט המקורי. עושים את זה על ידי אינדוקס ככה:
type MyMap<T> = { [K in keyof T]: T[K] extends X ? Y : Z }
נראה דוגמה שימושית לזה מיד בקטע הבא
דוגמה אחרונה:
Omit
בוא נבדוק את ההבנה שלך על ידי הדוגמה הבאה.
(ההגדרותExclude
ו-Omit
מגיעים מובנים ב-TS)// type Omit<T, K extends keyof any> = { [P in Exclude<keyof T, K>]: T[P]; } // type Exclude<T, U> = T extends U ? never : T type MyObject = { id: number foo: string bar: number } type MyObjectWithoutId = Omit<MyObject, 'id'>
טוב, התעייפתי לבינתיים, אמשיך בעז"ה בהזדמנות הבאה...
-
בפוסט הזה אולי לא נתקדם הלאה כי אני רוצה להבהיר כמה נקודות ולחזור על חלק מהמושגים מהפוסט הקודם
הודעות
א. תודה לכל מי שטרח להשאיר לי משוב, אם בצורת הצבעה על הפוסט ואם בערוצים פרטיים (כמה חברים כתבו אלי בערוצים אחרים), אני מעריך את זה!
ב. לכל מי שהתקשה להבין את הפוסט הקודם:
אל תאשים את עצמך. זה לא היה פוסט קל להבנה! כתבתי דברים בקיצור, ולפעמים השמטתי לגמרי הסברים נחוצים.
הנושא עצמו מאוד תיאורטי, וגם הדוגמאות לא היו ממש מעשיות.
אני מתנצל. (אנסה להבא להביא דוגמאות יותר מעשיות)
ולמי שלא התקשה כלל: או שיש לך הכירות קודמת עם הנושא, או שאתה מוכשר, או שלא הבנת באמת
ג. חשוב להבהיר! אין שום צורך להכיר את כל התוכן התיאורטי שכתבתי כדי לקבל תועלת מ-TS. גם אם הנושא לא מושך אותך או אפילו דוחה אותך, אני עדיין ממליץ בחום להשתמש ב-TS כי אפשר לקבל רוב התועלת בלי להבין את הרקע התיאורטי. הפוסט שלי חשוב למי שרוצה ליצור טייפים. רוב הזמן אתה צורך טייפים שאחרים כתבו ולא יוצר טייפים חדשים. וגם רוב הטייפים שתיצור יהיו פשוטים בלי צורך בידע תיאורטי עמוק.
ד. העירו לי על כמה דברים שלא הסברתי די הצורך בפוסט הקודם ואנסה להבהיר אותם כאןמה זה טייפ?
(הרחבה של הקטע "מה זה טייפ" בפוסט הקודם)
התפקיד של טייפים בשפות תכנות זהה לכל השפות: בהנתן ערך כלשהו, המתכנת רוצה לדעת בצורה סטטית (כלומר, בלי להריץ את התוכנה בפועל) איזה פעולות זמינים לו על ערך זה, איך מותר להשתמש בערך.
אבל יש הבדל מהותי בין ההתעסקות עם טייפים בשפות סטטיות (statically typed) "קלאסיות" (JAVA, C++, C#, etc) לטייפים ב-TS
כי בשפות קלאסיות אין עיסוק ישיר עם טייפים עצמם. עוסקים עם קלאסים, שהם "תבניות" או "אב טיפוס" לערכים, קלאס מגדיר איזה שדות הערך חייב להכיל, ובאיזה פעולות הוא צריך לתמוך. וכתוצאה נוצר "טייפ" (שהוא הסט של כל הערכים האפשריים שמתאימים לתבנית) אבל אין עיסוק ישיר עם טייפים עצמם
ב-TS יש עיסוק ישיר עם טייפים עצמם. מחברים טייפים אחד לשני, מחסרים טייפים אחד מהשני, בונים טייפים מטייפים אחרים, דברים שלא קיימים בשפות קלאסיות.
בשפה קלאסית אין מושג של literal type (הטייפ של ערך literal, סט בעל ערך יחיד). כי כל טייפ הוא תוצאה מהצהרה של קלאס שהוא תבנית להרבה ערכים כי העיקר של הטייפ הוא הפעולות שזמינים עליו.
לא כן ב-TS שאפשר לעסוק ישירות עם המושג טייפ.
לכן חשוב לחשוב על טייפים ב-TS כסטים, במקום כתבניות כמו בשפות קלאסיותGENERICS
הדוגמה שהבאתי בפוסט הקודם לא היה מספיק ברור וגם לא כל כך מעשי
אביא דוגמה מעשי ביותר
נגיד שאתה בונה קליינט ל-API
אתה יודע שה-API המדובר תמיד מחזיר תשובות בתבנית זה:type ApiResponse = { success: boolean data: object | undefined error: string | undefined }
השימוש ככה:
type User = { ... } type Transaction = { ... } class ApiClient { // users getUserById(id: number): ApiResponse { ... } updateUser(newUser: User): ApiResponse { ... } // transactions listTransactions(userId: number): ApiResponse { ... } // ... } const apiClient = new ApiClient const res: ApiResponse = apiClient.getUserById(id) if (res.success) { const user = res.data // do something with user } else { console.error(error) }
הבעיה בקוד הזה הוא שבשורה 18, הדבר היחיד ש-TS יודע על המשתנה
user
הוא שזה מסוגobject
אבל הוא לא יודע איזה סוג אובייקט.
דרך אחת לטפל בזה הוא להצהיר על טייפ בשםUserApiResponse
ושם להצהיר על הטייפ המלא של שדהdata
הבעיה היא שצריך לחזור שוב ושוב על הצהרה זו לכל סוג שלXXXApiResponse
אפשרות אחרת הוא להצהיר שהטייפ שלres.data
הואUser
const user = res.data as User
אבל בצורה זו מאבדים את הבטחת האבטחה של TS וצריך לסמוך על המתכנת שהצהיר נכון על הטייפ
הפתרון הוא להשתמש ב-generic שהוא "טייפ בונה טייפים"
ככה:type ApiResponse<T> = { success: boolean data: T | undefined error: string | undefined } type User = { ... } type Transaction = { ... } class ApiClient { // users getUserById(id: number): ApiResponse<User> { ... } updateUser(newUser: User): ApiResponse<User> { ... } // transactions listTransactions(userId: number): ApiResponse<Transaction[]> { ... } // ... } const apiClient = new ApiClient const res: ApiResponse = apiClient.getUserById(id) if (res.success) { const user = res.data // user is type User } else { console.error(error) }
בפסאודו קוד ההצהרה דומה לקוד כזה:
const ApiResponse = function(T: type): type { return new Type(`{ success: boolean data: ${T} | undefined error: string | undefined }`) }
תנאים
יש כמה בעיות בדוגמה שהבאתי למעלה עבור שימוש בתנאי
א. זה לא כל כך מעשי
ב. יש באג (הטייפ לא עושה מה שהתכוונתי שהוא יעשה - אולי אכתוב על זה בפוסט אחר) ואין לי ממש דרך לפתור את הבאג
ג. השתמשתי בתחביר שלא הצגתי מקודם (אינדוקס)אני רוצה להביא דוגמה מעשית פשוטה שבונה על הדוגמה הקודמת
נגיד שה-API מחזיר לפעמים אובייקט יחיד ולפעמים מערך
אם הוא מחזיר מערך אז יש שדות נוספות בתשובה
אולי נגיד שדהcount
שמחזיר כמה אובייקטים יש בסה"כ
איך נגדיר type כזה?type BaseApiResponse<T> = { success: boolean data: T | undefined error: string | undefined } type ApiResponse<T> = T extends any[] ? BaseApiResponse<T> & { count: number } : BaseApiResponse<T>
אפשר לתרגם את זה לקוד פסאודו זה:
function ApiResponse(T: type): type { const base = `{ success: boolean data: ${T} | undefined error: string | undefined }` if (T instanceof Array(any)) { return `base & { count: number }` } else { return base } }
Mapped Types
הבאנו למעלה דוגמה שהיא גם מסובכת מדי וגם לא כל כך מעשי לשימוש יומיומי
אני רוצה להביא דוגמה נורמלית מתוך הטייפים המובנים (TS מגיע עם "ספרייה סטנדרטית" של טייפים מובנים)
נבנה על הדוגמה הקודמת שלנו של קליינט API
אז אתה רוצה להגדיר שהפונקציהupdateUser
מקבל אובייקט מסוגUser
אבל בשונה מ-User
, כל השדות הם אופציונליות. (כי מי שרוצה לעדכן רק שדה אחת לא צריך לשלוח את כל השדות)אז אפשר לכתוב טייפ חדש
type User = { name: string foo: string bar: number } type UserUpdate = { name?: string foo?: string bar?: number }
אבל זה בזבוז נייר דיו וביטים, וכמובן זה לא DRY מה שאמור לזעזע כל מתכנת ששוה משהו
הפתרון הוא:
updateUser(newUser: Partial<User>): ApiResponse<User> { ... }
השתמשנו ב-helper (כך נקראים "פקונציות" אלו שעוזרים לבניית טייפים מבוססים טייפים קיימים) בשם
Partial
שלוקח טייפ ומחזיר טייפ עם אותם שדות, רק שכולם נהיים אופציונליים
איך עובד ה-helper הזה?
נסתכל בקוד המקור:type Partial<T> = { [P in keyof T]?: T[P] | undefined; }
מילת המפתח
in
עובד ככה: עבור כל חבר ב-union שצד הימני מייצג, הוא מריץ את הביטוי שאחרי הנקדותיים כאשר המשתנהP
מייצג את החבר הזה מה-unionשימו לב לתחביר שלא הצגנו מקודם:
T[P]
- זה אינדוקס בטייפ שעובד בדיוק כמו אינדוקס אובייקט רגיל.T[P]
הוא הטייפ של שדהP
בתוך טייפT
נתרגם את זה לפסאודו קוד:
function keyOf(o: object): union { return new Union(Object.keys(o)) } function Partial(T: type): type { const res = {} for (const P in keyOf(T)) { res[P] = T[P] | undefined } return res }
(סליחה. בפסאודו קוד הזה השתמשתי במוסכמות אחרות מהקטעים הקודמים. בקודמים יצרתי טייפ על ידי העברת מחרוזת ל"בנאי של הטייפ" - כביכול. הפעם לשם נוחות התייחסתי לטייפ כאובייקט. זה רק פסאודו קוד כמובן ואני מקווה שהכוונה ברורה)
-
הטייפ השולל:
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
אף פעם לא מתקיים
תם ולא נשלם