Intermediate

Functions in TypeScript

Learn how to define and type functions in TypeScript - parameters, return types, optional and default arguments, recursion, closures, higher-order functions, generics, and overloads

Functions are the primary unit of reuse in TypeScript. Because TypeScript is a multi-paradigm language — object-oriented, functional, and imperative all at once — functions are first-class values: you can store them in variables, pass them as arguments, return them from other functions, and assign them types just like any other value.

What sets TypeScript apart from plain JavaScript is that every part of a function can be typed: its parameters, its return value, and the function itself. The compiler then checks every call site. With TypeScript’s structural and optional typing, you get as much or as little type safety as you want — you can annotate everything explicitly, or lean on inference and only annotate the parts that matter.

This tutorial covers function declarations, optional and default parameters, rest parameters, arrow functions and function types, recursion, variable scope and closures, higher-order functions, generics, and function overloads. By the end you’ll know how to express most function patterns you’ll meet in real TypeScript codebases.

Declaring Functions, Parameters, and Return Values

A function declaration names its parameters with types and declares a return type after the parameter list. TypeScript also supports optional parameters (age?), default parameters (role = "member"), and rest parameters (...values) that collect a variable number of arguments into a typed array.

Create a file named functions.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Functions in TypeScript

// 1. Basic declaration: typed parameters and a typed return value
function add(a: number, b: number): number {
    return a + b;
}

// 2. Optional (age?) and default (role) parameters
function createUser(name: string, role: string = "member", age?: number): string {
    const agePart = age !== undefined ? `, age ${age}` : "";
    return `${name} (${role}${agePart})`;
}

// 3. Rest parameters collect any number of arguments into number[]
function sum(...values: number[]): number {
    return values.reduce((total, n) => total + n, 0);
}

// 4. Arrow function annotated with an explicit function type
const multiply: (a: number, b: number) => number = (a, b) => a * b;

// 5. Recursion: factorial calls itself until the base case
function factorial(n: number): number {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

// 6. Higher-order function: fn is itself a function value
function applyTwice(value: number, fn: (x: number) => number): number {
    return fn(fn(value));
}

// 7. Generic function: T is inferred from the argument
function firstElement<T>(items: T[]): T | undefined {
    return items[0];
}

console.log(`add(2, 3) = ${add(2, 3)}`);
console.log(createUser("Ada"));
console.log(createUser("Linus", "admin", 35));
console.log(`sum(1, 2, 3, 4) = ${sum(1, 2, 3, 4)}`);
console.log(`multiply(4, 5) = ${multiply(4, 5)}`);
console.log(`factorial(5) = ${factorial(5)}`);
console.log(`applyTwice(3, double) = ${applyTwice(3, (x) => x * 2)}`);
console.log(`firstElement(['a', 'b']) = ${firstElement(["a", "b"])}`);

A few things worth noting:

  • multiply shows a function type(a: number, b: number) => number — assigned to a variable. Because the type is declared on the left, the arrow function’s parameters don’t need their own annotations; TypeScript infers them.
  • firstElement<T> is generic. The caller never writes the type — TypeScript infers T as string from ["a", "b"], and the return type becomes string | undefined.
  • applyTwice takes another function as a parameter, the essence of a higher-order function.

Scope and Closures

TypeScript uses lexical scoping. Variables declared with let and const are block-scoped, and an inner function “closes over” the variables in the scope where it was defined — even after the outer function has returned. This closure is what makes makeCounter below remember its count between calls.

Create a file named closures_scope.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Variable scope and closures in TypeScript

const greeting = "Hello"; // module-level: visible to every function in this file

function makeCounter(): () => number {
    let count = 0; // local to makeCounter, captured by the returned closure
    return function (): number {
        count += 1;
        return count;
    };
}

function scopeDemo(): void {
    const local = "inside scopeDemo"; // local: not visible outside this function
    console.log(`${greeting} from ${local}`);
}

const counter = makeCounter();
console.log(`counter() = ${counter()}`);
console.log(`counter() = ${counter()}`);
console.log(`counter() = ${counter()}`);
scopeDemo();

Each call to counter() increments the same captured count, so the values climb. The : void return type on scopeDemo documents that it returns nothing useful — it runs only for its side effect.

Function Overloads

TypeScript lets a single function declare multiple overload signatures — different parameter and return type combinations — followed by one implementation that handles them all. The compiler checks calls against the overload signatures, not the implementation signature, giving callers precise types per input.

Create a file named overloads.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Function overloads: several typed signatures, one implementation

function describe(value: string): string;
function describe(value: number): string;
function describe(value: boolean): string;
function describe(value: string | number | boolean): string {
    return `${typeof value}: ${value}`;
}

console.log(describe("typescript"));
console.log(describe(42));
console.log(describe(true));

The three signatures above the implementation are the public API. The final signature (with the union type) is only visible inside the function body — callers can’t use it directly, which keeps the accepted types strictly limited to string, number, or boolean.

Running with Docker

Run each example with ts-node, which compiles and executes TypeScript in a single step. No local Node.js install is required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Pull the official image
docker pull node:22-alpine

# Run the functions example
docker run --rm -v $(pwd):/app -w /app node:22-alpine sh -c 'npx -y ts-node functions.ts'

# Run the scope and closures example
docker run --rm -v $(pwd):/app -w /app node:22-alpine sh -c 'npx -y ts-node closures_scope.ts'

# Run the overloads example
docker run --rm -v $(pwd):/app -w /app node:22-alpine sh -c 'npx -y ts-node overloads.ts'

Expected Output

Running functions.ts:

add(2, 3) = 5
Ada (member)
Linus (admin, age 35)
sum(1, 2, 3, 4) = 10
multiply(4, 5) = 20
factorial(5) = 120
applyTwice(3, double) = 12
firstElement(['a', 'b']) = a

Running closures_scope.ts:

counter() = 1
counter() = 2
counter() = 3
Hello from inside scopeDemo

Running overloads.ts:

string: typescript
number: 42
boolean: true

Key Concepts

  • Type the whole signature: Parameters and return values can each carry type annotations, and the compiler checks every call site against them.
  • Flexible parameters: Use ? for optional parameters, = for defaults, and ... for rest parameters that gather extra arguments into a typed array.
  • Functions are first-class values: Store them in variables, pass them to other functions, and return them — annotate them with function types like (x: number) => number.
  • Closures capture scope: An inner function retains access to the variables of the scope it was defined in, even after the outer function returns.
  • Generics keep functions reusable and type-safe: A type parameter like <T> lets one function work over many types while preserving precise types, often through inference.
  • Overloads express related signatures: Multiple overload signatures over one implementation give callers exact types for each kind of input.
  • Inference reduces noise: TypeScript often infers return types and arrow-function parameters, so annotate where it adds clarity rather than everywhere.

Running Today

All examples can be run using Docker:

docker pull node:22-alpine
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining