Skip to main content
Advertisement

3.5 Function Types

Functions are one of the most important building blocks in TypeScript. Functions are values themselves — they can be stored in variables, passed as arguments to other functions, and returned as values. Knowing how to accurately express function types is essential for maintaining type safety in all of these scenarios.

This chapter covers the full picture of function types: three ways to declare function type signatures, parameter variations, overloads, callback types, the this parameter, and covariance/contravariance.


Three Ways to Declare a Function Type Signature

Method 1: Arrow Function Style (type alias)

The (parameter: Type) => ReturnType form is the most widely used.

// Basic form
type Add = (a: number, b: number) => number;
type Greet = (name: string) => string;
type Logger = (message: string, level?: "info" | "warn" | "error") => void;
type AsyncFetcher<T> = (url: string) => Promise<T>;

// Usage examples
const add: Add = (a, b) => a + b;
const greet: Greet = (name) => `Hello, ${name}!`;
const log: Logger = (message, level = "info") => {
console.log(`[${level.toUpperCase()}] ${message}`);
};

Method 2: Call Signature — Object Notation in interface/type

Used when a function also has properties. This expresses a type that is both a function and an object.

// Defining a function + properties simultaneously with a call signature
type ClickHandler = {
(event: MouseEvent): void; // call signature
description: string; // property
once: boolean; // property
};

// interface version
interface ValidatorFn {
(value: unknown): boolean;
errorMessage: string;
ruleName: string;
}

const isPositive: ValidatorFn = Object.assign(
(value: unknown): boolean => typeof value === "number" && value > 0,
{ errorMessage: "Must be a positive number", ruleName: "positive" }
);

console.log(isPositive(5)); // true
console.log(isPositive.errorMessage); // "Must be a positive number"

Method 3: Method Signature — Methods Inside an Object/Interface

interface Calculator {
// Method signature (shorthand form)
add(a: number, b: number): number;
subtract(a: number, b: number): number;

// Function type as a property (arrow form)
multiply: (a: number, b: number) => number;
divide: (a: number, b: number) => number;
}

// Difference between method signature and property function:
// Method signatures allow more flexible this inference,
// and under strictFunctionTypes, covariance/contravariance rules apply differently (explained later)

Parameter Types

Optional Parameters

Adding ? makes a parameter optional. Optional parameters cannot be followed by required parameters.

function createUser(
name: string,
email: string,
role?: "user" | "admin", // optional
bio?: string // optional, cannot be followed by required params
): User {
return {
id: crypto.randomUUID(),
username: name,
email,
displayName: name,
isActive: true,
...(bio && { bio }),
} as User;
}

createUser("Alice", "alice@example.com"); // valid
createUser("Bob", "bob@example.com", "admin"); // valid
createUser("Charlie", "charlie@example.com", "user", "Developer"); // valid

Default Parameter Types

Parameters with default values are treated as optional and their types are inferred.

function paginate(
items: unknown[],
page: number = 1, // default 1, inferred type: number
pageSize: number = 20, // default 20
sortOrder: "asc" | "desc" = "asc"
): unknown[] {
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
}

paginate([1, 2, 3, 4, 5]); // page=1, pageSize=20
paginate([1, 2, 3], 2, 10); // page=2, pageSize=10
paginate([1, 2, 3], 1, 10, "desc"); // explicit sortOrder

Rest Parameter Types

The ...paramName: T[] form collects the remaining arguments into an array.

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

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

// Generic rest parameters
function first<T>(...items: [T, ...T[]]): T {
return items[0];
}

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

log("info", "Server started", "Port: 3000");

Function Overloads

Function overloads allow you to declare individual type signatures for each case when the same function name is called with different argument types.

How to Declare Overloads

The structure is multiple overload signatures (declarations) plus one implementation signature (body).

// Overload signatures (declarations) — used for type checking
function parse(input: string): number;
function parse(input: number): string;
function parse(input: boolean): string;

// Implementation signature (body) — cannot be called directly; must handle all cases
function parse(input: string | number | boolean): number | string {
if (typeof input === "string") return parseInt(input, 10);
if (typeof input === "number") return input.toString();
return input ? "true" : "false";
}

