Typing Functions in TypeScript
A practical, beginner-friendly guide to typing functions in TypeScript — parameter and return types, optional and default parameters, rest parameters, callbacks, and overloads.
What you'll learn
- ✓How to annotate parameters and return types
- ✓Optional and default parameters
- ✓Rest parameters with proper array typing
- ✓Function type expressions for variables and callbacks
- ✓The void return type and what it really means
- ✓A first look at function overloads
Prerequisites
- •You understand interfaces and type aliases — see Interfaces & Type Aliases
Functions are where the type system earns its keep. A well-typed function is self-documenting, autocompletes for callers, and refuses arguments that do not fit. This post covers everything a beginner needs to type functions correctly in real code.
Parameter and return types
The form is parameter: Type, with the return type after the closing parenthesis:
function add(a: number, b: number): number {
return a + b;
}
add(2, 3); // 5
add(2, "3"); // Error: Argument of type 'string' is not assignable to parameter of type 'number'.
Annotating parameters is essentially mandatory — without them, TypeScript infers any for each (and with noImplicitAny, the file does not compile). Return types are optional. The compiler will infer the return type from the body:
function add(a: number, b: number) {
return a + b; // return type inferred as number
}
A practical rule: always annotate exported functions, let inference handle private helpers. Explicit returns on a public API make it impossible to silently change the contract by editing the body.
Optional parameters
A parameter followed by ? may be omitted. Inside the function, its type includes undefined:
function greet(name: string, title?: string): string {
if (title === undefined) {
return `Hello, ${name}`;
}
return `Hello, ${title} ${name}`;
}
greet("Ada"); // "Hello, Ada"
greet("Ada", "Dr."); // "Hello, Dr. Ada"
Optional parameters must come after required ones. There is no syntax for (a?: number, b: number).
Default parameters
A parameter with a default value is automatically optional, and its type is inferred from the default:
function greet(name: string, title: string = "friend"): string {
return `Hello, ${title} ${name}`;
}
greet("Ada"); // "Hello, friend Ada"
greet("Ada", "Dr."); // "Hello, Dr. Ada"
Inside the function, title is just string — not string | undefined. The default closes the undefined case before the body ever runs. Prefer defaults over optional-with-checks whenever you have a sensible fallback.
Rest parameters
A rest parameter collects “the remaining arguments” into an array. The type is the array type, not the element type:
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3); // 6
sum(1, 2, 3, 4, 5); // 15
sum(); // 0
sum(1, "2"); // Error
A rest parameter must be the last one. You can mix it with required parameters in front:
function joinPath(base: string, ...segments: string[]): string {
return [base, ...segments].join("/");
}
joinPath("/users", "ada", "posts", "42");
Function type expressions
You can store a function in a variable. Annotating that variable uses the function type syntax:
const add: (a: number, b: number) => number = (a, b) => a + b;
The arrow => here is part of the type, not the function. It reads as “takes two numbers and returns a number.” Because the variable has a type, the arrow function’s parameters do not need their own annotations — the compiler infers them.
In practice, naming the type with an alias reads better:
type BinaryOp = (a: number, b: number) => number;
const add: BinaryOp = (a, b) => a + b;
const mul: BinaryOp = (a, b) => a * b;
This is how you type callbacks, event handlers, and higher-order functions.
Typing callbacks
A function that takes another function as a parameter is higher-order. The callback’s type is declared inline:
function map<T, U>(items: T[], fn: (item: T) => U): U[] {
const out: U[] = [];
for (const item of items) {
out.push(fn(item));
}
return out;
}
const lengths = map(["a", "bb", "ccc"], (s) => s.length);
console.log(lengths); // [1, 2, 3]
Two new things here. First, the <T, U> is generics — covered properly in the next post. Second, notice the callback’s parameter has no annotation at the call site. The compiler already knows that s is a string because items is a string[] and fn takes a T. This is called contextual typing: the type flows from context to fill in the gaps.
Try it yourself. Write a function filterStrings(items: string[], predicate: (s: string) => boolean): string[] that returns only the items for which the predicate returns true. Call it with a predicate that selects strings longer than three characters. Notice you do not need to annotate the predicate’s parameter at the call site.
The void return type
A function that does not return a useful value has return type void:
function log(message: string): void {
console.log(message);
}
There is a subtle and useful behaviour around void as a callback return type. When a function type says “returns void,” the compiler ignores the actual return value at the call site. This is what makes the following work:
const messages: string[] = [];
function forEach<T>(items: T[], fn: (item: T) => void): void {
for (const item of items) fn(item);
}
forEach([1, 2, 3], (n) => messages.push(`${n}`)); // ok — push returns a number, but void ignores it
If void were strict about the return value, you would have to wrap the body in { ... } to discard the result. The looser behaviour is intentional and pleasant in practice.
void is not the same as undefined. A function typed as (): undefined must explicitly return undefined. A function typed as (): void may return anything; the caller just is not allowed to look at it.
The never return type
A function that never returns a value at all — it always throws, or runs forever — has return type never:
function fail(message: string): never {
throw new Error(message);
}
You will not write many never functions by hand. The type is more useful as a marker — the compiler uses it during exhaustiveness checks on discriminated unions, which we will see in a moment.
Object parameters and destructuring
For functions with several parameters, an object is usually clearer than a long positional list:
interface CreateUserInput {
name: string;
email: string;
role?: "admin" | "editor" | "viewer";
}
function createUser({ name, email, role = "viewer" }: CreateUserInput): void {
console.log(`${name} <${email}> (${role})`);
}
createUser({ name: "Ada", email: "ada@example.com" });
createUser({ name: "Bea", email: "bea@example.com", role: "admin" });
The annotation applies to the whole destructured object, not to individual variables. Defaults inside the destructuring fill in missing optional values.
Function overloads
Sometimes a single function has more than one valid call signature. You can declare multiple overload signatures above one implementation:
function format(value: number): string;
function format(value: Date): string;
function format(value: number | Date): string {
if (typeof value === "number") return value.toFixed(2);
return value.toISOString();
}
format(3.14159); // "3.14"
format(new Date()); // ISO string
format("nope"); // Error: no overload matches
The overload signatures are what the compiler shows to callers; the implementation signature is invisible from outside. Callers cannot pass number | Date directly — only one of the declared overloads.
Overloads are most useful for libraries with truly distinct calling patterns. For most app code, a union parameter (value: number | Date) plus narrowing is simpler.
Exhaustiveness with discriminated unions
A pattern you will use constantly: handle every variant of a discriminated union, and let the compiler prove you handled them all.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "square"; side: number }
| { kind: "rect"; width: number; height: number };
function area(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "square":
return shape.side ** 2;
case "rect":
return shape.width * shape.height;
default: {
const _exhaustive: never = shape;
return _exhaustive;
}
}
}
If a future engineer adds { kind: "triangle"; ... } to Shape, the assignment const _exhaustive: never = shape stops compiling — shape is now triangle in that branch, which is not assignable to never. The compiler is forcing them to come back and handle the new case. This is one of the most valuable single tricks in the language.
Try it yourself. Add a fourth shape { kind: "triangle"; base: number; height: number } to Shape and watch the compiler complain in the default branch of area. Then add the matching case and watch the error vanish.
A worked example
Putting the pieces together — a small typed event emitter:
type Handler<T> = (payload: T) => void;
interface Emitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(event: K, handler: Handler<Events[K]>): void;
emit<K extends keyof Events>(event: K, payload: Events[K]): void;
}
function createEmitter<Events extends Record<string, unknown>>(): Emitter<Events> {
const handlers: { [K in keyof Events]?: Handler<Events[K]>[] } = {};
return {
on(event, handler) {
const list = handlers[event] ?? [];
list.push(handler);
handlers[event] = list;
},
emit(event, payload) {
for (const h of handlers[event] ?? []) {
h(payload);
}
},
};
}
interface AppEvents {
login: { userId: number };
logout: { userId: number; reason: string };
}
const events = createEmitter<AppEvents>();
events.on("login", ({ userId }) => {
console.log("login", userId);
});
events.emit("login", { userId: 1 });
events.emit("logout", { userId: 1, reason: "idle" });
events.emit("login", { reason: "wrong" }); // Error: missing 'userId' / extra 'reason'
This uses every concept in the post — annotated parameters, return types, callback types, defaults, and a glimpse of the generics that come next.
Recap
You now know:
- Parameters must be typed; return types are inferred but should be annotated on exported functions.
?makes a parameter optional; a default value makes it optional and removes theundefinedfrom its type inside the body.- Rest parameters use array types and must come last.
- Function types use the
(args) => returnarrow syntax and are usually named with atypealias. voidmeans “ignore the return value at the call site”;nevermeans “this function does not return.”- Function overloads declare multiple valid signatures above a single implementation.
- The
neverexhaustiveness trick turns discriminated unions into a compile-time guarantee that every case is handled.
Next steps
The example above leaned on <T>, <K>, and <Events> — placeholders that stand for “some type chosen later.” That is generics, and it is the last big idea in beginner TypeScript. The next post is a thorough walk through what generics are, when to use them, and how to read the syntax confidently.
→ Next: TypeScript Generics: The Beginner’s Guide
Questions or feedback? Email codeloomdevv@gmail.com.