Skip to main content

⚡ Functions in TypeScript

Functions are the heart of every application - they're where the action happens! In this lesson, we'll transform your JavaScript functions into type-safe powerhouses. You'll learn how to make your functions bulletproof, self-documenting, and impossible to misuse. Let's turn good functions into great functions! 🚀

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Type function parameters and return values with confidence
  • Use optional and default parameters effectively
  • Master rest parameters for variable-length arguments
  • Create function overloads for flexible APIs
  • Write arrow functions with proper type annotations
  • Use function types and callbacks safely

Estimated Time: 60-75 minutes

Project: Build a type-safe data processing pipeline with various function types

📑 In This Lesson

Function Basics: Parameters and Return Types

Every function is a mini contract: "Give me these inputs, I'll give you this output." TypeScript lets you write that contract explicitly, making your code self-documenting and error-proof. Let's start with the fundamentals! 📝

Basic Function Syntax

In TypeScript, you add types to both parameters and return values:

// Basic function with types
function greet(name: string): string {
    return `Hello, ${name}!`;
}

// Call the function
const message = greet("Alice");  // ✅ OK
console.log(message);  // "Hello, Alice!"

// TypeScript catches errors
greet(123);       // ❌ Error! Argument of type 'number' is not assignable to parameter of type 'string'
greet();          // ❌ Error! Expected 1 argument, but got 0

Let's break down the anatomy of a typed function:

TypeScript Function Anatomy Visual breakdown of a TypeScript function showing: function keyword, function name, parameters with types, return type, and function body Function Anatomy function add ( a : number , b : number ): number { return a + b ; } Keyword Name Params + Types Return Type Function Body
// function keyword
// ↓
function add(a: number, b: number): number {
//          ↑          ↑          ↑      ↑
//          param1     param2     colon  return type
    return a + b;
}

✅ Why Type Both Parameters AND Return Values?

  • Parameters: Tell callers what to pass in (prevents misuse)
  • Return type: Ensures you return what you promise (catches bugs in implementation)
  • Documentation: The signature IS the documentation!
  • Refactoring: Change the return type, TypeScript shows all affected code

Multiple Parameters

Functions can have as many parameters as needed, each with its own type:

// Multiple parameters with different types
function createUser(
    id: number,
    name: string,
    email: string,
    isActive: boolean
): string {
    return `User ${id}: ${name} (${email}) - Active: ${isActive}`;
}

const userInfo = createUser(1, "Alice", "alice@example.com", true);
console.log(userInfo);

// Mix parameter types freely
function calculateDiscount(
    price: number,
    discountPercent: number,
    membershipLevel: "bronze" | "silver" | "gold"
): number {
    let discount = price * (discountPercent / 100);
    
    // Additional discount based on membership
    if (membershipLevel === "gold") {
        discount *= 1.5;
    } else if (membershipLevel === "silver") {
        discount *= 1.2;
    }
    
    return price - discount;
}

const finalPrice = calculateDiscount(100, 10, "gold");
console.log(finalPrice);  // 85 (10% discount + 50% bonus for gold)

void Return Type

When a function doesn't return anything (or returns undefined), use void:

// Function that performs an action but returns nothing
function logMessage(message: string): void {
    console.log(`[LOG] ${message}`);
    // No return statement needed
}

function sendEmail(to: string, subject: string, body: string): void {
    console.log(`Sending email to ${to}`);
    console.log(`Subject: ${subject}`);
    console.log(`Body: ${body}`);
    // Side effect only, no return value
}

logMessage("Application started");
sendEmail("user@example.com", "Welcome!", "Thanks for signing up!");

// void functions can return undefined explicitly
function doNothing(): void {
    return undefined;  // ✅ OK
    // return null;    // ❌ Error! null is not void
    // return 5;       // ❌ Error! number is not void
}
💡 Pro Tip: Use void for functions that perform side effects (logging, saving to database, updating UI). Use a specific return type for functions that compute and return values.

never Return Type

Use never for functions that never return (they throw errors or run forever):

// Function that always throws an error
function throwError(message: string): never {
    throw new Error(message);
    // Execution never continues past here
}

// Function that runs forever
function infiniteLoop(): never {
    while (true) {
        console.log("Still running...");
    }
    // Never exits
}

// Useful for exhaustive checks
type Status = "pending" | "approved" | "rejected";

function handleStatus(status: Status): string {
    switch (status) {
        case "pending":
            return "Waiting for approval";
        case "approved":
            return "Request approved";
        case "rejected":
            return "Request rejected";
        default:
            // If we get here, we forgot to handle a case
            const exhaustiveCheck: never = status;
            throw new Error(`Unhandled status: ${exhaustiveCheck}`);
    }
}

Type Inference for Return Types

TypeScript can infer return types, but explicit is often better:

// TypeScript infers return type is number
function add(a: number, b: number) {
    return a + b;  // TypeScript knows this returns number
}

// But explicit is clearer and safer
function add2(a: number, b: number): number {
    return a + b;
}

// Inference catches mistakes
function calculate(x: number) {
    if (x > 0) {
        return x * 2;        // returns number
    }
    return "negative";       // returns string
    // TypeScript infers: number | string
    // This might not be what you wanted!
}

// Explicit type catches the bug
function calculate2(x: number): number {
    if (x > 0) {
        return x * 2;
    }
    return "negative";  // ❌ Error! Type 'string' is not assignable to type 'number'
}

⚠️ Best Practice: Always Type Return Values

Even though TypeScript can infer return types, explicitly typing them is recommended because:

  • Makes your intent clear
  • Catches implementation bugs
  • Helps with API design
  • Better documentation

Real-World Example: User Authentication

// Return types make the function contract crystal clear
function authenticateUser(
    username: string,
    password: string
): { success: true; token: string } | { success: false; error: string } {
    // Simulate authentication
    if (username === "admin" && password === "secret") {
        return {
            success: true,
            token: "jwt-token-12345"
        };
    }
    
    return {
        success: false,
        error: "Invalid credentials"
    };
}

// Usage with type safety
const result = authenticateUser("admin", "secret");

if (result.success) {
    // TypeScript knows result has 'token' here
    console.log("Login successful! Token:", result.token);
} else {
    // TypeScript knows result has 'error' here
    console.log("Login failed:", result.error);
}
graph LR A[Function Call] --> B[Parameters] B --> C[Function Body] C --> D[Return Value] D --> E[Caller Receives] B --> B1[Type Checked] D --> D1[Type Checked] style B1 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style D1 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff

Optional Parameters

Not all parameters need to be provided every time. Optional parameters let you create flexible APIs where some arguments are... well, optional! They're like saying "you can give me this, but you don't have to." 🎭

Parameter Types Comparison Three-column comparison showing Required parameters (must provide), Optional parameters (can omit, becomes undefined), and Default parameters (can omit, uses fallback value) Required name: string ✗ Cannot omit ✓ Can reassign Must always provide a value greet("Alice") Optional ? age?: number ✓ Can omit ✓ Can reassign Value is undefined greet("Alice") Default = role = "user" ✓ Can omit ✓ Can reassign Uses fallback "user" greet("Alice")

Basic Optional Parameters

Use the ? symbol to mark a parameter as optional:

// lastName is optional
function greetUser(firstName: string, lastName?: string): string {
    if (lastName) {
        return `Hello, ${firstName} ${lastName}!`;
    }
    return `Hello, ${firstName}!`;
}

// Both calls are valid
console.log(greetUser("Alice", "Smith"));  // "Hello, Alice Smith!"
console.log(greetUser("Bob"));             // "Hello, Bob!"

// Order matters - optional parameters must come AFTER required ones
function createUser(
    name: string,           // Required
    age: number,            // Required
    email?: string,         // Optional
    phone?: string          // Optional
): void {
    console.log(`Creating user: ${name}, age ${age}`);
    if (email) console.log(`Email: ${email}`);
    if (phone) console.log(`Phone: ${phone}`);
}

