לדלג לתוכן

5.5 טיפוסי איחוד וחיתוך הרצאה

טיפוסי איחוד וחיתוך - union and intersection types

טיפוסי איחוד (union) וחיתוך (intersection) הם מהכלים החזקים ביותר בטייפסקריפט. הם מאפשרים לבנות טיפוסים מורכבים מטיפוסים פשוטים, ולמדל מצבים מהעולם האמיתי בצורה מדויקת ובטוחה.

טיפוסי איחוד - union types

union type אומר "הערך יכול להיות אחד מהטיפוסים האלה":

let id: string | number;
id = "abc-123"; // ok
id = 42;        // ok
id = true;      // ERROR: Type 'boolean' is not assignable to type 'string | number'

union נפוץ מאוד בטייפסקריפט. כמה דוגמאות שכבר ראינו:

// nullable value
let name: string | null = null;

// function that accepts multiple types
function format(value: string | number): string {
    if (typeof value === "string") {
        return value.toUpperCase();
    }
    return value.toFixed(2);
}

// array of mixed types
let data: (string | number)[] = ["hello", 42, "world", 7];

טיפוסים ליטרליים - literal types

טיפוס ליטרלי מייצג ערך ספציפי אחד בלבד. השילוב של literal types עם union הוא דפוס מרכזי בטייפסקריפט:

type Direction = "up" | "down" | "left" | "right";

let dir: Direction = "up";     // ok
dir = "sideways";               // ERROR

type HttpStatus = 200 | 301 | 400 | 404 | 500;

let status: HttpStatus = 200;   // ok
status = 201;                    // ERROR

type YesNo = true | false;      // equivalent to boolean, but explicit

literal types מאפשרים להגביל ערכים לקבוצה ידועה מראש - בדומה ל-enum, אבל בצורה פשוטה וקלה יותר.

שימוש נפוץ - אפשרויות תצורה

type Theme = "light" | "dark" | "auto";
type Language = "he" | "en" | "ar";
type FontSize = "small" | "medium" | "large";

interface AppConfig {
    theme: Theme;
    language: Language;
    fontSize: FontSize;
}

let config: AppConfig = {
    theme: "dark",
    language: "he",
    fontSize: "medium"
};

איחוד מובחן - discriminated unions

זה הנושא הכי חשוב בשיעור. discriminated union הוא union של אובייקטים שלכולם יש שדה משותף (ה-discriminator) עם ערך ליטרלי שונה לכל טיפוס. זה מאפשר ל-TS לדעת בדיוק באיזה טיפוס מדובר.

הדפוס הבסיסי

interface LoginAction {
    type: "login";
    username: string;
    password: string;
}

interface LogoutAction {
    type: "logout";
    timestamp: number;
}

interface UpdateProfileAction {
    type: "updateProfile";
    field: string;
    value: string;
}

type UserAction = LoginAction | LogoutAction | UpdateProfileAction;

כשבודקים את ערך ה-type, TS מצמצמת אוטומטית:

function handleAction(action: UserAction): string {
    switch (action.type) {
        case "login":
            // TS knows: action is LoginAction
            return `User ${action.username} logged in`;
        case "logout":
            // TS knows: action is LogoutAction
            return `User logged out at ${action.timestamp}`;
        case "updateProfile":
            // TS knows: action is UpdateProfileAction
            return `Updated ${action.field} to ${action.value}`;
    }
}

למה זה כל כך חשוב

discriminated unions פותרים בעיה מרכזית: איך לייצג נתונים שיכולים להיות בכמה צורות שונות, בצורה בטוחה. בלי הדפוס הזה, הייתם צריכים שדות אופציונליים ובדיקות ידניות:

// BAD - optional fields, easy to make mistakes
interface Action {
    type: string;
    username?: string;
    password?: string;
    timestamp?: number;
    field?: string;
    value?: string;
}

// GOOD - discriminated union, TS enforces correctness
type Action = LoginAction | LogoutAction | UpdateProfileAction;

דוגמה - תוצאת פעולה (Result pattern)

