Type narrowing הוא TypeScript שמחשב טיפוס ספציפי יותר לערך בנקודה מסוימת בקוד, על סמך הבדיקות שהקוד כבר ביצע. פרמטר מטיפוס string | number הופך ל-string בתוך if (typeof x === "string") ול-number ב-else.
קריאה ל-value.toFixed(2) לפני הבדיקה הייתה שגיאת קומפילציה, כי toFixed לא קיים על string. הבדיקה היא JavaScript רגיל ורצה בזמן ריצה; הצמצום הוא הקומפיילר שקורא אותה ומתאים את הטיפוס. שום דבר נוסף לא נוצר בפלט.
Control flow analysis
TypeScript עוקב אחרי כל מסלול בתוך פונקציה: if/else, return ו-throw מוקדמים, switch, לולאות, ואופרטורי הקיצור &&, ||, ?? ו-?:. בכל נקודה, הטיפוס של משתנה הוא מה שעדיין אפשרי שם.
סגנון ה-return המוקדם ("guard clauses") הוא הדרך הקריאה ביותר לצמצם: מטפלים קודם במקרים החריגים, ושאר הפונקציה עובד עם הטיפוס הנקי.
כל הדרכים לצמצם
| צורה | דוגמה | מצמצם |
|---|---|---|
| typeof | typeof x === "string" | טיפוסים פרימיטיביים ופונקציות |
| Truthiness | if (x) | מסיר null, undefined וליטרלים falsy |
| שוויון | x === "a", x == null, x !== undefined | ליטרלים, null, undefined |
in | "swim" in pet | unions של אובייקטים, לפי מאפיין |
| instanceof | err instanceof TypeError | מופעים של מחלקות |
Array.isArray | Array.isArray(x) | מערכים מול כל השאר |
| השמה | x = 5 | לטיפוס שהושם |
| Type predicate | function isUser(x: unknown): x is User | כל מה שאפשר לבדוק |
| פונקציית assertion | function assertUser(x: unknown): asserts x is User | כל מה שבא אחרי הקריאה |
| מאפיין מבחין | switch (shape.kind) | unions מתויגים |
שלוש האחרונות מוסברות בעמודים של type guards ושל discriminated unions. השאר מוסברות בהמשך.
צמצום לפי truthiness
if (x) מסיר null ו-undefined (וגם את הטיפוסים הליטרליים false, 0, ""). זה קצר, ויש בו מלכודת קלאסית אחת: 0 ו-"" הם falsy, ולכן ערכים תקינים מטופלים כחסרים.
עבור מספרים ומחרוזות, השוו במפורש ל-undefined או ל-null (או השתמשו ב-??). Truthiness מתאים לאובייקטים, למערכים ולפונקציות, שאף פעם אינם falsy.
צמצום לפי שוויון
===, !==, == ו-!= מצמצמים את שני הצדדים. השוואה לליטרל מצמצמת לאותו ליטרל; == null (שוויון רופף) תופס גם null וגם undefined בבדיקה אחת, וזה המקום היחיד שבו שוויון רופף הוא הסגנון המקובל.
השוואה בין שני משתנים מצמצמת את שניהם למה שיכול להיות משותף להם: אם a: string | number ו-b: string | boolean עוברים את a === b, שניהם string בתוך ה-if.
האופרטור in
"key" in obj מצמצם union של טיפוסי אובייקטים לאיברים שיש להם (או שאולי יש להם) את המאפיין הזה.
in עובד גם על unknown ברגע שיודעים שזה אובייקט: אחרי typeof v === "object" && v !== null && "id" in v, TypeScript יודע שלאובייקט v יש מאפיין id מטיפוס unknown. עבור unions שאתם מתכננים בעצמכם, מאפיין תגית משותף (kind: "fish") ברור יותר מבדיקה של מתודות: לדפוס הזה קוראים discriminated union.
צמצום לפי השמה
למשתנה יש טיפוס מוצהר, וטיפוס מצומצם שעוקב אחרי ההשמות שלו. השמה של ערך מצמצמת אותו לטיפוס של הערך, בגבולות הטיפוס המוצהר.
איפה הצמצום הולך לאיבוד
הצמצום מקומי ושמרני. כמה מצבים מאפסים אותו:
- ביטוי אחר. בדיקה של
obj.nameמצמצמת אתobj.name(ואתobj["name"]), אבל לא אתobj[key]כש-keyהוא משתנה מטיפוסstringולא ליטרל, וגם לא עותק שנוצר לפני הבדיקה. - Callbacks והשמה מחדש. בתוך callback, משתנה
letמצומצם שומר על הצמצום רק אם לא מושם לו ערך שוב אחרי שה-callback נוצר.constאו פרמטר שאף פעם לא מושם לו ערך מחדש נשארים מצומצמים. - בדיקות שמוסתרות בפונקציות עזר. פונקציה
isString(x: unknown): booleanלא אומרת לקומפיילר כלום. תנו לה טיפוס החזרה של type predicate,x is string, וקריאות אליה יצמצמו כמו ש-typeofמצמצם.
הקומפיילר מדווח index.ts(5,38): error TS18048: 'x' is possibly 'undefined'. ה-callback יכול לרוץ מאוחר יותר, אחרי x = undefined. מחקו את ההשמה האחרונה (או העתיקו את הערך ל-const בתוך ה-if), והקוד יתקמפל וידפיס 5 פעמיים.
את פונקציית העזר שמחזירה boolean אפשר לתקן בהצהרה על מה שהיא מוכיחה. זה בדיוק type guard:
function isString(value: unknown): value is string {
return typeof value === "string";
}
מאז TypeScript 5.5 הקומפיילר מסיק predicates כאלה עבור פונקציות חץ פשוטות, ולכן list.filter((x) => x !== undefined) מחזיר היום מערך בלי undefined.
שאלות נפוצות
מה זה type narrowing ב-TypeScript?
Narrowing הוא TypeScript שמדייק את הטיפוס של משתנה בתוך בלוק, על סמך בדיקה שהקוד מבצע. אחרי if (typeof x === "string"), string | number הוא רק string בתוך ה-if ורק number ב-else. הקומפיילר עוקב אחרי if, else, return, switch, &&, || ו-?: כדי לחשב את הטיפוס בכל נקודה, ולזה קוראים control flow analysis.
למה TypeScript לא מצמצם את הטיפוס שלי?
סיבות נפוצות: הבדיקה היא על ביטוי שונה מזה שבו משתמשים (בודקים obj.a, ומשתמשים ב-obj[key] עם key מטיפוס string); הערך הוא let שמושם לו ערך מחדש אחרי שנוצר callback, ולכן ה-callback מאבד את הצמצום; או שהבדיקה מוסתרת בפונקציית עזר שמחזירה boolean רגיל במקום type predicate מהצורה x is T.
האם type narrowing עובד בזמן ריצה?
הבדיקות כן: typeof, instanceof, in ו-=== הן JavaScript רגיל שרץ. הצמצום עצמו קיים רק בזמן קומפילציה. TypeScript קורא את הבדיקות שלכם בזמן ריצה ומתאים אליהן את הטיפוסים הסטטיים, ושום דבר לא נוסף ל-JavaScript שנוצר.
איך מצמצמים טיפוס unknown ב-TypeScript?
עם אותן בדיקות: typeof value === "string", Array.isArray(value), value instanceof Date, או עבור אובייקטים typeof value === "object" && value !== null && "id" in value. לבדיקות שחוזרות על עצמן כתבו פונקציית type guard עם טיפוס החזרה value is T.
איך מסננים undefined ממערך ב-TypeScript?
items.filter((x) => x !== undefined) מחזיר T[] בלי undefined מאז TypeScript 5.5, שמסיק את ה-callback כ-type predicate. בגרסאות ישנות יותר כתבו את ה-predicate בעצמכם: items.filter((x): x is T => x !== undefined).