לדלג לתוכן

5.6 גנריקס הרצאה

גנריקס - generics

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

הבעיה בלי גנריקס

נניח שרוצים פונקציה שמחזירה את האיבר הראשון במערך:

function firstNumber(arr: number[]): number | undefined {
    return arr[0];
}

function firstString(arr: string[]): string | undefined {
    return arr[0];
}

// same logic, duplicated for each type!

אפשרות אחרת - להשתמש ב-any:

function first(arr: any[]): any {
    return arr[0];
}

let result = first([1, 2, 3]); // type is 'any' - we lost type safety!

שתי הגישות בעייתיות. גנריקס פותרים את זה.

פונקציות גנריות - generic functions

function first<T>(arr: T[]): T | undefined {
    return arr[0];
}

let num = first([1, 2, 3]);           // T is number -> returns number | undefined
let str = first(["a", "b", "c"]);      // T is string -> returns string | undefined
let bool = first([true, false]);       // T is boolean -> returns boolean | undefined

T הוא פרמטר טיפוס (type parameter). כש-TS רואה את הארגומנט [1, 2, 3], היא מסיקה ש-T = number, ולכן טיפוס ההחזרה הוא number | undefined.

הגדרה מפורשת של הטיפוס

לפעמים TS לא מצליחה להסיק את הטיפוס. אפשר לציין אותו במפורש:

let result = first<string>([]); // T is string, result is string | undefined

דוגמאות נוספות

// identity function - returns whatever it gets
function identity<T>(value: T): T {
    return value;
}

let x = identity(42);       // number
let y = identity("hello");  // string

// map function
function map<T, U>(arr: T[], fn: (item: T) => U): U[] {
    return arr.map(fn);
}

let lengths = map(["hello", "world"], s => s.length);
// T = string, U = number -> returns number[]

// pair
function pair<A, B>(first: A, second: B): [A, B] {
    return [first, second];
}

let p = pair("hello", 42); // [string, number]

אפשר להשתמש בכמה פרמטרי טיפוס. הקונבנציה היא אותיות גדולות: T (Type), U, V, או שמות תיאוריים כמו TKey, TValue.

אינטרפייסים גנריים - generic interfaces

interface Box<T> {
    value: T;
    isEmpty: boolean;
}

let numberBox: Box<number> = { value: 42, isEmpty: false };
let stringBox: Box<string> = { value: "hello", isEmpty: false };

דוגמה מעשית - תגובה מ-API:

interface ApiResponse<T> {
    data: T;
    status: number;
    message: string;
    timestamp: string;
}

interface User {
    id: number;
    name: string;
    email: string;
}

interface Product {
    id: number;
    name: string;
    price: number;
}

// the same response structure, different data
let userResponse: ApiResponse<User> = {
    data: { id: 1, name: "Alice", email: "alice@example.com" },
    status: 200,
    message: "OK",
    timestamp: "2025-01-01T00:00:00Z"
};

let productResponse: ApiResponse<Product> = {
    data: { id: 1, name: "Laptop", price: 999 },
    status: 200,
    message: "OK",
    timestamp: "2025-01-01T00:00:00Z"
};

רשימה מקושרת גנרית

interface ListNode<T> {
    value: T;
    next: ListNode<T> | null;
}

let list: ListNode<number> = {
    value: 1,
    next: {
        value: 2,
        next: {
            value: 3,
            next: null
        }
    }
};

type aliases גנריים

type Result<T> =
    | { success: true; value: T }
    | { success: false; error: string };

type Nullable<T> = T | null;

type ReadonlyArray2<T> = readonly T[];

type Pair<A, B> = {
    first: A;
    second: B;
};

classes גנריים

class Stack<T> {
    private items: T[] = [];

    push(item: T): void {
        this.items.push(item);
    }

    pop(): T | undefined {
        return this.items.pop();
    }

    peek(): T | undefined {
        return this.items[this.items.length - 1];
    }

    get size(): number {
        return this.items.length;
    }
}

let numberStack = new Stack<number>();
numberStack.push(1);
numberStack.push(2);
numberStack.push(3);
console.log(numberStack.pop()); // 3
console.log(numberStack.peek()); // 2

let stringStack = new Stack<string>();
stringStack.push("hello");
stringStack.push("world");

דוגמה מעשית - מטמון (cache):

class Cache<T> {
    private store: Map<string, { value: T; expiresAt: number }> = new Map();

    set(key: string, value: T, ttlMs: number): void {
        this.store.set(key, {
            value,
            expiresAt: Date.now() + ttlMs
        });
    }

    get(key: string): T | undefined {
        let entry = this.store.get(key);
        if (!entry) return undefined;
        if (Date.now() > entry.expiresAt) {
            this.store.delete(key);
            return undefined;
        }
        return entry.value;
    }
}

let userCache = new Cache<User>();
userCache.set("alice", { id: 1, name: "Alice", email: "a@b.com" }, 60000);

let configCache = new Cache<Record<string, string>>();
configCache.set("app", { theme: "dark", lang: "he" }, 300000);

אילוצים - constraints

לפעמים רוצים שהטיפוס הגנרי יקיים תנאים מסוימים. extends מגביל את הטיפוסים המותרים:

// T must have a 'length' property
function logLength<T extends { length: number }>(value: T): void {
    console.log(`Length: ${value.length}`);
}

logLength("hello");       // ok - string has length
logLength([1, 2, 3]);     // ok - array has length
logLength({ length: 10 }); // ok - has length property
logLength(42);             // ERROR: number doesn't have 'length'

אילוץ עם אינטרפייס