createUser("Alice", 30);                           // ✅ OK
createUser("Bob", 25, "bob@example.com");         // ✅ OK
createUser("Charlie", 35, "charlie@example.com", "555-1234");  // ✅ OK

⚠️ Important Rule: Optional Parameters Go Last

// ❌ Wrong - optional parameter before required
function wrong(optional?: string, required: number) { }

// ✅ Right - optional parameters at the end
function right(required: number, optional?: string) { }

Optional Parameters Are undefined

When an optional parameter isn't provided, its value is undefined:

function printValue(value?: number): void {
    console.log(value);  // Could be undefined!
    
    // Check before using
    if (value !== undefined) {
        console.log(value.toFixed(2));  // Safe to use number methods
    }
    
    // Or use optional chaining
    console.log(value?.toFixed(2));
    
    // Or provide a default with nullish coalescing
    console.log((value ?? 0).toFixed(2));
}

printValue(42);      // 42
printValue();        // undefined

Multiple Optional Parameters

You can have several optional parameters:

function fetchData(
    url: string,                    // Required
    method?: "GET" | "POST",        // Optional
    headers?: Record,  // Optional
    timeout?: number                // Optional
): Promise {
    const actualMethod = method ?? "GET";
    const actualTimeout = timeout ?? 5000;
    
    console.log(`Fetching ${url} with ${actualMethod}`);
    console.log(`Timeout: ${actualTimeout}ms`);
    
    if (headers) {
        console.log("Headers:", headers);
    }
    
    // Simulate API call
    return Promise.resolve({ data: "Success" });
}

// All valid calls
fetchData("/api/users");
fetchData("/api/users", "POST");
fetchData("/api/users", "POST", { "Content-Type": "application/json" });
fetchData("/api/users", "POST", { "Content-Type": "application/json" }, 10000);

Options Object Pattern

When you have many optional parameters, consider using an options object:

// Instead of many optional parameters
function drawRectangle(
    x: number,
    y: number,
    width?: number,
    height?: number,
    color?: string,
    borderWidth?: number,
    borderColor?: string
): void {
    // Implementation
}

// Better: use an options object
interface RectangleOptions {
    x: number;
    y: number;
    width?: number;
    height?: number;
    color?: string;
    borderWidth?: number;
    borderColor?: string;
}

function drawRectangle2(options: RectangleOptions): void {
    const {
        x,
        y,
        width = 100,       // Default values
        height = 100,
        color = "black",
        borderWidth = 1,
        borderColor = "gray"
    } = options;
    
    console.log(`Drawing rectangle at (${x}, ${y})`);
    console.log(`Size: ${width}x${height}`);
    console.log(`Color: ${color}, Border: ${borderWidth}px ${borderColor}`);
}

// Much clearer to call!
drawRectangle2({
    x: 10,
    y: 20,
    width: 200,
    color: "blue"
});

// Only required properties needed
drawRectangle2({ x: 50, y: 50 });

✅ When to Use Optional Parameters

  • Few optional params (1-3): Use optional parameters directly
  • Many optional params (4+): Use an options object
  • Complex config: Use an options object with nested structures
  • Public APIs: Options objects are more extensible

Real-World Example: API Client

interface RequestOptions {
    method?: "GET" | "POST" | "PUT" | "DELETE";
    headers?: Record;
    body?: any;
    timeout?: number;
    retries?: number;
}

async function apiRequest(
    endpoint: string,
    options?: RequestOptions
): Promise {
    // Destructure with defaults
    const {
        method = "GET",
        headers = {},
        body,
        timeout = 5000,
        retries = 3
    } = options ?? {};
    
    console.log(`${method} ${endpoint}`);
    console.log(`Timeout: ${timeout}ms, Retries: ${retries}`);
    
    // Implementation would go here
    return { success: true };
}

// Flexible usage
await apiRequest("/api/users");                     // All defaults
await apiRequest("/api/users", { method: "POST" }); // Override method
await apiRequest("/api/users", {
    method: "POST",
    headers: { "Authorization": "Bearer token" },
    body: { name: "Alice" },
    timeout: 10000
});
💡 Pro Tip: Optional parameters are perfect for progressive enhancement - start with the basics, let advanced users add more options as needed!

Default Parameters

Default parameters are like optional parameters with a built-in fallback. Instead of checking for undefined, you specify what value to use when none is provided. It's like ordering a coffee and saying "if I don't specify milk, use whole milk." ☕

Basic Default Parameters

Set a default value directly in the parameter list:

// Default parameter values
function greet(name: string, greeting: string = "Hello"): string {
    return `${greeting}, ${name}!`;
}

console.log(greet("Alice"));              // "Hello, Alice!"
console.log(greet("Bob", "Hi"));          // "Hi, Bob!"
console.log(greet("Charlie", "Good morning"));  // "Good morning, Charlie!"

// Default parameters are implicitly optional
function createUser(name: string, role: string = "user"): void {
    console.log(`Creating ${role}: ${name}`);
}

createUser("Alice");           // "Creating user: Alice"
createUser("Bob", "admin");    // "Creating admin: Bob"

Type Inference with Defaults

TypeScript infers the parameter type from the default value:

// TypeScript infers count is number
function repeat(text: string, count = 3): string {
    return text.repeat(count);
}

console.log(repeat("Ha"));         // "HaHaHa"
console.log(repeat("Ha", 5));      // "HaHaHaHaHa"
// repeat("Ha", "5");              // ❌ Error! Argument of type 'string' is not assignable to 'number'

// You can still add explicit types if you want
function repeat2(text: string, count: number = 3): string {
    return text.repeat(count);
}

Complex Default Values

Defaults can be expressions, not just literals:

// Default can be a computed value
function logMessage(
    message: string,
    timestamp: string = new Date().toISOString()
): void {
    console.log(`[${timestamp}] ${message}`);
}

logMessage("Server started");                    // Uses current time
logMessage("Error occurred", "2024-01-15T10:30:00Z");  // Uses provided time

// Default can reference other parameters
function createSlug(
    title: string,
    separator: string = "-"
): string {
    return title
        .toLowerCase()
        .replace(/\s+/g, separator);
}

console.log(createSlug("Hello World"));      // "hello-world"
console.log(createSlug("Hello World", "_")); // "hello_world"

// Default can be an object or array
function processData(
    data: any[],
    options: { sort: boolean; limit: number } = { sort: true, limit: 10 }
): any[] {
    let result = [...data];
    
    if (options.sort) {
        result.sort();
    }
    
    return result.slice(0, options.limit);
}

const numbers = [5, 2, 8, 1, 9];
console.log(processData(numbers));  // Uses default options
console.log(processData(numbers, { sort: false, limit: 3 }));  // Custom options

🎯 Default vs Optional: What's the Difference?

// Optional: explicitly handle undefined
function optional(x?: number): number {
    if (x === undefined) {
        return 0;  // Manual default
    }
    return x * 2;
}

// Default: value provided automatically
function withDefault(x: number = 0): number {
    return x * 2;  // No need to check undefined
}

// Both work similarly for callers
optional();      // 0
withDefault();   // 0

optional(5);     // 10
withDefault(5);  // 10

// Key difference: optional can be explicitly undefined
optional(undefined);    // 0
withDefault(undefined); // 0 (default kicks in)

Default Parameters with Destructuring

Combine defaults with destructuring for powerful patterns:

interface ConnectionOptions {
    host: string;
    port: number;
    ssl: boolean;
    timeout: number;
}

// Destructure with defaults
function connect({
    host = "localhost",
    port = 3000,
    ssl = false,
    timeout = 5000
}: Partial = {}): void {
    console.log(`Connecting to ${ssl ? "https" : "http"}://${host}:${port}`);
    console.log(`Timeout: ${timeout}ms`);
}

