On this page

Typing functions

14 min read TextCh. 3 — Functions and Generics

Function type expressions

Every function in TypeScript has a type that describes what arguments it accepts and what it returns. TypeScript calls this a function type expression:

// The type of a function that takes two numbers and returns a number
type BinaryOperation = (a: number, b: number) => number;

// Assigning a function to a variable of this type
const add: BinaryOperation = (a, b) => a + b;
const subtract: BinaryOperation = (a, b) => a - b;

// TypeScript infers the parameter types from BinaryOperation — no need to annotate a and b
const multiply: BinaryOperation = (a, b) => a * b;

The arrow syntax (params) => returnType is the standard way to write a function type inline. For more complex signatures, use a call signature inside an interface or type alias.

Parameters

Required parameters

Every parameter without a default or ? marker is required. TypeScript enforces the exact count and types at every call site:

function createUser(name: string, email: string, age: number): object {
  return { name, email, age };
}

createUser("Alice", "[email protected]", 30);   // OK
createUser("Alice", "[email protected]");        // Error: Expected 3 arguments, but got 2
createUser("Alice", 30, "[email protected]");   // Error: Argument types don't match

Optional parameters

Add ? after the parameter name to make it optional. Optional parameters have the type T | undefined inside the function body:

function buildUrl(base: string, path: string, query?: string): string {
  const url = `${base}/${path}`;
  return query ? `${url}?${query}` : url;
}

buildUrl("https://api.example.com", "users");               // no query
buildUrl("https://api.example.com", "users", "page=2");     // with query

Optional parameters must appear after all required parameters. You cannot have a required parameter after an optional one.

Default parameters

Default parameters provide a fallback value when the argument is not passed or is explicitly undefined. TypeScript infers the parameter's type from the default value:

function paginate(items: unknown[], page = 1, pageSize = 20): unknown[] {
  // page and pageSize are inferred as number
  const start = (page - 1) * pageSize;
  return items.slice(start, start + pageSize);
}

paginate(data);              // page=1, pageSize=20
paginate(data, 2);           // page=2, pageSize=20
paginate(data, 3, 10);       // page=3, pageSize=10
paginate(data, undefined, 5); // page=1, pageSize=5 — undefined triggers the default

Rest parameters

Rest parameters collect a variable number of trailing arguments into a typed array:

function log(level: "info" | "warn" | "error", ...messages: string[]): void {
  console[level](`[${level.toUpperCase()}]`, ...messages);
}

log("info", "Server started");
log("warn", "Retrying", "attempt 2 of 3");
log("error", "Connection failed", "timeout after 5000ms");

Callbacks and function types

When a function accepts another function as an argument, you type the callback inline or using a named type:

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

// Named callback type
type Predicate<T> = (value: T) => boolean;

function filter<T>(items: T[], predicate: Predicate<T>): T[] {
  return items.filter(predicate);
}

const numbers = [1, 2, 3, 4, 5, 6];
const evens = filter(numbers, (n) => n % 2 === 0); // [2, 4, 6]

void return type for callbacks

When a function receives a callback and ignores its return value, type the callback as returning void. This is more permissive than undefined — a () => void callback can return any value, but callers cannot rely on it.

// The callback returns void — Array.prototype.forEach ignores return values
function forEach<T>(items: T[], callback: (item: T) => void): void {
  for (const item of items) {
    callback(item);
  }
}

// Passing a function that returns a number is valid — return value is ignored
forEach([1, 2, 3], (n) => n * 2); // OK — TypeScript doesn't complain about the return value

Call signatures

When a callable value also has properties, use a call signature inside an interface or type:

interface Logger {
  (message: string): void;    // call signature
  level: "debug" | "info" | "warn" | "error";
  timestamp: boolean;
}

const logger: Logger = Object.assign(
  (message: string): void => {
    const prefix = logger.timestamp ? new Date().toISOString() : "";
    console[logger.level](`${prefix} ${message}`);
  },
  { level: "info" as const, timestamp: true }
);

logger("Server started");        // callable
logger.level = "debug";          // also has properties

Function overloads

Overloads let you declare multiple call signatures for a single function. TypeScript picks the most specific matching signature for each call site:

