Skip to main content

What if your functions could reject bad inputs before they even run? What if, instead of writing defensive code that checks types at runtime, you could define the rules up front and let the compiler enforce them?

That's what typed functions do. And once you experience it, you'll never want to go back.

This chapter is about making your functions opinionated. A function with opinions doesn't politely accept whatever you give it and hope for the best. It declares exactly what it expects, exactly what it returns, and refuses to compile if you get either wrong.

Parameter Types: The Front Door​

Every function has a contract. The parameters are the "you must provide" clause. In JavaScript, that contract is implicit — you have to read the function body (or the docs, if they exist) to know what to pass. In TypeScript, the contract is explicit:

function createUser(name: string, age: number, isAdmin: boolean) {
return { name, age, isAdmin, createdAt: new Date() };
}

createUser("Alice", 30, true); // OK
createUser("Alice", "30", true); // Error: 'string' is not assignable to 'number'
createUser("Alice", 30); // Error: Expected 3 arguments, got 2
createUser("Alice", 30, true, "extra"); // Error: Expected 3 arguments, got 4

The compiler checks three things:

  1. Argument count — you must pass exactly the right number of arguments
  2. Argument types — each argument must match the declared type
  3. Argument order — types must match in the correct position

This catches an entire category of bugs: wrong number of arguments, wrong types, swapped arguments. Before a single line of your function runs.

Optional Parameters​

Not every parameter is required. Mark optional ones with ?:

function greet(name: string, title?: string): string {
if (title) {
return `Hello, ${title} ${name}`;
}
return `Hello, ${name}`;
}

greet("Alice"); // OK
greet("Alice", "Dr."); // OK

Optional parameters must come AFTER required parameters. TypeScript enforces this — an optional parameter followed by a required one makes no sense.

Default Parameters​

Default values make parameters optional automatically:

function createUser(name: string, age: number = 0, isAdmin: boolean = false) {
return { name, age, isAdmin };
}

createUser("Alice"); // OK — age and isAdmin get defaults
createUser("Alice", 30); // OK — isAdmin gets default
createUser("Alice", 30, true); // OK — all explicit

TypeScript infers the type from the default value. age: number = 0 means age is number and optional. You don't need to write age?: number — the default handles both the type and the optionality.

Rest Parameters​

For functions that take a variable number of arguments:

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

sum(1, 2, 3); // 6
sum(1, 2, 3, 4, 5); // 15
sum(); // 0 (empty array)

You can also type rest parameters as tuples for mixed-type variadic arguments:

function log(...args: [string, ...number[]]): void {
const [message, ...values] = args;
console.log(message, ...values);
}

log("Scores:", 95, 87, 92); // OK
log("Scores:"); // OK
log(42, 95); // Error: first argument must be string

Return Types: The Back Door​

Just as important as what goes in is what comes out. Annotating return types is the single highest-leverage habit in TypeScript:

function calculateTotal(items: { price: number }[]): number {
return items.reduce((sum, item) => sum + item.price, 0);
}

Why annotate return types when TypeScript can infer them? Three reasons:

  1. Documentation. The return type is part of your function's public API. Anyone calling your function should know what they're getting back without reading the implementation.

  2. Refactoring safety. If you change the function body and accidentally change what it returns, TypeScript catches the mismatch. Without an explicit return type, the function's return type silently changes — and every caller now has a different type than before.

  3. Better error messages. When a function call has a type error, TypeScript can point to the return type annotation and say "this function returns X, but you're using it as Y." Without the annotation, the error might show up far from the actual problem.

The Rule​

Always annotate return types on exported functions. For internal helpers, inference is fine. But if another file imports your function, annotate the return type.

Function Type Expressions​

You can define a function's type separately from its implementation:

// Function type expression
type MathOperation = (a: number, b: number) => number;

const add: MathOperation = (a, b) => a + b;
const subtract: MathOperation = (a, b) => a - b;
const multiply: MathOperation = (a, b) => a * b;

// Now any function assigned to MathOperation must match the signature
const divide: MathOperation = (a, b) => {
if (b === 0) throw new Error("Division by zero");
return a / b;
};

This is powerful for callbacks, event handlers, and any place where multiple functions share the same signature:

type EventHandler = (event: Event) => void;

const onClick: EventHandler = (e) => {
console.log("clicked", e.target);
};

const onKeyDown: EventHandler = (e) => {
console.log("key pressed", (e as KeyboardEvent).key);
};

Function Overloads: Multiple Signatures, One Function​

Sometimes a function can accept different combinations of arguments and return different types based on those arguments. Function overloads let you express this:

// Overload signatures (the public API)
function getUser(id: number): User | undefined;
function getUser(email: string): User | undefined;
function getUser(name: string, age: number): User | undefined;

// Implementation signature (covers all cases)
function getUser(query: number | string, age?: number): User | undefined {
if (typeof query === "number") {
return db.users.findById(query);
}
if (age !== undefined) {
return db.users.findByNameAndAge(query, age);
}
return db.users.findByEmail(query);
}

The overload signatures define what callers can pass. The implementation signature must be compatible with ALL overload signatures. TypeScript only exposes the overload signatures to callers — the implementation signature is hidden.