// All these work!
connect();                                   // All defaults
connect({ host: "example.com" });           // Override host
connect({ host: "example.com", port: 8080, ssl: true });

When Defaults Are Calculated

Important: default values are evaluated each time the function is called:

// Default is evaluated on EACH call
function logWithTime(
    message: string,
    time: Date = new Date()  // New Date() called each time!
): void {
    console.log(`[${time.toISOString()}] ${message}`);
}

logWithTime("First message");
// Wait a second...
setTimeout(() => {
    logWithTime("Second message");  // Different timestamp!
}, 1000);

// Be careful with mutable defaults
function addItem(
    item: string,
    list: string[] = []  // New empty array each time
): string[] {
    list.push(item);
    return list;
}

const list1 = addItem("apple");   // ["apple"]
const list2 = addItem("banana");  // ["banana"] - different array!
console.log(list1 === list2);     // false

⚠️ Watch Out: Default Parameter Gotchas

  • Defaults are evaluated every call (not once at function definition)
  • Default parameters must come after required parameters
  • Passing undefined explicitly triggers the default
  • Passing null does NOT trigger the default
function test(x: number = 10): number {
    return x;
}

test();           // 10 (default used)
test(undefined);  // 10 (default used)
test(null as any);// null (default NOT used - null is a value!)
test(0);          // 0 (default NOT used - 0 is a value!)

Real-World Example: Configuration Function

interface ServerConfig {
    port: number;
    host: string;
    ssl: boolean;
    corsEnabled: boolean;
    maxConnections: number;
    logLevel: "debug" | "info" | "warn" | "error";
}

function startServer({
    port = 3000,
    host = "0.0.0.0",
    ssl = false,
    corsEnabled = true,
    maxConnections = 100,
    logLevel = "info"
}: Partial = {}): void {
    console.log("Server Configuration:");
    console.log(`  Address: ${ssl ? "https" : "http"}://${host}:${port}`);
    console.log(`  CORS: ${corsEnabled ? "enabled" : "disabled"}`);
    console.log(`  Max connections: ${maxConnections}`);
    console.log(`  Log level: ${logLevel}`);
    console.log("\nServer started successfully!");
}

// Minimal config - all defaults
startServer();

// Custom config - override some values
startServer({
    port: 8080,
    ssl: true,
    logLevel: "debug"
});

// Full custom config
startServer({
    port: 443,
    host: "api.example.com",
    ssl: true,
    corsEnabled: false,
    maxConnections: 500,
    logLevel: "warn"
});
💡 Best Practice: Use default parameters when you have a sensible default value. Use optional parameters when there's no good default and you need to handle the absence explicitly.

Rest Parameters

What if you don't know how many arguments you'll receive? Maybe you want to sum any number of values, or log multiple messages at once. Rest parameters let you handle variable-length argument lists elegantly! Think of them as "pack everything else into an array." 📦

Rest Parameters Collection Visual showing multiple individual arguments (1, 2, 3, 4, 5) being collected by the rest parameter spread operator into a single array Rest Parameter: Collect Multiple Arguments sum(1, 2, 3, 4, 5) 1 2 3 4 5 ... [1, 2, 3, 4, 5] numbers: number[] Individual arguments (any number of them) Collected into array (typed as number[])

Basic Rest Parameters

Use three dots (...) to collect remaining arguments into an array:

// Rest parameter collects all arguments into an array
function sum(...numbers: number[]): number {
    return numbers.reduce((total, n) => total + n, 0);
}

console.log(sum(1, 2, 3));           // 6
console.log(sum(1, 2, 3, 4, 5));     // 15
console.log(sum(10));                // 10
console.log(sum());                  // 0 (empty array)

// Log multiple messages
function logAll(...messages: string[]): void {
    messages.forEach(msg => console.log(msg));
}

logAll("Hello", "World", "!");
logAll("TypeScript", "is", "awesome");

📖 Key Concept

Rest Parameter: A special parameter that collects any number of arguments into an array. It must be the last parameter and there can only be one per function. The type is always an array type.

Combining Regular and Rest Parameters

You can have regular parameters before the rest parameter:

// First parameter is required, rest are collected
function greetAll(greeting: string, ...names: string[]): string[] {
    return names.map(name => `${greeting}, ${name}!`);
}

const greetings = greetAll("Hello", "Alice", "Bob", "Charlie");
console.log(greetings);
// ["Hello, Alice!", "Hello, Bob!", "Hello, Charlie!"]

// Mix types in regular parameters
function buildUrl(
    baseUrl: string,
    ...pathSegments: string[]
): string {
    return baseUrl + "/" + pathSegments.join("/");
}

console.log(buildUrl("https://api.example.com", "users", "123", "posts"));
// "https://api.example.com/users/123/posts"

console.log(buildUrl("https://api.example.com", "products"));
// "https://api.example.com/products"

⚠️ Important Rules for Rest Parameters

  • Must be the LAST parameter (nothing can come after it)
  • Can only have ONE rest parameter per function
  • The type must be an array type
  • It's always optional (can receive zero arguments)
// ✅ Correct
function correct(a: number, ...rest: string[]): void { }

// ❌ Wrong - rest parameter must be last
function wrong(...rest: string[], a: number): void { }

// ❌ Wrong - can't have two rest parameters
function wrong2(...rest1: string[], ...rest2: number[]): void { }

Rest Parameters with Different Types

The rest parameter can collect any type of values:

// Collect numbers
function max(...numbers: number[]): number {
    if (numbers.length === 0) {
        return -Infinity;
    }
    return Math.max(...numbers);
}

console.log(max(1, 5, 3, 9, 2));  // 9

// Collect objects
interface Product {
    name: string;
    price: number;
}

function calculateTotal(...products: Product[]): number {
    return products.reduce((total, product) => total + product.price, 0);
}

const total = calculateTotal(
    { name: "Laptop", price: 999 },
    { name: "Mouse", price: 25 },
    { name: "Keyboard", price: 75 }
);
console.log(total);  // 1099

// Collect any type with union
function logValues(...values: (string | number | boolean)[]): void {
    values.forEach(value => {
        console.log(`${typeof value}: ${value}`);
    });
}

logValues("hello", 42, true, "world", 3.14);

Rest Parameters with Tuples

For more precise typing, you can use tuple types with rest parameters:

// Rest parameter as tuple (TypeScript 4.0+)
function coordinate(x: number, y: number, ...rest: [z?: number, label?: string]): void {
    console.log(`Point at (${x}, ${y})`);
    
    if (rest[0] !== undefined) {
        console.log(`Z coordinate: ${rest[0]}`);
    }
    
    if (rest[1]) {
        console.log(`Label: ${rest[1]}`);
    }
}

coordinate(10, 20);                    // 2D point
coordinate(10, 20, 30);                // 3D point
coordinate(10, 20, 30, "Origin");      // 3D point with label

// More complex: variadic tuple types
function concat(...arrays: T[][]): T[] {
    return arrays.flat();
}

const numbers = concat([1, 2], [3, 4], [5, 6]);
const strings = concat(["a", "b"], ["c", "d"]);
console.log(numbers);  // [1, 2, 3, 4, 5, 6]
console.log(strings);  // ["a", "b", "c", "d"]

Spread Operator vs Rest Parameter

Rest parameters collect, spread operator expands. They look the same but do opposite things!

// Rest parameter: collects arguments INTO an array
function sum(...numbers: number[]): number {
    return numbers.reduce((a, b) => a + b, 0);
}

// Spread operator: expands an array INTO arguments
const numbersArray = [1, 2, 3, 4, 5];
const result = sum(...numbersArray);  // Spreading array into arguments
console.log(result);  // 15