// Precise return type inference when calling
const num = parse("42"); // type: number
const str = parse(42); // type: string
const boolStr = parse(true); // type: string

Real-World Overloads: Flexible API Design

// querySelector-like function — returns the precise element type from the tag name
function querySelector(selector: "button"): HTMLButtonElement | null;
function querySelector(selector: "input"): HTMLInputElement | null;
function querySelector(selector: "select"): HTMLSelectElement | null;
function querySelector(selector: "textarea"): HTMLTextAreaElement | null;
function querySelector(selector: string): HTMLElement | null;
function querySelector(selector: string): HTMLElement | null {
return document.querySelector(selector);
}

const btn = querySelector("button"); // type: HTMLButtonElement | null
const inp = querySelector("input"); // type: HTMLInputElement | null
const div = querySelector(".header"); // type: HTMLElement | null

Overloads vs Union Types

Use overloads when input-output type pairs must be explicitly linked.

// With a union, there is no guarantee that "string input → number output"
function badParse(input: string | number): string | number {
if (typeof input === "string") return parseInt(input, 10);
return input.toString();
}
const result = badParse("42"); // type: string | number (imprecise)

// Overloads guarantee the exact type pair
function goodParse(input: string): number;
function goodParse(input: number): string;
function goodParse(input: string | number): string | number {
if (typeof input === "string") return parseInt(input, 10);
return input.toString();
}
const result2 = goodParse("42"); // type: number (precise)

Callback Types

When passing a function as an argument to another function, define the callback's type.

Basic Callback Types

type Callback<T> = (error: Error | null, result: T | null) => void;
type EventCallback<T> = (event: T) => void;
type Predicate<T> = (value: T) => boolean;

function loadData<T>(url: string, callback: Callback<T>): void {
fetch(url)
.then((r) => r.json() as T)
.then((data) => callback(null, data))
.catch((err) => callback(err, null));
}

loadData<User[]>("/api/users", (err, users) => {
if (err) {
console.error(err.message);
return;
}
console.log(users?.length);
});

The Special Behavior of void Return Type

When a callback's return type is void, TypeScript allows the function to actually return a value. This is convenient for reusing existing functions as array method callbacks.

type VoidCallback = () => void;

// A function that returns a value can be assigned to a void return type
const handler: VoidCallback = () => "hello"; // valid! (return value is ignored)

// Array forEach has a void callback return type
[1, 2, 3].forEach((n) => n * 2); // returning a value is fine

// push returns a number, but can be used as a forEach callback
const result: number[] = [];
[1, 2, 3].forEach(result.push.bind(result)); // valid

Note that when a function explicitly declares its return type as void, it means the return value is ignored — it is different from returning undefined.

function runCallback(cb: () => void): void {
const result = cb();
// result's type is void, so it cannot be used
}

Generic Higher-Order Function Types

// Higher-order function similar to map
type MapFn = <T, U>(arr: T[], fn: (item: T, index: number) => U) => U[];

const myMap: MapFn = (arr, fn) => arr.map(fn);

const doubled = myMap([1, 2, 3], (n) => n * 2); // number[]
const strings = myMap([1, 2, 3], (n) => String(n)); // string[]

// Currying types
type Curry2<A, B, C> = (a: A) => (b: B) => C;

const add: Curry2<number, number, number> = (a) => (b) => a + b;
const add5 = add(5);
console.log(add5(3)); // 8

The this Type

TypeScript lets you use this: T as the first parameter of a function to explicitly declare the type of this. This is compile-time type information only, not an actual parameter.

Explicit this Parameter

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

interface UserWithMethods extends User {
greet(this: UserWithMethods): string;
updateEmail(this: UserWithMethods, newEmail: string): void;
}

const user: UserWithMethods = {
id: "1",
name: "Alice",
email: "alice@example.com",

greet(this: UserWithMethods): string {
return `Hello, ${this.name}!`;
},

updateEmail(this: UserWithMethods, newEmail: string): void {
this.email = newEmail;
},
};

user.greet(); // valid
// const greet = user.greet;
// greet(); // Error: loses the this context

The noImplicitThis Option

Setting noImplicitThis: true in tsconfig.json treats cases where this implicitly becomes any as errors.

