Skip to main content

Advanced functions

In this chapter, you learn more advanced features of functions in TypeScript.

What you learn in this chapter

  • Function overloads
  • Typing callback functions
  • How to use rest parameters
  • Typing this
info

You can try the code examples in this chapter on the TypeScript Playground.

Function overloads

A function overload is a feature for handling different parameter and return types under the same function name. In TypeScript, you define several "overload signatures" and one "implementation signature."

The basics of overloads

// Overload signatures (declarations only)
function reverse(value: string): string;
function reverse(value: number): number;

// Implementation signature (the actual implementation)
function reverse(value: string | number): string | number {
if (typeof value === 'string') {
return value.split('').reverse().join('');
} else {
return Number(value.toString().split('').reverse().join(''));
}
}

// Usage examples
console.log(reverse('hello')); // 'olleh' (returns a string)
console.log(reverse(12345)); // 54321 (returns a number)

Code explanation:

  • Overload signatures: declare the different ways the function can be called
  • Implementation signature: the actual implementation that satisfies all overloads
  • TypeScript selects the appropriate overload at the call site

A more complex overload

Overloads with different numbers of parameters are also possible.

// Overloads with different numbers of parameters
function createElement(tag: string): HTMLElement;
function createElement(tag: string, content: string): HTMLElement;
function createElement(tag: string, content: string, className: string): HTMLElement;

function createElement(
tag: string,
content?: string,
className?: string
): HTMLElement {
const element = document.createElement(tag);

if (content) {
element.textContent = content;
}

if (className) {
element.className = className;
}

return element;
}

// Usage examples
const div1 = createElement('div');
const div2 = createElement('div', 'Hello');
const div3 = createElement('div', 'Hello', 'container');

A practical overload example

// A function that searches for a user: searchable by ID or by name
type User = { id: number; name: string; email: string };

function findUser(id: number): User | undefined;
function findUser(name: string): User | undefined;

function findUser(idOrName: number | string): User | undefined {
// Dummy data
const users: User[] = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
{ id: 3, name: 'Charlie', email: 'charlie@example.com' }
];

if (typeof idOrName === 'number') {
return users.find((user) => user.id === idOrName);
} else {
return users.find((user) => user.name === idOrName);
}
}

// Usage examples
console.log(findUser(1)); // { id: 1, name: 'Alice', ... }
console.log(findUser('Bob')); // { id: 2, name: 'Bob', ... }

A note on overloads

// The implementation signature must encompass the overload signatures
function add(a: number, b: number): number;
function add(a: string, b: string): string;

// The implementation signature must support both types
function add(a: number | string, b: number | string): number | string {
if (typeof a === 'number' && typeof b === 'number') {
return a + b;
} else {
return String(a) + String(b);
}
}

console.log(add(5, 3)); // 8
console.log(add('Hello, ', 'World')); // 'Hello, World'
warning

The 2026 trend: consider generic constraints before overloads

Function overloads have a complex type-inference mechanism, and the implementation and type definitions tend to drift apart. In cases like the following, you can achieve the same type safety with generic constraints without using overloads.

// ❌ Writing it with overloads
function firstOrDefault(arr: string[], defaultValue: string): string;
function firstOrDefault(arr: number[], defaultValue: number): number;
function firstOrDefault<T>(arr: T[], defaultValue: T): T {
return arr[0] ?? defaultValue
}

// ✅ With generic constraints, the declaration is also shorter
function firstOrDefault<T>(arr: T[], defaultValue: T): T {
return arr[0] ?? defaultValue
}