// Overload signatures — visible to callers
function createElement(tag: "div"): HTMLDivElement;
function createElement(tag: "span"): HTMLSpanElement;
function createElement(tag: "input"): HTMLInputElement;
// Implementation signature — hidden from callers
function createElement(tag: string): HTMLElement {
  return document.createElement(tag);
}

const div = createElement("div");     // inferred as HTMLDivElement
const span = createElement("span");   // inferred as HTMLSpanElement
const other = createElement("table"); // Error: Argument '"table"' does not match any overload

Overloads are most useful when a function behaves differently based on argument types and the return type changes accordingly. For cases where only the input type varies but the return type is fixed, a union type is simpler.

The `this` parameter

TypeScript lets you annotate the expected this type as the first parameter (erased at compile time). This prevents calling the function from the wrong context:

interface EventEmitter {
  listeners: Map<string, Function[]>;
  on(event: string, listener: Function): void;
}

function handleClick(this: EventEmitter, event: MouseEvent): void {
  const listeners = this.listeners.get("click") ?? [];
  listeners.forEach((fn) => fn(event));
}

// TypeScript enforces that handleClick is called with the correct this context
const emitter: EventEmitter = {
  listeners: new Map(),
  on(event, listener) {
    const list = this.listeners.get(event) ?? [];
    this.listeners.set(event, [...list, listener]);
  },
};

handleClick.call(emitter, new MouseEvent("click")); // OK
handleClick(new MouseEvent("click"));                // Error: 'this' context of type 'void' is not assignable

Typing higher-order functions

Higher-order functions (functions that return functions) are common in TypeScript. The return type is itself a function type:

// A function that creates an event handler for a specific action
function createHandler(action: string): (event: Event) => void {
  return (event: Event) => {
    console.log(`Action: ${action}`, event.target);
  };
}

const handleSubmit = createHandler("submit");
const handleCancel = createHandler("cancel");

// Currying — each call returns a function waiting for the next argument
function curry<A, B, C>(fn: (a: A, b: B) => C): (a: A) => (b: B) => C {
  return (a) => (b) => fn(a, b);
}

const curriedAdd = curry((a: number, b: number) => a + b);
const addFive = curriedAdd(5);
addFive(3);  // 8
addFive(10); // 15

Practice

  1. Write a function clamp(value: number, min: number, max: number): number. Add JSDoc comments explaining the parameters. Then write a curried version clampTo(min: number, max: number): (value: number) => number.
  2. Create a Validator<T> function type (value: T) => boolean. Write three validators for strings: isEmail, isUrl, and hasMinLength(min: number). Compose them into a validateAll function that runs all validators against a value.
  3. Implement a function overload for serialize(value: string): string, serialize(value: number): string, and serialize(value: boolean): string. Each overload should return a different formatted string.

Always annotate return types on public functions
TypeScript can infer return types, but annotating them explicitly on public/exported functions is a best practice for two reasons: it makes your API self-documenting, and it catches bugs where you accidentally return the wrong type from one branch of a complex function. For short private helpers, inference is fine.
The this parameter is erased at runtime
The `this` parameter in TypeScript function signatures is a compile-time-only annotation. It does not appear in the compiled JavaScript and does not affect the function's arity. It is purely a type-checking hint that enforces how the function must be called.
Overload signatures vs implementation signature
Only the overload signatures (the ones without a body) are visible to callers. The implementation signature (the one with a body) must be compatible with all overloads but is not callable directly. This means if you have two overloads, callers can only pass arguments that match one of the two overloads — the wider implementation signature is hidden.
// Named function with explicit parameter and return types
function add(a: number, b: number): number {
  return a + b;
}

// Arrow function — same type information
const multiply = (a: number, b: number): number => a * b;

// Optional parameter — must come after required params
function greet(name: string, title?: string): string {
  return title ? `Hello, ${title} ${name}` : `Hello, ${name}`;
}

// Default parameter — implies the type from the default value
function createSlug(text: string, separator = "-"): string {
  return text.toLowerCase().replace(/\s+/g, separator);
}

// Rest parameters — typed as an array
function sumAll(...numbers: number[]): number {
  return numbers.reduce((total, n) => total + n, 0);
}

sumAll(1, 2, 3, 4, 5); // 15