interface HasId {
    id: number;
}

function findById<T extends HasId>(items: T[], id: number): T | undefined {
    return items.find(item => item.id === id);
}

interface User {
    id: number;
    name: string;
    email: string;
}

interface Product {
    id: number;
    name: string;
    price: number;
}

let users: User[] = [
    { id: 1, name: "Alice", email: "a@b.com" },
    { id: 2, name: "Bob", email: "b@b.com" }
];

let found = findById(users, 1); // type: User | undefined

הפונקציה עובדת עם כל טיפוס שיש לו id: number, ומחזירה את הטיפוס הספציפי (לא רק HasId).

keyof - מפתחות של טיפוס

keyof מחזיר union של כל המפתחות (שמות השדות) של טיפוס:

interface User {
    name: string;
    age: number;
    email: string;
}

type UserKeys = keyof User; // "name" | "age" | "email"

שילוב עם גנריקס - גישה בטוחה לשדות:

function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
    return obj[key];
}

let user: User = { name: "Alice", age: 30, email: "a@b.com" };

let name = getProperty(user, "name");    // type: string
let age = getProperty(user, "age");      // type: number
getProperty(user, "address");            // ERROR: "address" is not in keyof User

T[K] הוא lookup type - הטיפוס של השדה K באובייקט T. כש-K הוא "name" ו-T הוא User, אז T[K] הוא string.

דוגמה מעשית - פונקציית pick

function pick<T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
    let result = {} as Pick<T, K>;
    for (let key of keys) {
        result[key] = obj[key];
    }
    return result;
}

let user = { name: "Alice", age: 30, email: "a@b.com", password: "secret" };
let publicInfo = pick(user, ["name", "email"]);
// type: { name: string; email: string }

ברירת מחדל לטיפוס גנרי - default type parameters

כמו ברירות מחדל בפרמטרים של פונקציות, אפשר לתת ברירת מחדל לפרמטר טיפוס:

interface ApiResponse<T = unknown> {
    data: T;
    status: number;
    message: string;
}

// can use without specifying T
let response: ApiResponse = {
    data: "something",
    status: 200,
    message: "OK"
};
// response.data is unknown

// or specify T explicitly
let userResponse: ApiResponse<User> = {
    data: { id: 1, name: "Alice", email: "a@b.com" },
    status: 200,
    message: "OK"
};
// userResponse.data is User

דוגמה נוספת - קומפוננטה של רשימה:

interface ListProps<T = string> {
    items: T[];
    renderItem: (item: T) => string;
    emptyMessage?: string;
}

function renderList<T = string>(props: ListProps<T>): string {
    if (props.items.length === 0) {
        return props.emptyMessage ?? "No items";
    }
    return props.items.map(props.renderItem).join("\n");
}

// T defaults to string
renderList({
    items: ["Alice", "Bob"],
    renderItem: (name) => `- ${name}`
});

// T explicitly set to number
renderList<number>({
    items: [1, 2, 3],
    renderItem: (n) => `#${n}`
});

דפוסים נפוצים עם גנריקס

מפעל - factory pattern

function createArray<T>(length: number, value: T): T[] {
    return Array(length).fill(value);
}

let zeros = createArray(5, 0);         // number[]
let hellos = createArray(3, "hello");  // string[]

מיפוי - mapping

function mapObject<T, U>(
    obj: Record<string, T>,
    fn: (value: T, key: string) => U
): Record<string, U> {
    let result: Record<string, U> = {};
    for (let key in obj) {
        result[key] = fn(obj[key], key);
    }
    return result;
}

let prices = { apple: 5, banana: 3, cherry: 8 };
let formatted = mapObject(prices, (price) => `$${price.toFixed(2)}`);
// { apple: "$5.00", banana: "$3.00", cherry: "$8.00" }

אירועים - event emitter

interface EventMap {
    click: { x: number; y: number };
    keypress: { key: string };
    resize: { width: number; height: number };
}

class TypedEventEmitter<T extends Record<string, unknown>> {
    private handlers: Partial<Record<keyof T, ((data: any) => void)[]>> = {};

    on<K extends keyof T>(event: K, handler: (data: T[K]) => void): void {
        if (!this.handlers[event]) {
            this.handlers[event] = [];
        }
        this.handlers[event]!.push(handler);
    }

    emit<K extends keyof T>(event: K, data: T[K]): void {
        let eventHandlers = this.handlers[event];
        if (eventHandlers) {
            for (let handler of eventHandlers) {
                handler(data);
            }
        }
    }
}

let emitter = new TypedEventEmitter<EventMap>();

emitter.on("click", (data) => {
    // TS knows data is { x: number; y: number }
    console.log(`Click at ${data.x}, ${data.y}`);
});

emitter.emit("click", { x: 10, y: 20 }); // ok
emitter.emit("click", { x: 10 });          // ERROR: missing 'y'

סיכום

  • גנריקס מאפשרים קוד גמיש בלי לוותר על בטיחות טיפוסים
  • <T> הוא פרמטר טיפוס שנקבע בזמן השימוש
  • TS מסיקה את הטיפוס הגנרי מהארגומנטים ברוב המקרים
  • אפשר להשתמש בגנריקס בפונקציות, אינטרפייסים, type aliases, ו-classes
  • אילוצים (extends) מגבילים אילו טיפוסים מותרים
  • keyof מחזיר union של מפתחות טיפוס
  • T[K] הוא lookup type - הטיפוס של שדה ספציפי
  • ברירת מחדל (T = type) מאפשרת שימוש בלי ציון מפורש
  • דפוסים נפוצים: factory, mapping, typed events