You need overloads when the return type changes significantly depending on the number or combination of arguments (for example, the DOM's querySelector changes its type based on the tag name). If you only have variations in the argument types, expressing them with generics or conditional types is more maintainable.

Callback functions

A callback function is a function passed as an argument to another function. They are often used in asynchronous processing and event handling.

Typing a callback function

// A type definition for a function that takes a callback
function processNumbers(
numbers: number[],
callback: (num: number) => number
): number[] {
return numbers.map(callback);
}

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

const squared = processNumbers([1, 2, 3, 4, 5], (n) => n * n);
console.log(squared); // [1, 4, 9, 16, 25]

Code explanation:

  • callback: (num: number) => number: the type of the callback function
  • (num: number): takes one number argument
  • => number: returns a number

Defining a callback with a type alias

// Define the type of a callback function with a type alias
type NumberProcessor = (num: number) => number;
type StringProcessor = (str: string) => string;

function processItems<T>(
items: T[],
processor: (item: T) => T
): T[] {
return items.map(processor);
}

// Processing a number array
const numbers = processItems([1, 2, 3], (n) => n * 2);
console.log(numbers); // [2, 4, 6]

// Processing a string array
const strings = processItems(['a', 'b', 'c'], (s) => s.toUpperCase());
console.log(strings); // ['A', 'B', 'C']

Asynchronous callbacks

// A function that takes success/failure callbacks
function fetchData(
url: string,
onSuccess: (data: unknown) => void,
onError: (error: Error) => void
): void {
fetch(url)
.then((response) => response.json())
.then((data) => onSuccess(data))
.catch((error) => onError(error));
}

// Usage example
fetchData(
'https://api.example.com/data',
(data) => {
console.log('Success:', data);
},
(error) => {
console.error('Error:', error.message);
}
);

An event handler callback

// A type definition for an event callback
type EventCallback = (event: Event) => void;

function addClickListener(
element: HTMLElement,
callback: EventCallback
): void {
element.addEventListener('click', callback);
}

// Usage example
const button = document.getElementById('myButton') as HTMLElement;
addClickListener(button, (event) => {
console.log('Button clicked', event);
});

A function with multiple callbacks

// A function that calls callbacks at each stage of processing
type ProgressCallback = (progress: number) => void;
type CompleteCallback = (result: string) => void;
type ErrorCallback = (error: Error) => void;

function processWithCallbacks(
data: string[],
onProgress: ProgressCallback,
onComplete: CompleteCallback,
onError: ErrorCallback
): void {
try {
const total = data.length;
const results: string[] = [];

data.forEach((item, index) => {
// Process each item
results.push(item.toUpperCase());
// Report progress
onProgress(Math.round(((index + 1) / total) * 100));
});

// Report completion
onComplete(results.join(', '));
} catch (error) {
onError(error as Error);
}
}

// Usage example
processWithCallbacks(
['apple', 'banana', 'cherry'],
(progress) => console.log(`Progress: ${progress}%`),
(result) => console.log(`Complete: ${result}`),
(error) => console.error(`Error: ${error.message}`)
);
// Progress: 33%
// Progress: 67%
// Progress: 100%
// Complete: APPLE, BANANA, CHERRY

Rest parameters

Rest parameters receive a variable number of arguments as an array.

Basic usage

// The basic syntax of rest parameters: ...parameterName: type[]
function sum(...numbers: number[]): number {
return numbers.reduce((total, n) => total + n, 0);
}

console.log(sum(1, 2, 3)); // 6
console.log(sum(10, 20, 30, 40)); // 100
console.log(sum(5)); // 5
console.log(sum()); // 0

Code explanation:

  • ...numbers: number[]: receives any number of number arguments as an array
  • Internally, you can operate on it as a normal array

Combining with normal parameters

// The first parameter is normal, the rest are rest parameters
function multiply(multiplier: number, ...numbers: number[]): number[] {
return numbers.map((n) => n * multiplier);
}

console.log(multiply(2, 1, 2, 3, 4)); // [2, 4, 6, 8]
console.log(multiply(10, 5, 10, 15)); // [50, 100, 150]

// Multiple normal parameters and a rest parameter
function createMessage(
prefix: string,
suffix: string,
...words: string[]
): string {
return `${prefix} ${words.join(' ')} ${suffix}`;
}

console.log(createMessage('[', ']', 'Hello', 'World'));
// '[ Hello World ]'

A note on rest parameters

// A rest parameter must be the last parameter
// Correct
function correct(multiplier: number, ...numbers: number[]) {
return numbers.map((n) => n * multiplier);
}

// Wrong: a parameter comes after the rest parameter
// function wrong(...numbers: number[], multiplier: number) {} // Error

// Only one rest parameter is allowed
// Wrong: multiple rest parameters are not allowed
// function wrong(...a: number[], ...b: string[]) {} // Error

Practical examples

// Example 1: a log function
function log(level: string, ...messages: string[]): void {
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}]`, ...messages);
}

log('INFO', 'Application started');
log('ERROR', 'Something went wrong:', 'Connection failed');

// Example 2: find the maximum of multiple values
function max(...numbers: number[]): number {
if (numbers.length === 0) {
return -Infinity;
}
return Math.max(...numbers);
}

console.log(max(5, 10, 3, 8, 15)); // 15
console.log(max(42)); // 42

// Example 3: joining strings
function joinStrings(separator: string, ...strings: string[]): string {
return strings.join(separator);
}

console.log(joinStrings(', ', 'Apple', 'Banana', 'Orange'));
// 'Apple, Banana, Orange'

console.log(joinStrings(' - ', 'Tokyo', 'Osaka', 'Kyoto'));
// 'Tokyo - Osaka - Kyoto'

// Example 4: flattening arrays
function flatten<T>(...arrays: T[][]): T[] {
return arrays.reduce((acc, arr) => [...acc, ...arr], []);
}

console.log(flatten([1, 2], [3, 4], [5, 6])); // [1, 2, 3, 4, 5, 6]
console.log(flatten(['a'], ['b', 'c'])); // ['a', 'b', 'c']

Typing this

In TypeScript, you can explicitly specify the type of this inside a function.

Arrow functions and this

In an arrow function, this is bound lexically (it keeps the this from where it is defined).

class Counter {
count: number = 0;

// A traditional function: this may change depending on the caller
incrementWrong() {
setTimeout(function() {
// this.count++; // Error: this may not refer to Counter
console.log('Wrong');
}, 1000);
}

// An arrow function: this keeps the defining context (Counter)
incrementCorrect() {
setTimeout(() => {
this.count++; // OK: this refers to the Counter instance
console.log(this.count);
}, 1000);
}
}

const counter = new Counter();
counter.incrementCorrect(); // 1 is shown after one second

The this parameter

By specifying this as the first parameter of a function, you can state the type of this explicitly.

interface User {
name: string;
greet(this: User): void;
}

const user: User = {
name: 'Alice',
greet() {
console.log(`Hello, I'm ${this.name}`);
}
};