When to Use Overloads​

Overloads are for when the return type depends on the argument type:

function getData(key: string): string | null;
function getData(key: string[]): Record<string, string | null>;
function getData(key: string | string[]): string | null | Record<string, string | null> {
if (Array.isArray(key)) {
const result: Record<string, string | null> = {};
for (const k of key) {
result[k] = localStorage.getItem(k);
}
return result;
}
return localStorage.getItem(key);
}

const single = getData("token"); // Type: string | null
const multi = getData(["token", "user"]); // Type: Record<string, string | null>

Without overloads, getData would return string | null | Record<string, string | null> for both calls, and you'd need to narrow the type at every call site.

Overloads vs. Union Types​

Often, a union type on the parameter is simpler and better than overloads:

// Prefer this (simpler)
function getLength(value: string | unknown[]): number {
return value.length;
}

// Over this (unnecessary complexity)
function getLength(value: string): number;
function getLength(value: unknown[]): number;
function getLength(value: string | unknown[]): number {
return value.length;
}

Use overloads only when the return type varies based on the input type. For everything else, union types are cleaner.

this Parameter: Taming Context​

In JavaScript, this is notoriously tricky. TypeScript lets you type it:

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

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

user.greet(); // OK

const greet = user.greet;
greet(); // Error: The 'this' context of type 'void' is not assignable
// to method's 'this' of type 'User'

The this parameter is a fake parameter — it doesn't appear in the compiled JavaScript. It exists only for type checking. Use it when your function depends on a specific this context.

Call Signatures: Objects That Are Functions​

In JavaScript, functions are objects. They can have properties. TypeScript lets you type this:

type ValidatorFn = {
(value: string): boolean;
description: string;
errorMessage: string;
};

function createValidator(
fn: (value: string) => boolean,
description: string,
errorMessage: string
): ValidatorFn {
const validator = fn as ValidatorFn;
validator.description = description;
validator.errorMessage = errorMessage;
return validator;
}

const isEmail = createValidator(
(v) => v.includes("@"),
"Validates email addresses",
"Must be a valid email"
);

isEmail("alice@example.com"); // true
isEmail.description; // "Validates email addresses"

This pattern is common in libraries that attach metadata to functions.

void vs undefined vs never in Return Position​

These three types cause endless confusion. Here's the definitive guide:

// void: "I don't return anything useful. Don't use my return value."
function logMessage(msg: string): void {
console.log(msg);
}

// undefined: "I explicitly return undefined. You can use it if you want."
function getNothing(): undefined {
return undefined;
}

// never: "I never return. The function doesn't complete normally."
function throwError(msg: string): never {
throw new Error(msg);
}

The practical difference:

// void is flexible — a void-returning function CAN return a value
const forEachResult: void = [1, 2, 3].forEach(n => n * 2);
// forEach returns void, even though the callback returns number

// undefined is strict — you MUST return undefined
function strict(): undefined {
return undefined; // Must explicitly return undefined
}

// never means the code after the call is unreachable
function assertNever(x: never): never {
throw new Error(`Unexpected value: ${x}`);
}

type Shape = "circle" | "square";
function getArea(shape: Shape): number {
switch (shape) {
case "circle":
return Math.PI * 5 * 5;
case "square":
return 10 * 10;
default:
return assertNever(shape); // TypeScript knows this line is unreachable
}
}

Generic Functions: A Preview​

We'll dive deep into generics in Chapter 9, but here's the teaser — a function that works with any type while preserving type information:

function firstElement<T>(arr: T[]): T | undefined {
return arr[0];
}

const nums = firstElement([1, 2, 3]); // Type: number | undefined
const strings = firstElement(["a", "b"]); // Type: string | undefined

The <T> is a type parameter. It says "this function works with any type, and I'll tell you what type when I call it." TypeScript infers T from the argument — you don't need to write firstElement<number>([1, 2, 3]).

This is the gateway to reusable, type-safe abstractions. But we're getting ahead of ourselves.

Try This: Function Typing​

  1. Write a function createGreeting that takes a name (string) and an optional salutation (string, default "Hello") and returns a greeting string. Annotate everything.
  2. Write a function type Transformer<T> that represents a function taking T and returning T. Create two functions that match this type.
  3. Write an overloaded function format that:
    • Takes a number and returns a string (formatted as currency)
    • Takes a Date and returns a string (formatted as ISO date)
    • Takes a string and returns a string (trimmed and lowercased)
  4. Write a function with a this parameter. Try calling it with the wrong this context and see the error.
  5. Write a function that returns never. Use it in a switch statement's default case to get exhaustiveness checking.

Time needed: 20 minutes.

What to notice: How the overloaded format function gives precise return types based on input types. How the this parameter catches context errors at compile time. How never makes your switch statements bulletproof.

The Bridge​

You now know how to type functions — the verbs of your program. But programs aren't just verbs. They're also nouns: the objects, the data structures, the shapes that flow through your functions.

In the next chapter, you'll learn to type those shapes with interfaces and type aliases. You'll discover how to define objects that can't lie about their structure — and what happens when you try.


In the next chapter: interfaces vs. type aliases, optional properties, readonly modifiers, index signatures, and the art of designing types that make invalid states impossible.