דפוס נפוץ מאוד - ייצוג תוצאה שיכולה להצליח או להיכשל:

interface Success<T> {
    ok: true;
    data: T;
}

interface Failure {
    ok: false;
    error: string;
}

type Result<T> = Success<T> | Failure;

function fetchUser(id: number): Result<{ name: string; email: string }> {
    if (id <= 0) {
        return { ok: false, error: "Invalid user ID" };
    }
    return {
        ok: true,
        data: { name: "Alice", email: "alice@example.com" }
    };
}

let result = fetchUser(1);
if (result.ok) {
    // TS knows: result is Success
    console.log(result.data.name);
} else {
    // TS knows: result is Failure
    console.log(result.error);
}

דוגמה - תגובות API

interface PendingResponse {
    status: "pending";
    requestId: string;
}

interface SuccessResponse {
    status: "success";
    data: unknown;
    metadata: {
        timestamp: string;
        requestDuration: number;
    };
}

interface ErrorResponse {
    status: "error";
    errorCode: number;
    errorMessage: string;
    retryAfter?: number;
}

type ApiResponse = PendingResponse | SuccessResponse | ErrorResponse;

function handleResponse(response: ApiResponse): void {
    switch (response.status) {
        case "pending":
            console.log(`Request ${response.requestId} is pending...`);
            break;
        case "success":
            console.log(`Got data in ${response.metadata.requestDuration}ms`);
            break;
        case "error":
            console.log(`Error ${response.errorCode}: ${response.errorMessage}`);
            if (response.retryAfter) {
                console.log(`Retry after ${response.retryAfter}s`);
            }
            break;
    }
}

טיפוסי חיתוך - intersection types

intersection type משלב מספר טיפוסים לטיפוס אחד שמכיל את כל השדות:

type HasName = { name: string };
type HasAge = { age: number };
type HasEmail = { email: string };

type Person = HasName & HasAge & HasEmail;

// Person must have ALL fields from all three types
let person: Person = {
    name: "Alice",
    age: 30,
    email: "alice@example.com"
};

union (|) אומר "אחד מהם", intersection (&) אומר "כולם ביחד".

מתי להשתמש ב-intersection

intersection שימושי כשרוצים להרכיב טיפוס ממספר חלקים עצמאיים:

type Timestamped = {
    createdAt: string;
    updatedAt: string;
};

type SoftDeletable = {
    deletedAt: string | null;
    isDeleted: boolean;
};

type Auditable = {
    createdBy: string;
    updatedBy: string;
};

// combine all into a full user type
type User = {
    id: number;
    name: string;
    email: string;
} & Timestamped & SoftDeletable & Auditable;

זה מאפשר לשתף "תכונות" בין טיפוסים שונים בלי ירושה:

type Product = {
    id: number;
    name: string;
    price: number;
} & Timestamped & SoftDeletable;

type Comment = {
    id: number;
    text: string;
    authorId: number;
} & Timestamped;

intersection של unions

אפשר לשלב intersection עם union:

type StringOrNumber = string | number;
type NumberOrBoolean = number | boolean;

type Both = StringOrNumber & NumberOrBoolean;
// Both = number (the only type that's in both unions)

טיפוסי מצב - state machine types

discriminated unions מתאימים מצוין למידול מכונת מצבים (state machine):

interface IdleState {
    status: "idle";
}

interface LoadingState {
    status: "loading";
    startedAt: number;
}

interface SuccessState {
    status: "success";
    data: unknown;
    loadedAt: number;
}

interface ErrorState {
    status: "error";
    error: string;
    failedAt: number;
    retryCount: number;
}

type RequestState = IdleState | LoadingState | SuccessState | ErrorState;

function renderUI(state: RequestState): string {
    switch (state.status) {
        case "idle":
            return "Click to load";
        case "loading":
            return "Loading...";
        case "success":
            return `Data: ${JSON.stringify(state.data)}`;
        case "error":
            return `Error: ${state.error} (retries: ${state.retryCount})`;
    }
}

