conditional type בוחר בין שני טיפוסים עם בדיקה שנקראת כמו האופרטור הטרנרי של JavaScript: T extends U ? X : Y. אם T ניתן להשמה ל-U, התוצאה היא X; אחרת היא Y.
extends כאן פירושו "ניתן להשמה ל", אותו יחס שהקומפיילר משתמש בו כשמשימים ערך למשתנה. conditional types קיימים רק בזמן קומפילציה; הם נמחקים מפלט ה-JavaScript.
התחביר
type Result = CheckedType extends TestType ? TrueType : FalseType;
conditional types נהיים שימושיים עם גנריים, כשהטיפוס הנבדק הוא פרמטר טיפוס שמקבל טיפוס קונקרטי מאוחר יותר. בענף ה-true, TypeScript יודעת שהטיפוס הנבדק עומד בבדיקה, ולכן T["message"] למעלה מותר גם של-T רגיל אין מאפיין message.
אפשר גם לקנן אותם, כמו אופרטורים טרנריים משורשרים:
ה-as TypeName<T> בגוף הפונקציה הוא לא אופציונלי, כפי שהחלק הבא מראה.
טיפוסי החזרה מותנים צריכים assertion
פונקציה שטיפוס ההחזרה שלה הוא conditional type על פרמטר הטיפוס שלה עצמה לא יכולה להחזיר אף אחד מהענפים ישירות. TypeScript לא מצמצמת את T בתוך הגוף, ולכן היא לא יכולה לדעת איזה ענף חל:
index.ts(5,34): error TS2322: Type 'number' is not assignable to type 'Flip<T>'.
index.ts(5,45): error TS2322: Type 'string' is not assignable to type 'Flip<T>'.
שני תיקונים נפוצים: overloads, שמציינים כל זוג של קלט ופלט ובודקים את הקוראים בדיוק, או assertion במימוש.
overloads מוסברים בעמוד על function overloading. עם assertion, הקומפיילר סומך עליכם: ענף שגוי בגוף לא ייתפס.
Distributive Conditional Types
כשהטיפוס הנבדק הוא פרמטר טיפוס חשוף והוא מקבל union, התנאי רץ פעם אחת לכל איבר והתוצאות מתאחדות ל-union חדש:
הפיזור הוא מה שגורם ל-Exclude ול-Extract לעבוד. Exclude<T, U> מוגדר כ-T extends U ? never : T: כל איבר שמתאים ל-U הופך ל-never, ו-never נעלם מ-union. לכן Exclude<"a" | "b" | "c", "a"> הוא "b" | "c".
שתי הפתעות נובעות מאותו כלל. boolean הוא ה-union true | false, ולכן ToArray<boolean> הוא false[] | true[] ולא boolean[]. ו-never הוא ה-union הריק, ולכן distributive conditional type שמקבל never מחזיר never בלי לבדוק כלום:
type IsNever<T> = T extends never ? true : false;
type X = IsNever<never>; // never, not true
type IsNeverFixed<T> = [T] extends [never] ? true : false;
type Y = IsNeverFixed<never>; // true
חילוץ טיפוסים עם infer
infer מצהיר על משתנה טיפוס חדש בתוך פסוקית ה-extends. אם ההתאמה מצליחה, TypeScript ממלאת את המשתנה הזה מתוך הטיפוס הנבדק, ואפשר להשתמש בו בענף ה-true:
קראו את T extends Promise<infer V> ? V : T כך: "אם T הוא promise של משהו, קראו למשהו הזה V והחזירו אותו; אחרת החזירו את T בלי שינוי". infer מותר רק בפסוקית ה-extends של conditional type.
למשתנה infer יכול להיות אילוץ משלו עם extends. אז ההתאמה מצליחה רק אם הטיפוס שהוסק מתאים לו:
type FirstString<T> = T extends [infer S extends string, ...unknown[]] ? S : never;
type A = FirstString<["a", 1]>; // "a"
type B = FirstString<[1, "a"]>; // never: the first element is not a string
לבנות ReturnType בעצמכם
ReturnType המובנה הוא conditional type של שורה אחת עם infer. לכתוב אותו בעצמכם זה התרגיל הקלאסי שגורם לשני הרעיונות להתחבר:
typeof makeUser הופך את ערך הפונקציה לטיפוס שלה, ואז ה-conditional type מתאים אותו מול "כל פונקציה" ולוכד את טיפוס ההחזרה כ-R. הגרסה של הספרייה הסטנדרטית שונה בשני פרטים: הפרמטר שלה מוגבל לטיפוסי פונקציה (T extends (...args: any) => any), ולכן ReturnType<string> היא שגיאת קומפילציה ולא never, והענף השלילי שלה הוא any. העמוד על ReturnType מכסה גם את Parameters, InstanceType ו-Awaited.
Conditional Types רקורסיביים
conditional type יכול להתייחס לעצמו, וזה מאפשר לו לפרוס קינון בכל עומק:
type Flatten<T> = T extends readonly (infer U)[] ? Flatten<U> : T;
type A = Flatten<number[][][]>; // number
type B = Flatten<string>; // string
Awaited<T> המובנה עובד כך, ופורס Promise<Promise<T>> עד T. בפועל, שמרו על רקורסיה רדודה: רקורסיה עמוקה מאוד או בלי גבול גורמת לקומפיילר לוותר עם error TS2589: Type instantiation is excessively deep and possibly infinite.
סיכום מהיר
| דפוס | משמעות |
|---|---|
T extends U ? X : Y | X אם T ניתן להשמה ל-U, אחרת Y |
T extends U ? never : T | הסרת איברים שמתאימים ל-U (זה Exclude) |
T extends U ? T : never | השארת איברים שמתאימים ל-U (זה Extract) |
[T] extends [U] ? X : Y | אותה בדיקה, בלי פיזור על union |
T extends (infer E)[] ? E : T | טיפוס האיבר של מערך |
T extends Promise<infer V> ? V : T | טיפוס הערך של promise |
T extends (...args: any[]) => infer R ? R : never | טיפוס ההחזרה של פונקציה |
T extends [infer H, ...infer Rest] ? ... | האיבר הראשון ושאר ה-tuple |
שאלות נפוצות
מה זה conditional type ב-TypeScript?
טיפוס מהצורה T extends U ? X : Y. אם T ניתן להשמה ל-U, התוצאה היא X, ואחרת Y. זה if/else לטיפוסים, שמחושב בזמן קומפילציה; שום דבר ממנו לא קיים ב-JavaScript שנוצר.
מה עושה מילת המפתח infer ב-TypeScript?
infer מצהיר על משתנה טיפוס בתוך פסוקית ה-extends של conditional type, ונותן ל-TypeScript למלא אותו מתוך הטיפוס שהותאם. T extends Promise<infer V> ? V : T מחלץ את טיפוס הערך שה-promise מחזיר. אפשר להשתמש בו רק בפסוקית ה-extends של conditional type.
מה זה distributive conditional type?
כשהטיפוס הנבדק הוא פרמטר טיפוס חשוף ומעבירים union, התנאי מופעל על כל איבר בנפרד והתוצאות מתאחדות. ToArray<string | number> עם type ToArray<T> = T extends unknown ? T[] : never הופך ל-string[] | number[]. עטפו את שני הצדדים בסוגריים מרובעים, [T] extends [unknown], כדי לכבות את זה.
איך מקבלים את טיפוס ההחזרה של פונקציה ב-TypeScript?
השתמשו ב-ReturnType<typeof fn> המובנה. זה conditional type עם infer: T extends (...args: any) => infer R ? R : any. בפונקציות async, עטפו אותו ב-Awaited<...> כדי לקבל את הערך שה-promise מחזיר ולא את ה-promise עצמו.
למה IsNever<never> מחזיר never ולא true?
never הוא ה-union הריק, ו-distributive conditional type עובר על האיברים של union. כשאין איברים, אין על מה לעבור והתוצאה היא never. כתבו [T] extends [never] ? true : false כדי לבדוק את never עצמו.