discriminated union (שנקרא גם tagged union) הוא union של טיפוסי אובייקט שלכולם יש מאפיין אחד משותף, התגית, עם ערך ליטרלי שונה בכל איבר. בדיקה של התגית אומרת ל-TypeScript איזה איבר יש לכם, והמאפיינים האחרים של האיבר הזה נהיים זמינים.
בתוך case "circle", קריאה של shape.width הייתה שגיאת קומפילציה, כי לאיבר של העיגול אין width. התגית היא מידע רגיל: בזמן ריצה היא סתם מאפיין מחרוזת, וה-switch הוא JavaScript רגיל.
מה הופך union ל-discriminated
צמצום לפי התגית עובד כששלושה תנאים מתקיימים:
- כל איבר הוא טיפוס אובייקט.
- לכל איבר יש את אותו שם מאפיין (ה-discriminant). השם תלוי בכם:
kind,type,statusו-tagנפוצים. - בכל איבר, למאפיין הזה יש טיפוס ליטרלי: ליטרל של מחרוזת, מספר או בוליאני, או
null/undefined.
type UiEvent =
| { type: "click"; x: number; y: number }
| { type: "keypress"; key: string }
| { type: "scroll"; delta: number };
type ApiResponse =
| { ok: true; data: string } // boolean literal tags
| { ok: false; error: string };
type Message =
| { version: 1; text: string } // number literal tags
| { version: 2; text: string; lang: string };
אם איבר אחד מצהיר על התגית כ-string רגיל במקום ליטרל, השוואת התגית כבר לא מצמצמת את ה-union, ומאפיינים ספציפיים לאיבר נשארים לא נגישים (TS2339).
צמצום לפי התגית
כל בדיקה על התגית ש-TypeScript מבינה מצמצמת את האובייקט: switch, if/else, === ו-!==, אפילו תגית שחולצה ב-destructuring, כל עוד היא const:
function describe(e: UiEvent): string {
if (e.type === "click") return `click at ${e.x},${e.y}`;
const { type } = e; // destructured tags narrow too
if (type === "keypress") return `key ${e.key}`;
return `scroll by ${e.delta}`; // only "scroll" is left
}
גם בדיקה של מאפיין עם "radius" in shape מצמצמת, אבל השוואה של תגית קריאה יותר ומאפשרת בדיקה ממצה.
בדיקות ממצות
היתרון הגדול ביותר של הדפוס מתגלה כשה-union גדל. עם טיפוס החזרה מפורש ובלי default, TypeScript יודעת שה-switch חייב לכסות כל תגית, ולכן איבר חדש בלי case הוא שגיאת קומפילציה:
index.ts(7,30): error TS2366: Function lacks ending return statement and return type does not include 'undefined'.
ההודעה הזו לא אומרת איזה מקרה חסר. פונקציית העזר assertNever כן אומרת, והיא גם זורקת בזמן ריצה אם מידע מבחוץ (JSON, לקוח ישן יותר) נושא תגית שלפי הטיפוסים לא יכולה להתקיים:
הוסיפו סטטוס חמישי בלי case והשורה assertNever(state) תדווח Argument of type '{ status: "..."; ... }' is not assignable to parameter of type 'never', עם שם האיבר. עוד על זה בעמוד never.
להפוך מצבים בלתי אפשריים לבלתי אפשריים
RequestState שלמעלה מחליף מבנה נפוץ וחלש יותר:
// Every combination is allowed, including nonsense
type LooseState<T> = {
loading: boolean;
data?: T;
error?: string;
};
const nonsense: LooseState<string[]> = { loading: true, data: ["a"], error: "timeout" };
עם שדות אופציונליים, "טוען עם מידע ועם שגיאה" עובר בדיקת טיפוסים, וכל קורא צריך לנחש אילו צירופים באמת יכולים לקרות. עם ה-discriminated union, data קיים רק במצב success ו-error רק ב-error, ולכן קוד שקורא את state.data חייב להוכיח קודם שהוא במצב success. הטיפוס עצמו מתעד את המצבים התקינים.
תיאור תוצאות: הצלחה או כישלון
תגית בוליאנית מספיקה ל"זה עבד או לא". טיפוס ה-Result הזה הוא חלופה נפוצה לזריקת חריגות בכישלונות צפויים כמו קלט לא תקין:
הקורא לא יכול לקרוא את result.value בלי לבדוק קודם את result.ok, וזו בדיוק הבדיקה שקל לשכוח עם חריגות או עם החזרת null.
מכונות מצבים ו-Reducers
שני discriminated unions, אחד למצבים ואחד לפעולות, מתארים מכונת מצבים. reducer עושה switch על הפעולה ומחזיר את המצב הבא; הקומפיילר בודק שכל אובייקט שמוחזר הוא מצב תקין:
{ ...state, name: "paused" } עובר קומפילציה רק כי state צומצם קודם לאיבר של playing, ולכן העותק נושא את track ואת position. החזרה של { name: "paused" } לבד הייתה שגיאה: מצב paused צריך track. זה אותו מבנה שבו משתמשים reducers של Redux ו-useReducer ב-React.
מלכודת ההרחבה
התגית חייבת להישאר טיפוס ליטרלי. לאובייקט שנבנה לתוך משתנה בלי הערת טיפוס, התגית מורחבת ל-string, ואז הוא לא מתאים לאף איבר:
type Shape = { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };
declare function area(shape: Shape): number;
const c = { kind: "circle", radius: 2 }; // kind: string
area(c);
// error TS2345: Argument of type '{ kind: string; radius: number; }' is not assignable to parameter of type 'Shape'.
const ok1: Shape = { kind: "circle", radius: 2 }; // annotate the variable
const ok2 = { kind: "circle", radius: 2 } as const; // or keep the literal with as const
area({ kind: "circle", radius: 2 }); // or build it where Shape is expected
הכלל שמאחורי זה, שמאפיינים שניתנים לשינוי מורחבים, מוסבר בעמוד literal types.
שאלות נפוצות
מה זה discriminated union ב-TypeScript?
union של טיפוסי אובייקט שבו לכל איבר יש את אותו מאפיין (ה-discriminant או התגית) עם טיפוס ליטרלי שונה, למשל { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number }. בדיקה של shape.kind === "circle" מצמצמת את shape לאיבר של העיגול, ולכן המאפיינים האחרים שלו נהיים זמינים.
מה ההבדל בין union ל-discriminated union?
discriminated union הוא union עם כלל אחד נוסף: כל האיברים חולקים מאפיין עם טיפוס ליטרלי ייחודי. union רגיל כמו Cat | Fish חייב להצטמצם עם in או עם type guards מותאמים; discriminated union מצטמצם בהשוואה של מאפיין אחד, ואפשר לבדוק ש-switch על המאפיין הזה ממצה.
איך הופכים switch על discriminated union לממצה?
או שנותנים לפונקציה טיפוס החזרה מפורש בלי default (מקרה חסר הוא אז השגיאה TS2366), או שמוסיפים default: return assertNever(value) עם function assertNever(x: never): never { throw ... }. הצורה השנייה מציינת בשגיאה את האיבר שלא טופל, וגם זורקת בזמן ריצה אם מגיע מידע לא צפוי.
למה ה-discriminated union שלי לא מצטמצם?
התגית חייבת להיות טיפוס ליטרלי בכל איבר. אם אובייקט נוצר בלי הערת טיפוס, ה-kind שלו מורחב ל-string והוא כבר לא מתאים לאף איבר (TS2345/TS2322). תקנו עם הערת טיפוס, עם as const, או ביצירה שלו במקום שבו מצופה טיפוס ה-union. גם איבר עם kind: string מונע מה-union להצטמצם לפי kind.
האם ה-discriminant יכול להיות בוליאני או מספר?
כן. כל טיפוס ליטרלי עובד: מחרוזות (kind: "circle"), מספרים (version: 2), בוליאנים (ok: true / ok: false), ואפילו null או undefined. תגיות מחרוזת הן הנפוצות ביותר כי הן קריאות בלוגים או כשהן נשלחות כ-JSON.