// Without noImplicitThis: this is any
function badMethod() {
return this.name; // this: any → no type checking
}

// With noImplicitThis enabled: must explicitly type this
function goodMethod(this: { name: string }) {
return this.name; // type-safe
}

// Class methods automatically infer the this type
class Counter {
count = 0;
increment() {
this.count++; // this: Counter (automatically inferred)
}
// Arrow functions lexically capture this
decrement = () => {
this.count--; // this: Counter (captured via arrow function)
};
}

this in Event Handlers

interface EventEmitter {
on<K extends string>(
event: K,
handler: (this: EventEmitter, ...args: unknown[]) => void
): this;
emit(event: string, ...args: unknown[]): boolean;
}

Practical Example: Event Handler System, Higher-Order Function Types, Overloaded Parsing Function

Type-Safe Event Emitter System

// Define an event map
interface AppEventMap {
"user:login": { userId: string; timestamp: Date };
"user:logout": { userId: string };
"order:created": { orderId: string; total: number };
"order:cancelled": { orderId: string; reason: string };
"error": { message: string; code: number };
}

type EventName = keyof AppEventMap;
type EventPayload<E extends EventName> = AppEventMap[E];
type EventHandler<E extends EventName> = (payload: EventPayload<E>) => void;

class TypedEventEmitter {
private handlers: { [E in EventName]?: EventHandler<E>[] } = {};

on<E extends EventName>(event: E, handler: EventHandler<E>): this {
if (!this.handlers[event]) {
(this.handlers as Record<string, unknown[]>)[event] = [];
}
(this.handlers[event] as EventHandler<E>[]).push(handler);
return this;
}

off<E extends EventName>(event: E, handler: EventHandler<E>): this {
const list = this.handlers[event] as EventHandler<E>[] | undefined;
if (list) {
const index = list.indexOf(handler);
if (index >= 0) list.splice(index, 1);
}
return this;
}

emit<E extends EventName>(event: E, payload: EventPayload<E>): void {
const list = this.handlers[event] as EventHandler<E>[] | undefined;
list?.forEach((h) => h(payload));
}
}

// Usage: full type inference
const emitter = new TypedEventEmitter();

emitter.on("user:login", ({ userId, timestamp }) => {
console.log(`${userId} logged in at ${timestamp.toISOString()}`);
});

emitter.on("error", ({ message, code }) => {
console.error(`[${code}] ${message}`);
});

emitter.emit("user:login", { userId: "user-1", timestamp: new Date() });
// emitter.emit("user:login", { userId: "user-1" }); // Error: timestamp is missing

Higher-Order Function Types in Action

// compose: function composition from right to left
type Fn<A, B> = (a: A) => B;

function compose<A, B, C>(f: Fn<B, C>, g: Fn<A, B>): Fn<A, C> {
return (a) => f(g(a));
}

function compose3<A, B, C, D>(
f: Fn<C, D>,
g: Fn<B, C>,
h: Fn<A, B>
): Fn<A, D> {
return (a) => f(g(h(a)));
}

const double = (n: number) => n * 2;
const addOne = (n: number) => n + 1;
const toString = (n: number) => `Result: ${n}`;

const transform = compose3(toString, double, addOne);
console.log(transform(5)); // "Result: 12"

// memoize: a higher-order function that caches results
function memoize<Args extends unknown[], R>(
fn: (...args: Args) => R
): (...args: Args) => R {
const cache = new Map<string, R>();
return (...args: Args): R => {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key)!;
const result = fn(...args);
cache.set(key, result);
return result;
};
}

const expensiveCalc = memoize((n: number) => {
console.log(`Computing... ${n}`);
return n * n;
});

console.log(expensiveCalc(5)); // "Computing... 5", 25
console.log(expensiveCalc(5)); // 25 (from cache)

Overloaded Parsing Function

// Accepts various inputs and parses them into a consistent type
interface ParsedDate {
year: number;
month: number;
day: number;
}

// Overload signatures
function parseDate(input: string): ParsedDate;
function parseDate(input: number): ParsedDate; // Unix timestamp
function parseDate(input: Date): ParsedDate;
function parseDate(input: [number, number, number]): ParsedDate; // [year, month, day]