// Another example
function createUser(name: string, ...roles: string[]): void {
    console.log(`User: ${name}`);
    console.log(`Roles: ${roles.join(", ")}`);
}

const rolesList = ["admin", "editor", "viewer"];
createUser("Alice", ...rolesList);  // Spreading array
// User: Alice
// Roles: admin, editor, viewer

// You can combine them!
function merge(...arrays: number[][]): number[] {
    return arrays.flat();
}

const result2 = merge([1, 2], [3, 4], [5, 6]);
console.log(result2);  // [1, 2, 3, 4, 5, 6]
graph LR A[Function Call] --> B[Multiple Arguments] B --> C[Rest Parameter ...] C --> D[Collected into Array] E[Array] --> F[Spread Operator ...] F --> G[Expanded to Arguments] G --> A style C fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style F fill:#2196F3,stroke:#333,stroke-width:2px,color:#fff

Real-World Example: SQL Query Builder

type WhereCondition = [field: string, operator: string, value: any];

class QueryBuilder {
    private table: string = "";
    private conditions: WhereCondition[] = [];
    
    // Use rest parameters for flexible WHERE clauses
    where(...conditions: WhereCondition[]): this {
        this.conditions.push(...conditions);
        return this;
    }
    
    from(table: string): this {
        this.table = table;
        return this;
    }
    
    build(): string {
        let query = `SELECT * FROM ${this.table}`;
        
        if (this.conditions.length > 0) {
            const whereClause = this.conditions
                .map(([field, op, value]) => `${field} ${op} '${value}'`)
                .join(" AND ");
            query += ` WHERE ${whereClause}`;
        }
        
        return query;
    }
}

// Flexible usage with any number of conditions
const query1 = new QueryBuilder()
    .from("users")
    .where(["age", ">", 18])
    .build();

const query2 = new QueryBuilder()
    .from("users")
    .where(
        ["age", ">", 18],
        ["status", "=", "active"],
        ["role", "=", "admin"]
    )
    .build();

console.log(query1);
// SELECT * FROM users WHERE age > '18'

console.log(query2);
// SELECT * FROM users WHERE age > '18' AND status = 'active' AND role = 'admin'
💡 Pro Tip: Rest parameters are perfect for functions that naturally work with collections - mathematical operations, array utilities, logging functions, and builder patterns!

Arrow Functions

Arrow functions are the modern, concise way to write functions in JavaScript and TypeScript. They're shorter, cleaner, and have some special behaviors. Let's master them with types! 🏹

Arrow Function Syntax Comparison Side-by-side comparison of regular function syntax versus arrow function syntax, showing equivalent code in both styles Regular Function function add ( a : number , b : number ): number { return a + b ; } • Uses function keyword • Has own 'this' binding • Can be hoisted • Named or anonymous Arrow Function const add = ( a : number , b : number ): number => a + b ; // implicit return! • Uses => arrow syntax • Inherits 'this' from scope • Cannot be hoisted • Always anonymous

Basic Arrow Function Syntax

Arrow functions use the => syntax:

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

// Arrow function - equivalent
const add2 = (a: number, b: number): number => {
    return a + b;
};

// Shorter - implicit return (no braces needed for single expression)
const add3 = (a: number, b: number): number => a + b;

// All three work the same way
console.log(add(5, 3));   // 8
console.log(add2(5, 3));  // 8
console.log(add3(5, 3));  // 8

Type Annotations in Arrow Functions

You can type arrow functions in several ways:

// Method 1: Type parameters and return type
const greet1 = (name: string): string => `Hello, ${name}!`;

// Method 2: Type the entire function variable
const greet2: (name: string) => string = (name) => `Hello, ${name}!`;

// Method 3: Use a type alias
type GreetFunction = (name: string) => string;
const greet3: GreetFunction = (name) => `Hello, ${name}!`;

// Method 4: Use an interface
interface GreetFunction2 {
    (name: string): string;
}
const greet4: GreetFunction2 = (name) => `Hello, ${name}!`;

// Most common: Method 1 (type parameters directly)
const multiply = (a: number, b: number): number => a * b;

Implicit Returns

When the function body is a single expression, you can omit the braces and return:

// With explicit return
const double1 = (n: number): number => {
    return n * 2;
};

// Implicit return - cleaner!
const double2 = (n: number): number => n * 2;

// Works with any single expression
const isEven = (n: number): boolean => n % 2 === 0;
const square = (n: number): number => n * n;
const exclaim = (text: string): string => `${text}!`;

// Returning objects requires parentheses
const createUser = (name: string, age: number) => ({
    name,
    age,
    createdAt: new Date()
});

// Without parens, it looks like a code block!
// const wrong = (name: string) => { name: name };  // This is a code block, not an object!

Arrow Functions with Rest Parameters

// Rest parameters work perfectly in arrow functions
const sum = (...numbers: number[]): number => 
    numbers.reduce((total, n) => total + n, 0);

const max = (...numbers: number[]): number => 
    Math.max(...numbers);

const concat = (...strings: string[]): string => 
    strings.join("");

console.log(sum(1, 2, 3, 4));        // 10
console.log(max(5, 2, 9, 1));        // 9
console.log(concat("Type", "Script")); // "TypeScript"

Arrow Functions with Optional/Default Parameters

// Optional parameters
const greet = (name: string, greeting?: string): string => 
    `${greeting ?? "Hello"}, ${name}!`;

// Default parameters
const repeat = (text: string, times: number = 3): string => 
    text.repeat(times);

// Destructuring with defaults
const createPoint = ({
    x = 0,
    y = 0
}: { x?: number; y?: number } = {}): string => 
    `Point(${x}, ${y})`;

console.log(greet("Alice"));              // "Hello, Alice!"
console.log(repeat("Ha"));                // "HaHaHa"
console.log(createPoint({ x: 10 }));      // "Point(10, 0)"

Arrow Functions as Callbacks

Arrow functions really shine when used as callbacks:

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

const users: User[] = [
    { id: 1, name: "Alice", age: 30 },
    { id: 2, name: "Bob", age: 25 },
    { id: 3, name: "Charlie", age: 35 }
];

// Array methods with arrow functions
const names = users.map((user) => user.name);
const adults = users.filter((user) => user.age >= 18);
const totalAge = users.reduce((sum, user) => sum + user.age, 0);
const sorted = users.sort((a, b) => a.age - b.age);

console.log(names);     // ["Alice", "Bob", "Charlie"]
console.log(totalAge);  // 90

// Even cleaner with implicit returns
const activeUsers = users.filter(u => u.age > 25);
const userIds = users.map(u => u.id);

// TypeScript infers types from the array!
users.forEach(user => {
    // user is typed as User automatically
    console.log(user.name);
});

Arrow Functions vs Regular Functions: this Binding

Arrow functions don't create their own this context - they inherit it. This is crucial in TypeScript:

class Counter {
    count: number = 0;
    
    // Regular function - 'this' can be problematic
    incrementRegular() {
        setTimeout(function() {
            // 'this' is undefined here in strict mode!
            // this.count++;  // Error!
        }, 100);
    }
    
    // Arrow function - 'this' works correctly
    incrementArrow() {
        setTimeout(() => {
            this.count++;  // ✅ Works! 'this' refers to Counter instance
        }, 100);
    }
    
    // Public method using arrow function property
    increment = (): void => {
        this.count++;
    }
}

const counter = new Counter();
counter.incrementArrow();

// Arrow functions preserve 'this' when passed as callbacks
const button = {
    label: "Click me",
    handleClick: () => {
        console.log(this.label);  // 'this' comes from surrounding scope
    }
};

✅ When to Use Arrow Functions

  • Callbacks: Array methods, event handlers, promises
  • Short functions: One-liners and simple transformations
  • Preserving 'this': When you need lexical 'this' binding
  • Functional programming: Map, filter, reduce chains

