לדלג לתוכן

5.4 פונקציות וצמצום טיפוסים הרצאה

פונקציות וצמצום טיפוסים - functions and narrowing

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

פונקציות מוטייפות - typed functions

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

// basic typed function
function add(a: number, b: number): number {
    return a + b;
}

// arrow function with types
let multiply = (a: number, b: number): number => a * b;

// return type is usually inferred - no need to write it
function greet(name: string) {
    return `Hello, ${name}!`; // TS infers return type: string
}

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

פרמטרים אופציונליים ועם ברירת מחדל

// optional parameter - must come after required ones
function createUser(name: string, age?: number): string {
    if (age !== undefined) {
        return `${name}, age ${age}`;
    }
    return name;
}

createUser("Alice");      // "Alice"
createUser("Alice", 30);  // "Alice, age 30"

// default value - also makes the parameter optional
function createUser2(name: string, role: string = "user"): string {
    return `${name} (${role})`;
}

createUser2("Alice");          // "Alice (user)"
createUser2("Alice", "admin"); // "Alice (admin)"

פרמטרים מסוג rest

function sum(...numbers: number[]): number {
    return numbers.reduce((total, n) => total + n, 0);
}

sum(1, 2, 3);       // 6
sum(1, 2, 3, 4, 5); // 15

טיפוס של פונקציה - function type

אפשר להגדיר את הטיפוס של פונקציה בנפרד:

type Formatter = (value: number) => string;

let toFixed: Formatter = (value) => value.toFixed(2);
let toCurrency: Formatter = (value) => `$${value.toFixed(2)}`;

function formatValues(values: number[], formatter: Formatter): string[] {
    return values.map(formatter);
}

formatValues([1.5, 2.333, 10], toFixed);     // ["1.50", "2.33", "10.00"]
formatValues([1.5, 2.333, 10], toCurrency);  // ["$1.50", "$2.33", "$10.00"]

עומס - function overloads

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

// overload signatures
function parse(value: string): number;
function parse(value: number): string;

// implementation signature - must handle all overloads
function parse(value: string | number): number | string {
    if (typeof value === "string") {
        return parseInt(value);
    }
    return value.toString();
}

let num = parse("42");   // TS knows: number
let str = parse(42);     // TS knows: string

החתימות העליונות הן מה שהמשתמש רואה. חתימת ה-implementation היא פנימית ולא נראית מבחוץ.

דוגמה מעשית - פונקציה שמחזירה אלמנט או מערך:

function getElement(id: string): HTMLElement;
function getElement(className: string, all: true): HTMLElement[];

function getElement(selector: string, all?: boolean): HTMLElement | HTMLElement[] {
    if (all) {
        return Array.from(document.querySelectorAll(selector)) as HTMLElement[];
    }
    return document.querySelector(selector) as HTMLElement;
}

let one = getElement("#header");        // HTMLElement
let many = getElement(".item", true);   // HTMLElement[]

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

אישור טיפוס - type assertions

לפעמים אנחנו יודעים יותר מ-TS על הטיפוס. as מאפשר "להגיד" ל-TS מה הטיפוס:

// TS doesn't know what querySelector returns specifically
let input = document.querySelector("#username") as HTMLInputElement;
input.value = "Alice"; // ok - TS trusts us that it's an HTMLInputElement

// alternative syntax (doesn't work in JSX/TSX files)
let input2 = <HTMLInputElement>document.querySelector("#username");

זהירות: type assertions לא מבצעות בדיקה ב-runtime. אם טעיתם, הקוד יקרוס:

let value: unknown = "hello";
let num = value as number; // no error at compile time
console.log(num.toFixed(2)); // CRASH at runtime! "hello" doesn't have toFixed

כלל: השתמשו ב-type assertions רק כשאתם בטוחים. אם אפשר, העדיפו narrowing (ראו בהמשך).

צמצום טיפוסים - type narrowing

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

typeof

הבדיקה הכי בסיסית - typeof מצמצמת טיפוסים פרימיטיביים:

function printValue(value: string | number): void {
    if (typeof value === "string") {
        // here TS knows value is string
        console.log(value.toUpperCase());
    } else {
        // here TS knows value is number
        console.log(value.toFixed(2));
    }
}

typeof עובד עם: "string", "number", "boolean", "undefined", "object", "function", "bigint", "symbol".

instanceof

בודק אם אובייקט הוא מופע של class:

function formatError(error: Error | string): string {
    if (error instanceof Error) {
        // here TS knows error is Error
        return `Error: ${error.message}\nStack: ${error.stack}`;
    }
    // here TS knows error is string
    return `Error: ${error}`;
}
class Dog {
    bark() { console.log("Woof!"); }
}

class Cat {
    meow() { console.log("Meow!"); }
}