// Implementation
function parseDate(
input: string | number | Date | [number, number, number]
): ParsedDate {
if (Array.isArray(input)) {
const [year, month, day] = input;
return { year, month, day };
}
if (input instanceof Date) {
return {
year: input.getFullYear(),
month: input.getMonth() + 1,
day: input.getDate(),
};
}
if (typeof input === "number") {
return parseDate(new Date(input));
}
// string: "YYYY-MM-DD"
const [year, month, day] = input.split("-").map(Number);
return { year, month, day };
}

const d1 = parseDate("2025-03-21"); // ParsedDate
const d2 = parseDate(1742515200000); // ParsedDate (Unix timestamp)
const d3 = parseDate(new Date()); // ParsedDate
const d4 = parseDate([2025, 3, 21]); // ParsedDate

Pro Tips: Function Type Compatibility — Covariance and Contravariance

Covariance and Contravariance

TypeScript's strictFunctionTypes option (enabled by default) applies contravariance to function parameters and covariance to return values.

Covariance (return type): A narrower type (subtype) can be used in place of a wider type (supertype)

class Animal { name: string = "" }
class Dog extends Animal { bark() { console.log("Woof!") } }

type MakeAnimal = () => Animal;
type MakeDog = () => Dog;

// Return type: Dog is a subtype of Animal → MakeDog is assignable to MakeAnimal (covariant)
const makeDog: MakeDog = () => new Dog();
const makeAnimal: MakeAnimal = makeDog; // valid: returning a Dog counts as returning an Animal

Contravariance (parameter type): A wider type (supertype) can be used in place of a narrower type (subtype)

type HandleAnimal = (animal: Animal) => void;
type HandleDog = (dog: Dog) => void;

// Parameter: a function that accepts Animal can also be used in place of HandleDog (contravariant)
const handleAnimal: HandleAnimal = (a) => console.log(a.name);
const handleDog: HandleDog = handleAnimal; // valid: an Animal handler can also handle a Dog

// The reverse is not allowed: a function that only accepts Dog cannot be used in place of HandleAnimal
// since Animal may not have bark()
// const bad: HandleAnimal = (d: Dog) => d.bark(); // Error

strictFunctionTypes and the Method Signature Difference

interface WithArrow {
handle: (animal: Animal) => void; // property (arrow) — strictFunctionTypes applies
}

interface WithMethod {
handle(animal: Animal): void; // method signature — bivariant (both co- and contravariant)
}

// Method signatures maintain bivariance for backward compatibility
// In practice, the property arrow style is safer

Parameter Count and Type Compatibility

// TypeScript allows functions with fewer parameters (JavaScript convention)
type BinaryFn = (a: number, b: number) => number;

const unary: (a: number) => number = (a) => a;
const binary: BinaryFn = unary; // valid: fewer parameters are still compatible

// This is why array forEach callbacks work without the index parameter
[1, 2, 3].forEach((n) => console.log(n)); // (value, index, array) → accepting only value is fine

Summary Table

FeatureSyntaxDescription
Arrow function typetype F = (a: T) => UMost common function type declaration
Call signature{ (a: T): U; prop: V }Express a function with properties
Method signature{ method(a: T): U }Method inside an interface
Optional parameter(a: T, b?: U) => Vb can be omitted; type is U | undefined
Default parameter(a: T, b: U = val) => VTreated as optional, type is inferred
Rest parameter(...args: T[]) => VCollects remaining args into an array
OverloadN declaration signatures + 1 implementationExplicitly declares input-output type pairs
Callback void(cb: () => void)Return value is ignored; returning is still allowed
this parameter(this: T, a: U) => VDeclares the this type (not an actual argument)
Covariance (return)Subtype-returning function is assignableDog => __ ⊂ Animal => __
Contravariance (param)Supertype-accepting function is assignable__ => Animal ⊂ __ => Dog

What's Next...

Chapter 4 covers generics, the most powerful abstraction tool in TypeScript. You will learn to write reusable functions, classes, and interfaces using the <T> type parameter, and explore the depth of the generic type system through type constraints (extends), default types, conditional types, and the infer keyword.

Advertisement