When to Use Regular Functions

  • Methods: Class methods that use 'this'
  • Constructors: Functions used with 'new'
  • Complex logic: Multiple statements, better with explicit returns
  • Function hoisting: When you need the function before it's defined

Real-World Example: Data Pipeline

interface Product {
    id: number;
    name: string;
    price: number;
    category: string;
    inStock: boolean;
}

const products: Product[] = [
    { id: 1, name: "Laptop", price: 999, category: "electronics", inStock: true },
    { id: 2, name: "Mouse", price: 25, category: "electronics", inStock: true },
    { id: 3, name: "Desk", price: 299, category: "furniture", inStock: false },
    { id: 4, name: "Chair", price: 199, category: "furniture", inStock: true }
];

// Data processing pipeline with arrow functions
const processProducts = (products: Product[], maxPrice: number): Product[] => 
    products
        .filter(p => p.inStock)              // Only in-stock items
        .filter(p => p.price <= maxPrice)    // Within budget
        .map(p => ({                         // Add discount
            ...p,
            price: p.price * 0.9
        }))
        .sort((a, b) => a.price - b.price);  // Sort by price

const affordable = processProducts(products, 500);
console.log(affordable);

// Reusable transforms as arrow functions
const applyDiscount = (percent: number) => 
    (product: Product): Product => ({
        ...product,
        price: product.price * (1 - percent / 100)
    });

const addTax = (rate: number) => 
    (product: Product): Product => ({
        ...product,
        price: product.price * (1 + rate / 100)
    });

// Compose transforms
const finalPrice = products
    .map(applyDiscount(10))
    .map(addTax(8));
💡 Pro Tip: Arrow functions make functional programming patterns much cleaner in TypeScript. Use them for transformations, filters, and data pipelines!

Function Types

Sometimes you need to describe what a function looks like without implementing it. Maybe you're accepting a callback, or defining a common interface for different implementations. Function types let you specify the contract! 📋

Function Types as Contracts Diagram showing a function type definition at top acting as a contract, with multiple function implementations below that conform to the contract Function Type (Contract) (a: number, b: number) => number add (a, b) => a + b ✓ Matches type subtract (a, b) => a - b ✓ Matches type multiply (a, b) => a * b ✓ Matches type All implementations are interchangeable - pass any to a function expecting MathOp function calculate(op: MathOp, x: number, y: number)

Basic Function Type Syntax

Function types describe parameter types and return type:

// Function type: (parameters) => return type
type MathOperation = (a: number, b: number) => number;

// Implement functions matching this type
const add: MathOperation = (a, b) => a + b;
const subtract: MathOperation = (a, b) => a - b;
const multiply: MathOperation = (a, b) => a * b;

console.log(add(5, 3));       // 8
console.log(subtract(5, 3));  // 2
console.log(multiply(5, 3));  // 15

// Function that takes another function
function applyOperation(
    a: number,
    b: number,
    operation: MathOperation
): number {
    return operation(a, b);
}

console.log(applyOperation(10, 5, add));       // 15
console.log(applyOperation(10, 5, multiply));  // 50

Function Types with Interfaces

You can also define function types using interfaces:

// Using interface for function type
interface Validator {
    (value: string): boolean;
}

const isEmail: Validator = (value) => {
    return value.includes("@");
};

const isNotEmpty: Validator = (value) => {
    return value.length > 0;
};

console.log(isEmail("test@example.com"));  // true
console.log(isNotEmpty(""));               // false

// Interface can also have properties (callable object)
interface Counter {
    (start: number): number;  // Call signature
    reset: () => void;         // Method
    current: number;           // Property
}

// This is advanced - functions are objects in JavaScript!

Callback Function Types

Define types for callbacks to ensure they're called correctly:

// Define callback types
type SuccessCallback = (data: any) => void;
type ErrorCallback = (error: string) => void;

// Function that accepts callbacks
function fetchData(
    url: string,
    onSuccess: SuccessCallback,
    onError: ErrorCallback
): void {
    // Simulate API call
    setTimeout(() => {
        if (url.startsWith("http")) {
            onSuccess({ data: "Success!" });
        } else {
            onError("Invalid URL");
        }
    }, 1000);
}

// Use with type-safe callbacks
fetchData(
    "https://api.example.com",
    (data) => console.log("Success:", data),
    (error) => console.error("Error:", error)
);

// More specific callback types
type UserCallback = (user: { id: number; name: string }) => void;
type ErrorCallback2 = (error: { code: number; message: string }) => void;

function getUser(id: number, callback: UserCallback, onError: ErrorCallback2): void {
    // Implementation
}

Generic Function Types

Function types can be generic for maximum flexibility:

// Generic function type
type Transform = (input: T) => U;

// Specific implementations
const numberToString: Transform = (n) => n.toString();
const stringToNumber: Transform = (s) => parseInt(s);
const doubleNumber: Transform = (n) => n * 2;

console.log(numberToString(42));    // "42"
console.log(stringToNumber("100")); // 100
console.log(doubleNumber(21));      // 42

// Generic array processor
type Processor = (item: T, index: number) => T;

function processArray(items: T[], processor: Processor): T[] {
    return items.map((item, index) => processor(item, index));
}

const numbers = [1, 2, 3, 4, 5];
const doubled = processArray(numbers, (n, i) => n * 2);
console.log(doubled);  // [2, 4, 6, 8, 10]

const strings = ["a", "b", "c"];
const indexed = processArray(strings, (s, i) => `${i}: ${s}`);
console.log(indexed);  // ["0: a", "1: b", "2: c"]

Predicate Functions

Special function types for filtering and validation:

// Predicate: function that returns boolean
type Predicate = (item: T) => boolean;

interface User {
    id: number;
    name: string;
    age: number;
    isActive: boolean;
}

// Use predicates for filtering
const isAdult: Predicate = (user) => user.age >= 18;
const isActive: Predicate = (user) => user.isActive;

const users: User[] = [
    { id: 1, name: "Alice", age: 30, isActive: true },
    { id: 2, name: "Bob", age: 17, isActive: true },
    { id: 3, name: "Charlie", age: 25, isActive: false }
];

const adults = users.filter(isAdult);
const activeUsers = users.filter(isActive);

console.log(adults.length);       // 2
console.log(activeUsers.length);  // 2

// Combine predicates
const combinePredicates = (
    ...predicates: Predicate[]
): Predicate => {
    return (item: T) => predicates.every(pred => pred(item));
};

const activeAdults = users.filter(
    combinePredicates(isAdult, isActive)
);
console.log(activeAdults);  // [{ id: 1, name: "Alice", ... }]

Function Type Parameters

Functions can receive other functions as parameters:

// Higher-order function types
type Comparator = (a: T, b: T) => number;
type Mapper = (item: T) => U;
type Reducer = (accumulator: U, current: T) => U;

// Sort with custom comparator
function sort(items: T[], compare: Comparator): T[] {
    return [...items].sort(compare);
}

const numbers = [3, 1, 4, 1, 5, 9, 2, 6];

const ascending = sort(numbers, (a, b) => a - b);
const descending = sort(numbers, (a, b) => b - a);

console.log(ascending);   // [1, 1, 2, 3, 4, 5, 6, 9]
console.log(descending);  // [9, 6, 5, 4, 3, 2, 1, 1]

// Map with custom mapper
function map(items: T[], mapper: Mapper): U[] {
    return items.map(mapper);
}

const doubled = map(numbers, n => n * 2);
const stringified = map(numbers, n => n.toString());

// Reduce with custom reducer
function reduce(
    items: T[],
    reducer: Reducer,
    initialValue: U
): U {
    return items.reduce(reducer, initialValue);
}

const sum = reduce(numbers, (acc, n) => acc + n, 0);
console.log(sum);  // 31

🎯 Real-World Example: Event System