function makeSound(animal: Dog | Cat): void {
    if (animal instanceof Dog) {
        animal.bark();  // ok - TS knows it's Dog
    } else {
        animal.meow();  // ok - TS knows it's Cat
    }
}

אופרטור in

בודק אם property קיים באובייקט:

interface Fish {
    swim(): void;
}

interface Bird {
    fly(): void;
}

function move(animal: Fish | Bird): void {
    if ("swim" in animal) {
        animal.swim(); // ok - TS knows it has swim
    } else {
        animal.fly();  // ok - TS knows it has fly
    }
}

in שימושי כשאין class (ולכן אי אפשר להשתמש ב-instanceof) ורוצים להבדיל בין אינטרפייסים.

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

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

interface Circle {
    type: "circle";
    radius: number;
}

interface Rectangle {
    type: "rectangle";
    width: number;
    height: number;
}

interface Triangle {
    type: "triangle";
    base: number;
    height: number;
}

type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
    switch (shape.type) {
        case "circle":
            // TS knows shape is Circle
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            // TS knows shape is Rectangle
            return shape.width * shape.height;
        case "triangle":
            // TS knows shape is Triangle
            return (shape.base * shape.height) / 2;
    }
}

ה-discriminator (במקרה הזה type) הוא השדה שמבדיל בין הטיפוסים. כשבודקים את ערכו, TS יודעת בדיוק באיזה טיפוס מדובר.

פרדיקט טיפוסים - type predicates

פונקציה שמחזירה value is Type אומרת ל-TS שאם הפונקציה מחזירה true, הערך הוא מהטיפוס הזה:

interface Fish {
    swim(): void;
}

interface Bird {
    fly(): void;
}

// type predicate - the return type is 'animal is Fish'
function isFish(animal: Fish | Bird): animal is Fish {
    return "swim" in animal;
}

function move(animal: Fish | Bird): void {
    if (isFish(animal)) {
        animal.swim(); // TS knows it's Fish
    } else {
        animal.fly();  // TS knows it's Bird
    }
}

type predicates שימושיים כשלוגיקת הבדיקה מורכבת ורוצים לחלץ אותה לפונקציה נפרדת.

דוגמה מעשית - סינון null מתוך מערך:

function isNotNull<T>(value: T | null): value is T {
    return value !== null;
}

let values: (string | null)[] = ["hello", null, "world", null, "!"];
let filtered = values.filter(isNotNull);
// filtered: string[] - no more nulls!

בדיקה ממצה עם never - exhaustive checking

never מאפשר לוודא שטיפלנו בכל המקרים האפשריים:

type Shape = Circle | Rectangle | Triangle;

function getArea(shape: Shape): number {
    switch (shape.type) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "rectangle":
            return shape.width * shape.height;
        case "triangle":
            return (shape.base * shape.height) / 2;
        default:
            // if we handled all cases, shape is 'never' here
            const exhaustiveCheck: never = shape;
            return exhaustiveCheck;
    }
}

למה זה שימושי? אם מוסיפים טיפוס חדש ל-union (למשל Pentagon) ושוכחים לטפל בו, TS תתן שגיאת קומפילציה ב-default case - כי Pentagon לא ניתן להשמה ל-never.

// later, someone adds Pentagon to the union:
type Shape = Circle | Rectangle | Triangle | Pentagon;

// now getArea gives a compile error:
// Type 'Pentagon' is not assignable to type 'never'

זה מבטיח שלעולם לא נשכח לטפל במקרה חדש.

שרשור צמצום - control flow narrowing

TS עוקבת אחרי הטיפוס לאורך כל הפונקציה:

function processValue(value: string | number | null): string {
    if (value === null) {
        return "null";
    }
    // here value is string | number (null was eliminated)

    if (typeof value === "number") {
        return value.toFixed(2);
    }
    // here value is string (number was eliminated too)

    return value.toUpperCase();
}

TS מבינה גם return מוקדם, throw, ולולאות - כל מה שמשפיע על flow הקוד.

סיכום

  • פונקציות צריכות טיפוסים לפרמטרים. טיפוס ההחזרה בדרך כלל מוסק
  • פרמטרים אופציונליים עם ?, ברירות מחדל עם =, rest עם ...
  • function overloads מגדירים חתימות שונות לפונקציה אחת
  • type assertions (as) אומרות ל-TS מה הטיפוס - זהירות, אין בדיקה ב-runtime
  • צמצום (narrowing) מצמצם טיפוס רחב לטיפוס ספציפי
  • כלים לצמצום: typeof, instanceof, in, discriminated unions
  • type predicates (is) מאפשרים ליצור פונקציות צמצום מותאמות
  • בדיקה ממצה עם never מוודאת שטיפלנו בכל המקרים
  • TS עוקבת אחרי הטיפוס לאורך כל ה-control flow