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 ליצירת מודלים עשירים ובטוחים