// Define event types
type EventHandler = (data: T) => void;
type Unsubscribe = () => void;

class EventBus {
    private listeners: Map = new Map();
    
    // Subscribe with typed handler
    on(event: string, handler: EventHandler): Unsubscribe {
        if (!this.listeners.has(event)) {
            this.listeners.set(event, []);
        }
        
        this.listeners.get(event)!.push(handler);
        
        // Return unsubscribe function
        return () => {
            const handlers = this.listeners.get(event);
            if (handlers) {
                const index = handlers.indexOf(handler);
                if (index > -1) {
                    handlers.splice(index, 1);
                }
            }
        };
    }
    
    // Emit with typed data
    emit(event: string, data: T): void {
        const handlers = this.listeners.get(event);
        if (handlers) {
            handlers.forEach(handler => handler(data));
        }
    }
}

// Usage with type safety
const bus = new EventBus();

interface UserLoginEvent {
    userId: number;
    timestamp: Date;
}

const unsubscribe = bus.on("user:login", (data) => {
    // data is typed as UserLoginEvent
    console.log(`User ${data.userId} logged in at ${data.timestamp}`);
});

bus.emit("user:login", {
    userId: 123,
    timestamp: new Date()
});

unsubscribe();  // Remove listener
💡 Pro Tip: Function types are essential for building flexible, reusable APIs. Use them for callbacks, event handlers, data processors, and higher-order functions!

Function Overloads

Sometimes you want the same function to work differently based on what you pass in. Function overloads let you define multiple signatures for one function - like having different versions of the same tool for different jobs! 🔧

Function Overload Resolution Diagram showing how TypeScript matches function calls to the appropriate overload signature based on argument types Function Overload Resolution Function Call format(42) arg is number match? Overload Signatures format(x: string): string format(x: number): string format(x: boolean): string Return Type: string How Overload Resolution Works 1. TypeScript checks each overload signature from top to bottom 2. First matching signature determines the return type 3. More specific overloads should come before general ones

What Are Function Overloads?

Function overloads allow you to define multiple type signatures for a single function implementation:

📖 Definition

Function Overload: Multiple function signatures that describe different ways to call the same function. The implementation must handle all the overload cases.

Basic Function Overload Syntax

// Overload signatures
function format(value: string): string;
function format(value: number): string;
function format(value: boolean): string;

// Implementation signature (must be compatible with all overloads)
function format(value: string | number | boolean): string {
    if (typeof value === "string") {
        return `"${value}"`;
    } else if (typeof value === "number") {
        return value.toFixed(2);
    } else {
        return value ? "Yes" : "No";
    }
}

// TypeScript knows the return type based on the input!
console.log(format("hello"));    // "hello"
console.log(format(42));         // 42.00
console.log(format(true));       // Yes

// The implementation signature is not callable!
// format([1, 2, 3]);  // ❌ Error! No overload matches this call

Why Use Function Overloads?

Overloads provide better type inference and clearer API contracts:

// Without overloads - less precise
function createElement(tag: string, className?: string): HTMLElement {
    const element = document.createElement(tag);
    if (className) {
        element.className = className;
    }
    return element;
}

// With overloads - more precise!
function createElement2(tag: "div"): HTMLDivElement;
function createElement2(tag: "span"): HTMLSpanElement;
function createElement2(tag: "a"): HTMLAnchorElement;
function createElement2(tag: string): HTMLElement;

function createElement2(tag: string): HTMLElement {
    return document.createElement(tag);
}

// TypeScript knows the specific return type!
const div = createElement2("div");     // Type: HTMLDivElement
const span = createElement2("span");   // Type: HTMLSpanElement
const anchor = createElement2("a");    // Type: HTMLAnchorElement
const custom = createElement2("custom"); // Type: HTMLElement

// You get autocomplete for specific element properties
div.style.display = "block";           // ✅ OK - div has style
anchor.href = "https://example.com";   // ✅ OK - anchor has href

Overloads with Different Parameter Counts

// Different numbers of parameters
function createPoint(x: number, y: number): { x: number; y: number };
function createPoint(x: number, y: number, z: number): { x: number; y: number; z: number };

function createPoint(x: number, y: number, z?: number) {
    if (z !== undefined) {
        return { x, y, z };
    }
    return { x, y };
}

const point2D = createPoint(10, 20);       // Type: { x: number; y: number }
const point3D = createPoint(10, 20, 30);   // Type: { x: number; y: number; z: number }

// TypeScript enforces the correct structure
console.log(point2D.x, point2D.y);         // ✅ OK
// console.log(point2D.z);                 // ❌ Error! z doesn't exist on 2D point
console.log(point3D.x, point3D.y, point3D.z);  // ✅ OK

Overloads with Different Return Types

// Return different types based on input
function getValue(key: "name"): string;
function getValue(key: "age"): number;
function getValue(key: "active"): boolean;

function getValue(key: string): string | number | boolean {
    const data: Record = {
        name: "Alice",
        age: 30,
        active: true
    };
    return data[key];
}

// TypeScript knows the exact return type!
const name = getValue("name");     // Type: string
const age = getValue("age");       // Type: number
const active = getValue("active"); // Type: boolean

// Can use without type guards
console.log(name.toUpperCase());   // ✅ OK - name is definitely string
console.log(age.toFixed(2));       // ✅ OK - age is definitely number
console.log(active ? "Yes" : "No"); // ✅ OK - active is definitely boolean

⚠️ Important Overload Rules

  • Overload signatures come BEFORE the implementation
  • The implementation signature must be compatible with ALL overloads
  • Only overload signatures are visible to callers (not the implementation)
  • Order matters - more specific overloads should come before general ones

Conditional Return Types with Overloads

// Parse based on format
function parse(data: string, format: "json"): object;
function parse(data: string, format: "number"): number;
function parse(data: string, format: "date"): Date;

function parse(data: string, format: string): object | number | Date {
    switch (format) {
        case "json":
            return JSON.parse(data);
        case "number":
            return parseFloat(data);
        case "date":
            return new Date(data);
        default:
            throw new Error(`Unknown format: ${format}`);
    }
}

// Type-safe parsing!
const obj = parse('{"name": "Alice"}', "json");   // Type: object
const num = parse("42.5", "number");              // Type: number
const date = parse("2024-01-15", "date");         // Type: Date

// You can use specific methods
console.log(num.toFixed(2));          // ✅ OK
console.log(date.getFullYear());      // ✅ OK

Generic Overloads

Combine overloads with generics for ultimate flexibility:

// Generic overloads
function map(arr: T[], fn: (item: T) => U): U[];
function map(arr: T[], fn: (item: T, index: number) => U): U[];

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

const numbers = [1, 2, 3];

// Both work with full type inference
const doubled = map(numbers, n => n * 2);              // U[] where U = number
const indexed = map(numbers, (n, i) => `${i}: ${n}`);  // U[] where U = string

// Another example: flexible fetch
function fetch(url: string): Promise;
function fetch(url: string, options: RequestInit): Promise;

function fetch(url: string, options?: RequestInit): Promise {
    return window.fetch(url, options).then(r => r.json());
}

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

// Type-safe API calls
const user = await fetch("/api/users/1");
console.log(user.name);  // TypeScript knows user has name

Real-World Example: Database Query

// Different return types based on whether we're fetching one or many
interface User {
    id: number;
    name: string;
    email: string;
}

function query(sql: string, single: true): User | null;
function query(sql: string, single: false): User[];
function query(sql: string): User[];

function query(sql: string, single?: boolean): User | User[] | null {
    // Simulate database query
    const allUsers: User[] = [
        { id: 1, name: "Alice", email: "alice@example.com" },
        { id: 2, name: "Bob", email: "bob@example.com" }
    ];
    
    if (single) {
        return allUsers[0] || null;
    }
    
    return allUsers;
}

