Chapter 3: Functions and Control Flow
Functions are the building blocks of most TypeScript programs; control flow guides how and when those functions run. This chapter focuses on typing function parameters and return values, practical narrowing techniques, how to model impossible states with never, and patterns for callbacks and typed events.
Function types, parameters, and return types
TypeScript lets you describe a function’s shape precisely. You can annotate parameter types, return types, and the overall callable signature. Clear function types make call sites safer, improve editor assistance, and simplify refactoring.
function declarations and => arrows
Both declaration and arrow styles support full type annotations. Prefer explicit return types for exported functions; they help prevent unintended changes from leaking into public APIs.
function add(a: number, b: number): number { return a + b; }
const multiply = (a: number, b: number): number => a * b;
// Function type variable
let op: (x: number, y: number) => number;
op = add; // OK
Optional, defaulted, and rest parameters
Optional parameters use ?. Default values provide a value when callers omit the argument. Rest parameters capture a list of arguments as an array; annotate the element type rather than the array literal.
function greet(name?: string): string { return `Hello ${name ?? "friend"}`; }
function power(base: number, exp: number = 2): number { return base ** exp; }
function sumAll(...nums: number[]): number { return nums.reduce((t, n) => t + n, 0); }
this types and call signatures
You can annotate the expected this for a function using a fake first parameter. This helps when a function is intended to be called with an explicit receiver through call or apply.
interface Point { x: number; y: number; }
function move(this: Point, dx: number, dy: number): void { this.x += dx; this.y += dy; }
const p: Point = { x: 0, y: 0 };
move.call(p, 5, 3);
Overloads for multiple call shapes
Overload signatures describe distinct ways to call the same function. Provide one or more overloads, then implement a single body that narrows parameters at runtime and returns a value compatible with the overload list.
function format(input: number): string;
function format(input: Date): string;
function format(input: number | Date): string {
if (typeof input === "number") { return input.toFixed(2); }
return input.toISOString();
}
const a = format(3.14); // string
const b = format(new Date()); // string
Return type inference
TypeScript infers return types from the implementation. For APIs, write an explicit type. Use void for functions that should not be used for values; reserve undefined for cases where a function intentionally returns that value.
function log(message: string): void { console.log(message); }
// do not rely on a value
function maybeFind(): string | undefined { return Math.random() > 0.5 ? "value" : undefined; }
Narrowing and equality checks
Narrowing refines a union type to a specific member inside a code block. The compiler trusts well known runtime checks such as typeof, in, and equality comparisons; this unlocks property access and method calls that would otherwise be unsafe.
Primitive guards
Use typeof for primitives such as string, number, boolean, bigint, and symbol. The compiler narrows within the guarded branch.
function len(x: string | string[]): number {
if (typeof x === "string") { return x.length; }
return x.reduce((t, s) => t + s.length, 0);
}
Property existence
The in operator narrows based on whether a property exists. This works well for object unions where each variant has a distinguishing key.
type Circle = { kind: "circle"; radius: number };
type Square = { kind: "square"; side: number };
type Shape = Circle | Square;
function area(s: Shape): number {
if ("radius" in s) { return Math.PI * s.radius ** 2; }
return s.side * s.side;
}
Equality checks and discriminated unions
When unions share a literal tag such as kind, compare that tag to narrow precisely. This is a common pattern for state machines and reducers.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: string };
function render(s: State): string {
if (s.status === "idle") { return "Start"; }
if (s.status === "loading") { return "Loading…"; }
if (s.status === "error") { return `Error: ${s.message}`; }
return `OK: ${s.data}`;
}
Truthiness and nullish checks
Checks like if (value) narrow away null and undefined. For values where empty strings or zero are valid, prefer value ?? fallback instead of truthiness to avoid accidental replacement.
function label(x: string | null | undefined): string {
if (x) { return x.toUpperCase(); }
return "UNKNOWN";
}
function safePort(p: number | null | undefined): number { return p ?? 3000; }
Exhaustiveness and never
Exhaustive handling ensures every variant of a union is covered. When a switch or if chain handles all cases, the leftover path becomes never. Use this to detect missing cases during refactoring.
Exhaustive switch with a never guard
Place a final check that assigns the remaining value to a never variable. If a new variant appears, the compiler flags the assignment.
type Result =
| { type: "ok"; value: number }
| { type: "fail"; reason: string };
function handle(r: Result): string {
switch (r.type) {
case "ok": return `Value ${r.value}`;
case "fail": return `Fail: ${r.reason}`;
default: {
const _exhaustive: never = r; // compile error if a case is missing
return _exhaustive;
}
}
}
assertNever helper
A small utility improves readability. It turns an impossible value into a thrown error at runtime and a compile time error for missing cases.
function assertNever(x: never): never { throw new Error(`Unexpected: ${String(x)}`); }
type Op = { kind: "add"; a: number; b: number } | { kind: "mul"; a: number; b: number };
function compute(op: Op): number {
switch (op.kind) {
case "add": return op.a + op.b;
case "mul": return op.a * op.b;
}
return assertNever(op);
}
Intentional non-returning functions
Annotate functions that never produce a value with never. Examples include functions that always throw or endlessly loop. The annotation communicates intent; it also helps control flow analysis.
function fail(msg: string): never { throw new Error(msg); }
function loopForever(): never { for (;;) { /* do work … */ } }
Callbacks, higher order functions, and typed events
Callbacks and event handlers connect parts of your program. With careful types, producers and consumers agree on data shapes; this prevents subtle bugs when parameters change.
Typing callbacks
Express callbacks as function types. For Node style error-first callbacks, model the first parameter as Error | null; this encourages explicit null checks at call sites.
type Callback<T> = (err: Error | null, value?: T) => void;
function asyncRead(path: string, cb: Callback<string>): void {
setTimeout(() => { cb(null, "file contents"); }, 10);
}
asyncRead("data.txt", (err, value) => {
if (err) { console.error(err.message); return; }
console.log(value);
});
Higher order functions
Higher order functions accept functions or return functions. Generics keep types connected across parameters and results; this preserves inference at call sites.
function mapValues<A, B>(xs: A[], f: (a: A) => B): B[] { return xs.map(f); }
const lengths = mapValues(["a", "bb", "ccc"], s => s.length);
Event emitters with typed payloads
You can create a simple typed event system by mapping event names to payload types. The emitter enforces correct payloads for emit and correct handlers for on.
type Events = {
"connected": { id: string; at: Date };
"message": { from: string; text: string };
};
class Emitter {
private handlers: { [K in keyof Events]?: Array<(p: Events[K]) => void> } = {};
on<K extends keyof Events>(name: K, h: (p: Events[K]) => void): void {
(this.handlers[name] ??= []).push(h);
}
emit<K extends keyof Events>(name: K, payload: Events[K]): void {
for (const h of this.handlers[name] ?? []) { h(payload); }
}
}
const bus = new Emitter();
bus.on("message", m => console.log(m.text));
bus.emit("message", { from: "alice", text: "Hi" });
on and emit aligned.
Typing Promise-based APIs and async functions
async functions return Promise<T> automatically. Annotate the resolved type when clarity helps. When adapting callbacks to promises, express the wrapper’s return type as a Promise.
async function fetchJson(url: string): Promise<unknown> {
const res = await fetch(url);
return res.json();
}
function readFileP(path: string): Promise<string> {
return new Promise((resolve, reject) => {
setTimeout(() => resolve("contents"), 5);
});
}
Control flow with guards and early returns
Guard clauses simplify complex branches. Return early when preconditions fail; the remaining code benefits from narrowed types and fewer nested blocks.
function parseId(x: string | null | undefined): number {
if (x == null) { return -1; }
const n = Number(x);
if (Number.isNaN(n)) { return -1; }
return n;
}