היתרון: אי אפשר ליצור מצב לא חוקי. למשל, אי אפשר שיהיה status: "error" בלי שדה error. הטיפוסים מבטיחים שכל מצב מכיל בדיוק את הנתונים הרלוונטיים אליו.

מעבר בין מצבים

אפשר גם לטפס את המעברים המותרים:

function transition(state: RequestState, event: "start" | "succeed" | "fail" | "reset"): RequestState {
    switch (event) {
        case "start":
            return { status: "loading", startedAt: Date.now() };
        case "succeed":
            if (state.status !== "loading") return state; // can only succeed from loading
            return { status: "success", data: {}, loadedAt: Date.now() };
        case "fail":
            if (state.status !== "loading") return state;
            let retryCount = 0;
            return { status: "error", error: "Something went wrong", failedAt: Date.now(), retryCount };
        case "reset":
            return { status: "idle" };
    }
}

דפוס Redux - actions ו-reducer

discriminated unions הם הבסיס של ניהול state ב-Redux ובספריות דומות:

interface Todo {
    id: number;
    text: string;
    completed: boolean;
}

// action types as discriminated union
type TodoAction =
    | { type: "ADD_TODO"; text: string }
    | { type: "TOGGLE_TODO"; id: number }
    | { type: "DELETE_TODO"; id: number }
    | { type: "CLEAR_COMPLETED" };

// the reducer
function todoReducer(state: Todo[], action: TodoAction): Todo[] {
    switch (action.type) {
        case "ADD_TODO":
            return [...state, { id: Date.now(), text: action.text, completed: false }];
        case "TOGGLE_TODO":
            return state.map(todo =>
                todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
            );
        case "DELETE_TODO":
            return state.filter(todo => todo.id !== action.id);
        case "CLEAR_COMPLETED":
            return state.filter(todo => !todo.completed);
    }
}

כשנגיע ל-React, נראה שזה בדיוק הדפוס שמשתמשים בו עם useReducer.

שילוב - union עם intersection

אפשר לשלב את שני הכלים יחד ליצירת טיפוסים עשירים:

type BaseEvent = {
    id: string;
    timestamp: number;
    source: string;
};

type ClickEvent = BaseEvent & {
    type: "click";
    x: number;
    y: number;
    target: string;
};

type InputEvent = BaseEvent & {
    type: "input";
    field: string;
    value: string;
    previousValue: string;
};

type NavigationEvent = BaseEvent & {
    type: "navigation";
    from: string;
    to: string;
};

type AnalyticsEvent = ClickEvent | InputEvent | NavigationEvent;

function logEvent(event: AnalyticsEvent): void {
    // all events have id, timestamp, source (from BaseEvent)
    console.log(`[${event.source}] Event ${event.id} at ${event.timestamp}`);

    switch (event.type) {
        case "click":
            console.log(`  Click on ${event.target} at (${event.x}, ${event.y})`);
            break;
        case "input":
            console.log(`  Input ${event.field}: "${event.previousValue}" -> "${event.value}"`);
            break;
        case "navigation":
            console.log(`  Navigate: ${event.from} -> ${event.to}`);
            break;
    }
}

הדפוס הזה - BaseType עם intersection ו-discriminated union - הוא שכיח מאוד בפרויקטים אמיתיים.

סיכום

  • union (|) - הערך הוא אחד מהטיפוסים. צריך narrowing כדי לגשת לשדות ספציפיים
  • literal types - ערך ספציפי כטיפוס: "hello", 42, true
  • discriminated unions - union של אובייקטים עם שדה discriminator ליטרלי. הדפוס הכי חשוב בשיעור
  • intersection (&) - הערך חייב לקיים את כל הטיפוסים. שימושי להרכבת תכונות
  • state machine types - discriminated unions מודלים מצבים בצורה בטוחה
  • דפוס Result - הצלחה או כישלון כ-discriminated union
  • דפוס Redux - actions כ-discriminated union, reducer עם switch
  • שילוב union עם intersection ליצירת מודלים עשירים ובטוחים