// Type-safe queries!
const oneUser = query("SELECT * FROM users WHERE id = 1", true);
// Type: User | null
if (oneUser) {
    console.log(oneUser.name);  // Safe to access
}

const manyUsers = query("SELECT * FROM users", false);
// Type: User[]
manyUsers.forEach(user => {
    console.log(user.name);  // Safe to iterate
});

const allUsers = query("SELECT * FROM users");
// Type: User[] (default behavior)
console.log(allUsers.length);

Overloads vs Union Types

When should you use overloads instead of union types?

// Using union types (less precise)
function process(
    input: string | number
): string | number {
    if (typeof input === "string") {
        return input.toUpperCase();
    }
    return input * 2;
}

const result1 = process("hello");  // Type: string | number (not precise!)
const result2 = process(42);       // Type: string | number (not precise!)

// You need type guards to use the result
if (typeof result1 === "string") {
    console.log(result1.toUpperCase());
}

// Using overloads (more precise)
function process2(input: string): string;
function process2(input: number): number;

function process2(input: string | number): string | number {
    if (typeof input === "string") {
        return input.toUpperCase();
    }
    return input * 2;
}

const result3 = process2("hello");  // Type: string (precise!)
const result4 = process2(42);       // Type: number (precise!)

// No type guards needed!
console.log(result3.toUpperCase());  // ✅ TypeScript knows it's string
console.log(result4.toFixed(2));     // ✅ TypeScript knows it's number

✅ When to Use Function Overloads

  • When return type depends on parameter types
  • When you need different parameter combinations
  • When union types would require many type guards
  • When you want precise type inference
  • When building libraries with complex APIs