user.greet(); // "Hello, I'm Alice"

// Calling it incorrectly is an error
const greetFunc = user.greet;
// greetFunc(); // Error: The 'this' context... is not assignable

this in a class

class EventHandler {
message: string = 'Button clicked';

setupEventListener() {
const button = document.getElementById('myButton');

// Use an arrow function (this works correctly)
button?.addEventListener('click', () => {
console.log(this.message); // 'Button clicked'
});
}
}

class NumberProcessor {
multiplier: number = 2;

processNumbers(numbers: number[]): number[] {
// Access this.multiplier with an arrow function
return numbers.map((n) => n * this.multiplier);
}
}

const processor = new NumberProcessor();
console.log(processor.processNumbers([1, 2, 3])); // [2, 4, 6]

Try it: build a generic filter function ★★★

Create a set of generic filter functions that meet the following requirements.

Requirements:

  1. filterNumbers(numbers, ...predicates): takes multiple predicate functions and returns only the numbers that satisfy all conditions
  2. filterByRange(min, max): returns a function that determines whether a number is within the given range
  3. filterByDivisible(divisor): returns a function that determines whether a number is divisible by the given number
  4. createFilter(predicate): takes a predicate function and returns a function that filters an array
Hint
  1. In filterNumbers, receive multiple predicate functions with a rest parameter
  2. Implement filterByRange and filterByDivisible as higher-order functions (functions that return functions)
  3. Make createFilter work with any type using generics
  4. The type of a predicate function is (value: number) => boolean
Answer and explanation
// Define the type of a predicate function
type Predicate<T> = (value: T) => boolean;

// Take multiple predicate functions and return only the numbers that satisfy all conditions
function filterNumbers(
numbers: number[],
...predicates: Predicate<number>[]
): number[] {
return numbers.filter((num) =>
// return true only when every predicate function returns true
predicates.every((predicate) => predicate(num))
);
}

// Return a function that determines whether a number is within the given range
function filterByRange(min: number, max: number): Predicate<number> {
return (value: number) => value >= min && value <= max;
}

// Return a function that determines whether a number is divisible by the given number
function filterByDivisible(divisor: number): Predicate<number> {
return (value: number) => value % divisor === 0;
}

// Take a predicate function and return a function that filters an array
function createFilter<T>(predicate: Predicate<T>): (items: T[]) => T[] {
return (items: T[]) => items.filter(predicate);
}

// Tests
const numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 12, 15, 18, 20];

