Union, Narrowing ו-Literal Types

השיעור הזה הוא הלב של TypeScript. Union מתאר ערך שיכול להיות אחד מכמה דברים, ו-Narrowing הוא הדרך שבה TypeScript "מבינה" מתוך תנאי רגיל באיזה מהם מדובר. יחד הם מאפשרים לתאר מצבים אמיתיים של אפליקציה בדיוק.

Union: זה או זה

type Id = number | string;

function printId(id: Id) {
    console.log(`המזהה הוא ${id}`);
    id.toUpperCase();
    // Property 'toUpperCase' does not exist on type 'number'.
}

על Union מותר לעשות רק מה שמותר על כל האפשרויות. כדי להשתמש במשהו ספציפי, צריך לבדוק קודם.

Narrowing: הבדיקה מצמצמת את הטיפוס

function printId(id: number | string) {
    if (typeof id === 'string') {
        console.log(id.toUpperCase());   // כאן id הוא string
    } else {
        console.log(id.toFixed(0));      // וכאן number
    }
}

אין כאן תחביר מיוחד: אותו if של JavaScript. TypeScript עוקבת אחרי התנאים ויודעת מה הטיפוס בכל ענף. דרכים נפוצות לצמצם:

בדיקהדוגמה
typeoftypeof value === 'string'
אמת/שקרif (user) מוציא את null ו-undefined
Array.isArrayArray.isArray(value)
in'email' in contact
instanceoferror instanceof Error
השוואה לערךstatus === 'done'

דוגמה יומיומית: catch

try {
    await saveOrder(order);
} catch (error) {
    // error הוא unknown: כל דבר יכול להיזרק ב-JavaScript
    const message = error instanceof Error ? error.message : String(error);
    showToast(`השמירה נכשלה: ${message}`);
}

Discriminated Union: מצבים שלא מתערבבים

הכלי החזק ביותר בשיעור. כל מצב מקבל שדה משותף עם ערך קבוע (כאן status), ולכל מצב יש רק את השדות שהגיוניים בו:

type RequestState =
    | { status: 'loading' }
    | { status: 'error'; message: string }
    | { status: 'success'; data: string[] };

function render(state: RequestState): string {
    switch (state.status) {
        case 'loading':
            return 'טוען...';
        case 'error':
            return `שגיאה: ${state.message}`;          // message קיים רק כאן
        case 'success':
            return `נמצאו ${state.data.length} תוצאות`;  // data קיים רק כאן
    }
}

השוו לחלופה הנפוצה: { loading: boolean; error?: string; data?: string[] }. שם אפשר בטעות להיות גם בטעינה וגם עם שגיאה, ו-data תמיד "אולי undefined". עם Discriminated Union מצבים בלתי אפשריים פשוט לא ניתנים לכתיבה.

לוודא שטיפלתם בכל המקרים

function assertNever(value: never): never {
    throw new Error(`מצב לא צפוי: ${JSON.stringify(value)}`);
}

function icon(state: RequestState): string {
    switch (state.status) {
        case 'loading': return '⏳';
        case 'error': return '❌';
        case 'success': return '✅';
        default: return assertNever(state);
    }
}

מחר מישהו מוסיף מצב 'idle' לטיפוס? כל switch שלא טיפל בו יסומן בשגיאה, כי state כבר לא never ב-default.

Type Guard: בדיקה משלכם

type Cat = { meow: () => void };
type Dog = { bark: () => void };

// value is Cat: "אם הפונקציה מחזירה true, זה Cat"
function isCat(animal: Cat | Dog): animal is Cat {
    return 'meow' in animal;
}

function speak(animal: Cat | Dog) {
    if (isCat(animal)) animal.meow();
    else animal.bark();
}

בדקו את עצמכם

נסו לענות לבד לפני שאתם פותחים את התשובה.

  1. מה זה Narrowing?

    1. המרה בין טיפוסים
    2. מחיקת טיפוסים
    3. קיצור שמות
    4. צמצום הטיפוס בתוך ענף של תנאי, לפי בדיקה כמו typeof
    הצגת התשובה

    תשובה ד. אחרי if (typeof x === 'string'), TypeScript יודעת ש-x הוא string.

  2. מה היתרון של Discriminated Union לתיאור מצב טעינה?

    1. מצבים בלתי אפשריים (גם טעינה וגם שגיאה) לא ניתנים לכתיבה
    2. אין יתרון
    3. קוד קצר יותר
    4. מהירות
    הצגת התשובה

    תשובה א. כל מצב מחזיק רק את השדות שהגיוניים בו.

  3. מה הטיפוס של error בתוך catch עם strict?

    1. any
    2. Error
    3. string
    4. unknown
    הצגת התשובה

    תשובה ד. אפשר לזרוק כל דבר ב-JavaScript, ולכן בודקים error instanceof Error.