When to Use Union Types

  • When the function truly returns multiple types
  • When the relationship between input and output is complex
  • When simpler is better (don't over-engineer!)
💡 Pro Tip: Function overloads are powerful but add complexity. Use them when they truly improve type safety and developer experience, not just because you can!

Hands-on Practice: Data Processing Pipeline

Let's build a complete data processing system that uses everything we've learned - typed functions, optional parameters, rest parameters, arrow functions, function types, and overloads! 🚀

🎯 Project Goal

Create a type-safe data processing pipeline that can filter, transform, sort, and aggregate data with full type safety and flexibility.

Step 1: Define Core Types

// Core function types
type Predicate = (item: T) => boolean;
type Mapper = (item: T) => U;
type Comparator = (a: T, b: T) => number;
type Reducer = (accumulator: U, current: T) => U;

// Sample data type
interface Product {
    id: number;
    name: string;
    price: number;
    category: string;
    inStock: boolean;
    rating: number;
}

Step 2: Create the Pipeline Class

class DataPipeline {
    constructor(private data: T[]) {}
    
    // Filter with predicate
    filter(predicate: Predicate): DataPipeline {
        return new DataPipeline(this.data.filter(predicate));
    }
    
    // Map to new type
    map<U>(mapper: Mapper<T, U>): DataPipeline<U> {
        return new DataPipeline(this.data.map(mapper));
    }
    
    // Sort with comparator
    sort(comparator: Comparator): DataPipeline {
        return new DataPipeline([...this.data].sort(comparator));
    }
    
    // Take first n items
    take(count: number): DataPipeline {
        return new DataPipeline(this.data.slice(0, count));
    }
    
    // Skip first n items
    skip(count: number): DataPipeline {
        return new DataPipeline(this.data.slice(count));
    }
    
    // Reduce to single value
    reduce<U>(reducer: Reducer<T, U>, initialValue: U): U {
        return this.data.reduce(reducer, initialValue);
    }
    
    // Group by key
    groupBy<K extends keyof T>(key: K): Map<T[K], T[]> {
        const groups = new Map<T[K], T[]>();
        
        for (const item of this.data) {
            const groupKey = item[key];
            if (!groups.has(groupKey)) {
                groups.set(groupKey, []);
            }
            groups.get(groupKey)!.push(item);
        }
        
        return groups;
    }
    
    // Get results
    value(): T[] {
        return this.data;
    }
    
    // Get count
    count(): number {
        return this.data.length;
    }
}

Step 3: Create Helper Functions with Overloads

// Overloaded sort helpers
function sortBy(key: keyof T, order: "asc"): Comparator;
function sortBy(key: keyof T, order: "desc"): Comparator;

function sortBy(key: keyof T, order: "asc" | "desc"): Comparator {
    return (a, b) => {
        const aVal = a[key];
        const bVal = b[key];
        
        if (aVal < bVal) return order === "asc" ? -1 : 1;
        if (aVal > bVal) return order === "asc" ? 1 : -1;
        return 0;
    };
}

// Predicate combinators
function and(...predicates: Predicate[]): Predicate {
    return (item) => predicates.every(pred => pred(item));
}

function or(...predicates: Predicate[]): Predicate {
    return (item) => predicates.some(pred => pred(item));
}

function not(predicate: Predicate): Predicate {
    return (item) => !predicate(item);
}

// Common predicates
const inStock = (item: T) => item.inStock;
const highRated = (item: T) => item.rating >= 4;
const affordable = (maxPrice: number) => 
    (item: T) => item.price <= maxPrice;

Step 4: Create Analysis Functions

// Aggregation functions with proper typing
function sum(items: T[], selector: (item: T) => number): number {
    return items.reduce((total, item) => total + selector(item), 0);
}

function average(items: T[], selector: (item: T) => number): number {
    if (items.length === 0) return 0;
    return sum(items, selector) / items.length;
}

function min(items: T[], selector: (item: T) => number): number {
    if (items.length === 0) return Infinity;
    return Math.min(...items.map(selector));
}

function max(items: T[], selector: (item: T) => number): number {
    if (items.length === 0) return -Infinity;
    return Math.max(...items.map(selector));
}

// Statistics object
function statistics(
    items: T[],
    selector: (item: T) => number
): {
    count: number;
    sum: number;
    average: number;
    min: number;
    max: number;
} {
    return {
        count: items.length,
        sum: sum(items, selector),
        average: average(items, selector),
        min: min(items, selector),
        max: max(items, selector)
    };
}

Step 5: Use the Pipeline

// Sample data
const products: Product[] = [
    { id: 1, name: "Laptop", price: 999, category: "electronics", inStock: true, rating: 4.5 },
    { id: 2, name: "Mouse", price: 25, category: "electronics", inStock: true, rating: 4.2 },
    { id: 3, name: "Desk", price: 299, category: "furniture", inStock: false, rating: 4.0 },
    { id: 4, name: "Chair", price: 199, category: "furniture", inStock: true, rating: 4.8 },
    { id: 5, name: "Monitor", price: 399, category: "electronics", inStock: true, rating: 4.6 },
    { id: 6, name: "Keyboard", price: 79, category: "electronics", inStock: false, rating: 3.9 }
];

// Example 1: Find affordable, in-stock, highly-rated electronics
const recommendedElectronics = new DataPipeline(products)
    .filter(item => item.category === "electronics")
    .filter(and(inStock, highRated, affordable(500)))
    .sort(sortBy("price", "asc"))
    .value();

console.log("Recommended Electronics:", recommendedElectronics);

// Example 2: Get top 3 products by rating
const topRated = new DataPipeline(products)
    .filter(inStock)
    .sort(sortBy("rating", "desc"))
    .take(3)
    .value();

console.log("Top Rated:", topRated);

// Example 3: Calculate category statistics
const categoryStats = new DataPipeline(products)
    .filter(inStock)
    .groupBy("category");

categoryStats.forEach((items, category) => {
    console.log(`\nCategory: ${category}`);
    console.log("Statistics:", statistics(items, p => p.price));
});

// Example 4: Transform to summary
interface ProductSummary {
    name: string;
    priceLabel: string;
    available: string;
}

const summaries = new DataPipeline(products)
    .filter(inStock)
    .map(p => ({
        name: p.name,
        priceLabel: `$${p.price.toFixed(2)}`,
        available: p.inStock ? "✓ In Stock" : "✗ Out of Stock"
    }))
    .value();

console.log("\nProduct Summaries:", summaries);

// Example 5: Complex aggregation
const totalValue = new DataPipeline(products)
    .filter(inStock)
    .reduce((total, product) => total + product.price, 0);

console.log(`\nTotal Inventory Value: $${totalValue}`);

// Example 6: Multi-stage pipeline
const result = new DataPipeline(products)
    .filter(item => item.price < 500)           // Affordable items
    .filter(or(                                  // In stock OR highly rated
        inStock,
        (item) => item.rating >= 4.5
    ))
    .sort(sortBy("rating", "desc"))             // Best rated first
    .take(5)                                     // Top 5
    .map(p => ({                                 // Transform
        name: p.name,
        score: p.rating * (p.inStock ? 1 : 0.8) // Boost in-stock items
    }))
    .sort((a, b) => b.score - a.score)          // Sort by score
    .value();

console.log("\nBest Value Products:", result);

✅ Sample Output:

Recommended Electronics: [
  { id: 2, name: "Mouse", price: 25, category: "electronics", ... },
  { id: 5, name: "Monitor", price: 399, category: "electronics", ... }
]

Top Rated: [
  { id: 4, name: "Chair", price: 199, rating: 4.8, ... },
  { id: 5, name: "Monitor", price: 399, rating: 4.6, ... },
  { id: 1, name: "Laptop", price: 999, rating: 4.5, ... }
]

Category: electronics
Statistics: { count: 3, sum: 1423, average: 474.33, min: 25, max: 999 }

Category: furniture
Statistics: { count: 1, sum: 199, average: 199, min: 199, max: 199 }

Your Challenge: Extend the Pipeline

🏋️ Exercise: Add More Pipeline Methods

Challenge: Add these methods to the DataPipeline class:

  1. distinct() - Remove duplicates
  2. distinctBy(key) - Remove duplicates by a specific key
  3. findFirst(predicate) - Find first matching item
  4. partition(predicate) - Split into two arrays [matching, notMatching]
💡 Hint

For distinct(), use a Set. For distinctBy(), track seen keys in a Set.

🎉 Outstanding Work!

You've built a production-quality data processing pipeline with complete type safety! This demonstrates mastery of TypeScript functions, types, and functional programming patterns. 🚀

Best Practices

Let's consolidate everything with the golden rules for writing great TypeScript functions! ✨

✅ Do's: Function Excellence

1. Always Type Function Parameters

// ❌ Bad - no type safety
function process(data) {
    return data.value * 2;
}

// ✅ Good - fully typed
function process(data: { value: number }): number {
    return data.value * 2;
}

2. Explicitly Type Return Values

// ❌ Relying on inference can hide bugs
function calculate(x: number) {
    if (x > 0) {
        return x * 2;
    }
    return "negative";  // Bug! Mixed return types
}

// ✅ Explicit return type catches the bug
function calculate(x: number): number {
    if (x > 0) {
        return x * 2;
    }
    return -1;  // Fixed!
}

3. Use Arrow Functions for Simple Callbacks

// ✅ Clean and concise
const numbers = [1, 2, 3, 4, 5];
const doubled = numbers.map(n => n * 2);
const evens = numbers.filter(n => n % 2 === 0);

4. Use Function Types for Callbacks

// ✅ Type the callback properly
type EventHandler = (event: MouseEvent) => void;

function onClick(handler: EventHandler): void {
    document.addEventListener("click", handler);
}

5. Prefer Optional Parameters Over Undefined Unions

// ❌ Awkward
function greet(name: string, title: string | undefined) { }

// ✅ Cleaner
function greet(name: string, title?: string) { }

❌ Don'ts: Common Pitfalls

1. Don't Use any in Function Signatures

// ❌ No type safety
function process(data: any): any {
    return data.value * 2;
}

// ✅ Use proper types or generics
function process(data: T): number {
    return data.value * 2;
}

2. Don't Make Everything Optional

// ❌ Too permissive
function createUser(
    name?: string,
    email?: string,
    age?: number
): void { }

// ✅ Required params first
function createUser(
    name: string,
    email: string,
    age?: number
): void { }

3. Don't Overuse Function Overloads

// ❌ Overengineered
function format(value: string): string;
function format(value: number): string;
function format(value: boolean): string;
function format(value: Date): string;
// ... 10 more overloads

// ✅ Sometimes union types are simpler
function format(value: string | number | boolean | Date): string {
    return String(value);
}

💡 Pro Tips

Tip 1: Use Descriptive Parameter Names

// ❌ Unclear
function process(a: number, b: number, c: boolean): number {
    return c ? a + b : a - b;
}

// ✅ Self-documenting
function calculate(
    firstValue: number,
    secondValue: number,
    shouldAdd: boolean
): number {
    return shouldAdd ? firstValue + secondValue : firstValue - secondValue;
}

Tip 2: Keep Functions Small and Focused

// ✅ Single responsibility
function validateEmail(email: string): boolean {
    return email.includes("@");
}

function saveUser(user: User): void {
    // Just save, don't also validate, format, log, etc.
}

function sendEmail(to: string, subject: string, body: string): void {
    // Just send, don't also validate, log, retry, etc.
}

Tip 3: Document Complex Functions

/**
 * Calculates the compound interest
 * @param principal - Initial amount invested
 * @param rate - Annual interest rate (as decimal, e.g., 0.05 for 5%)
 * @param years - Number of years
 * @param compoundFrequency - How many times per year interest compounds
 * @returns The final amount after interest
 */
function calculateCompoundInterest(
    principal: number,
    rate: number,
    years: number,
    compoundFrequency: number = 12
): number {
    return principal * Math.pow(1 + rate / compoundFrequency, compoundFrequency * years);
}

Quick Reference

Feature Syntax Use Case
Basic Function function f(x: number): number Standard function
Optional Parameter function f(x?: number) Parameter may be omitted
Default Parameter function f(x: number = 0) Parameter with default value
Rest Parameters function f(...nums: number[]) Variable number of arguments
Arrow Function const f = (x: number) => x * 2 Concise function syntax
Function Type type F = (x: number) => number Reusable function signature
Function Overload function f(x: string): string;
function f(x: number): number;
Multiple signatures
void Return function f(): void No return value
never Return function f(): never Never returns (throws/loops)

Summary

🎉 Key Takeaways

  • Always type parameters and return values - it's the foundation of type safety
  • Optional parameters (?) let parameters be omitted
  • Default parameters provide automatic fallback values
  • Rest parameters (...) collect variable arguments into an array
  • Arrow functions are concise and preserve 'this' context
  • Function types let you define reusable function signatures
  • Function overloads provide precise types for different call patterns
  • void means "no return value", never means "never returns"
  • Keep functions focused - single responsibility principle
  • Document complex functions with JSDoc comments

📚 Additional Resources

🚀 What's Next?

In the next lesson, we'll explore Advanced Types. You'll learn about:

  • Generics for reusable, flexible code
  • Utility types (Partial, Pick, Omit, Record)
  • Conditional types and mapped types
  • Type guards and type assertions
  • Template literal types

This is where TypeScript's type system really shows its power! 💪

Quick Quiz

🎯 Test Your Knowledge

Question 1: What's the difference between optional parameters and default parameters?

Question 2: What must be true about rest parameters?

Question 3: When should you use function overloads?

🎉 Incredible Progress!

You've mastered TypeScript functions! You can now write type-safe, flexible, and maintainable functions that form the backbone of great applications. This is a major milestone in your TypeScript journey!

Next up: Advanced Types - where things get REALLY powerful! 🚀