// Test filterNumbers: numbers between 5 and 15 that are divisible by 3
const result1 = filterNumbers(
numbers,
filterByRange(5, 15),
filterByDivisible(3)
);
console.log(result1); // [6, 9, 12, 15]

// Test filterByRange
const inRange = filterByRange(5, 10);
console.log(inRange(7)); // true
console.log(inRange(15)); // false

// Test filterByDivisible
const isEven = filterByDivisible(2);
console.log(isEven(4)); // true
console.log(isEven(5)); // false

// Test createFilter
const filterPositive = createFilter<number>((n) => n > 0);
console.log(filterPositive([-2, -1, 0, 1, 2])); // [1, 2]

const filterLongStrings = createFilter<string>((s) => s.length > 3);
console.log(filterLongStrings(['a', 'ab', 'abc', 'abcd', 'abcde'])); // ['abcd', 'abcde']

Explanation:

  • Predicate<T>: a generic predicate function type. It takes a value and returns a boolean
  • filterNumbers: receives multiple predicate functions with a rest parameter and checks all conditions with every
  • filterByRange: returns a predicate function that keeps min and max using a closure
  • filterByDivisible: similarly keeps divisor with a closure
  • createFilter: uses generics to work with an array of any type

The benefits of higher-order functions:

  • Combine functions to achieve complex filtering
  • Parameterize conditions to make them reusable
  • The intent of the code becomes clear

Summary

What you learned in this chapter:

  • Function overloads: handle different parameter/return types under the same function name
  • Callback functions: pass a function as an argument to control the flow of processing
  • Rest parameters: use ... to receive a variable number of arguments as an array
  • Typing this: state the type of this explicitly with arrow functions or the this parameter

In the next chapter, you will learn the basics of classes. It covers the fundamentals of object-oriented programming in TypeScript.

Common pitfalls for beginners

warning

Common mistakes

❌ Getting the order of overloads wrong

// ❌ Wrong: writing the more specific signature later
function process(value: string | number): string;
function process(value: string): string; // this is never called

// ✅ Correct: write the more specific signature first
function process(value: string): string;
function process(value: string | number): string;

function process(value: string | number): string {
return String(value);
}

Cause: TypeScript checks signatures from top to bottom and uses the first one that matches.

Solution: Place specific signatures first and generic signatures later.

❌ An inaccurate type for a callback function

// ❌ Wrong: omitting the type of the callback's arguments
function processItems(items: string[], callback) {
items.forEach(callback);
}

// ✅ Correct: state the type of the callback
function processItems(
items: string[],
callback: (item: string, index: number) => void
): void {
items.forEach(callback);
}

Cause: When the argument type is omitted, it implicitly becomes any.

Solution: Explicitly define the types of the callback function's arguments and return value.

❌ Placing a rest parameter anywhere but last

// ❌ Wrong: the rest parameter is in the middle
function wrong(...numbers: number[], multiplier: number) {
// Error: A rest parameter must be last in a parameter list
}

// ✅ Correct: place the rest parameter last
function correct(multiplier: number, ...numbers: number[]) {
return numbers.map(n => n * multiplier);
}

Cause: Per the JavaScript spec, a rest parameter must be last in the parameter list.

Solution: Always place rest parameters last in the function's parameters.

❌ Losing the this context

class Timer {
seconds: number = 0;

// ❌ Wrong: using this in a traditional function
startWrong() {
setInterval(function() {
this.seconds++; // Error: this does not refer to Timer
}, 1000);
}

// ✅ Correct: keep this with an arrow function
startCorrect() {
setInterval(() => {
this.seconds++; // OK: an arrow function keeps this
}, 1000);
}
}

Cause: A traditional function's this changes depending on the caller, but an arrow function keeps the this from where it is defined.

Solution: Use an arrow function when you use this inside a callback.

❌ Thinking you can call the implementation signature directly

function format(value: string): string;
function format(value: number): string;
function format(value: string | number): string { // the implementation signature
return String(value);
}

// ✅ OK: matches an overload signature
format('hello'); // OK
format(123); // OK

// ❌ Wrong: you cannot call the implementation signature directly
format({ toString: () => 'test' }); // Error: does not match an overload

Cause: The implementation signature is not used for type checking; only the overload signatures are visible as types from the outside.

Solution: Define the callable types with overload signatures.


Continue to Classes and OOP. You learn object-oriented design using classes, inheritance, access modifiers, and abstract classes.