⚡ 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:
// 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);
}
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." 🎭
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
undefinedexplicitly triggers the default - Passing
nulldoes 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." 📦
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]
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! 🏹
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! 📋
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! 🔧
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:
distinct()- Remove duplicatesdistinctBy(key)- Remove duplicates by a specific keyfindFirst(predicate)- Find first matching itempartition(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; |
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! 🚀