Phase 1 · Foundations · Lesson 1.3
BeginnerTyping functions
Parameters, return types, optional and rest parameters, function types, overloads, the `this` parameter, and the `void` and `never` return types that trip people up in interviews.
20 min
Functions are where types pay off most. A typed function signature is a contract: callers can't pass the wrong thing, and the body can't return the wrong thing. It's also where TypeScript has its strangest rules, like a void callback that is allowed to return a number. This lesson covers both.
Parameters and return types
Annotate each parameter after its name. The return type goes after the parameter list.
function add(a: number, b: number): number {
return a + b;
}
// @ts-expect-error -- Expected 2 arguments, but got 1.
add(1);
// @ts-expect-error -- Expected 2 arguments, but got 3.
add(1, 2, 3);Unlike JavaScript, TypeScript checks the number of arguments too. Missing or extra arguments are errors.
Should you annotate the return type?
TypeScript infers the return type from the return statements, so this is optional:
function double(n: number) {
return n * 2;
}
const result = double(21);
// ^? const result: number (the inferred return type)When to write it anyway:
- Public or exported functions. The annotation is the contract. Without it, a change deep in the body silently changes the API.
- To catch mistakes in the body. With an annotation, a wrong
returnis reported at the return, not at some far-away caller. - Recursive functions whose return depends on themselves. TypeScript can't infer those:
// @ts-expect-error -- 'factorial' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
function factorial(n: number) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
function factorialOk(n: number): number {
return n <= 1 ? 1 : n * factorialOk(n - 1);
}For small internal helpers and inline callbacks, let inference do the work.
Optional and default parameters
A ? makes a parameter optional. Inside the function its type includes undefined.
function greet(name: string, greeting?: string) {
const g = greeting;
// ^? const g: string | undefined
return `${greeting ?? "Hello"}, ${name}`;
}
greet("Ada");
greet("Ada", "Hi");A default value also makes a parameter optional, and its type is inferred from the default. Inside the body it is never undefined.
function repeat(text: string, times = 2) {
const t = times;
// ^? const t: number
return text.repeat(times);
}
repeat("ab");
repeat("ab", undefined); // passing undefined explicitly uses the defaultOrdering rules:
- An optional (
?) parameter can't come before a required one. - A default parameter can, but then callers must pass
undefinedto skip it, which is awkward.
function bad(a?: number, b: number) {}
// ~ A required parameter cannot follow an optional parameter.Rest parameters
...name: T[] collects the remaining arguments into an array. The type must be an array or tuple type.
function sum(...nums: number[]): number {
return nums.reduce((total, n) => total + n, 0);
}
sum();
sum(1, 2, 3);
const values = [1, 2, 3];
sum(...values); // spreading an array into a rest parameter is fineSpreading into a function with fixed parameters is a classic gotcha. An array has unknown length, so TypeScript can't prove it has exactly two items:
const coords = [10, 20];
// @ts-expect-error -- A spread argument must either have a tuple type or be passed to a rest parameter.
Math.atan2(...coords);
const fixed = [10, 20] as const; // a tuple: length known
Math.atan2(...fixed);Function types
Functions are values, so they need types too. The function type expression looks like an arrow function:
type BinaryOp = (a: number, b: number) => number;
const multiply: BinaryOp = (a, b) => a * b; // a and b are inferred from BinaryOp
function apply(op: BinaryOp, x: number, y: number) {
return op(x, y);
}
apply(multiply, 3, 4);
apply((a, b) => a - b, 3, 4);Notice (a, b) => a * b needs no annotations. That's contextual typing: the expected type flows into the function.
Call signatures
A function type expression can't describe a function that also has properties. For that, use an object type with a call signature. Note the : instead of =>.
type Formatter = {
(value: number): string; // call signature
locale: string; // plus a property
};
function makeFormatter(): Formatter {
const fn = (value: number) => value.toFixed(2);
return Object.assign(fn, { locale: "en" });
}
const format = makeFormatter();
format(3.14159);
format.locale;There is also a construct signature, written new (...args) => T or { new (x: string): T }, for things called with new.
Quick check
What is the type of n inside the arrow function?
type Handler = (n: number) => void;
const h: Handler = (n) => console.log(n);Overloads
Sometimes one function accepts different argument shapes, and the return type depends on which. Overload signatures list each shape; a single implementation signature follows with the body.
function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
return typeof value === "string" ? Number(value) : String(value);
}
const a = parse("42");
// ^? const a: number
const b = parse(42);
// ^? const b: stringRules that interviewers probe:
- The implementation signature is not callable. Callers only see the overloads above it. Even though the implementation accepts
string | number, you can't callparsewith a union:
function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
return typeof value === "string" ? Number(value) : String(value);
}
declare const input: string | number;
// @ts-expect-error -- No overload matches this call.
parse(input);- The implementation must be compatible with every overload. Its parameters must accept each overload's arguments and its return type must cover each overload's return.
- Order matters. TypeScript picks the first overload that matches, top to bottom. Put specific signatures before general ones, or the general one swallows every call:
function describe(value: unknown): string;
function describe(value: string): "text"; // never chosen: the line above matches first
function describe(value: unknown): string {
return typeof value === "string" ? "text" : "other";
}
const d = describe("hi");
// ^? const d: stringThe this parameter
In JavaScript, this depends on how a function is called. TypeScript lets you declare what this must be with a fake first parameter named this. It's erased at compile time and doesn't count as an argument.
interface Counter {
count: number;
increment(this: Counter): void;
}
const counter: Counter = {
count: 0,
increment() {
this.count++; // this is Counter
},
};
counter.increment(); // fine: called as a method
const loose = counter.increment;
// @ts-expect-error -- The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'.
loose();Pulling a method off its object and calling it bare is a real bug (this would be undefined), and the this parameter catches it.
void: "the result is ignored"
A function declared to return void can't return a value:
function log(message: string): void {
console.log(message);
// @ts-expect-error -- Type 'number' is not assignable to type 'void'.
return 42;
}But a function type returning void accepts functions that return anything. This is deliberate:
type Callback = (item: number) => void;
const double: Callback = (item) => item * 2; // returns number: allowed
const results: number[] = [];
[1, 2, 3].forEach((n) => results.push(n)); // push returns a number; forEach wants voidWhy? Array.prototype.forEach ignores the callback's result. If () => void rejected value-returning functions, forEach(n => results.push(n)) would be an error, and that pattern is everywhere. So the rule is: a void return in a function type means "the caller won't use the result", not "must return nothing".
The return value is still hidden from whoever calls through the void type:
type Callback = (item: number) => void;
const cb: Callback = (item) => item * 2;
const r = cb(1);
// ^? const r: voidQuick check
Which line is a type error?
const f: () => void = () => "x";
function g(): void { return "x"; }
f();never: functions that don't return
A function that always throws, or loops forever, has return type never. Its call is a dead end, and TypeScript uses that for narrowing.
function fail(message: string): never {
throw new Error(message);
}
function getPort(value: string | undefined): number {
if (value === undefined) fail("PORT is required");
return Number(value); // value is string here: fail() never returns
}The gotcha: inference differs between declarations and expressions. A function declaration that only throws is inferred as void, for backward compatibility. An arrow function or function expression is inferred as never.
function throwsDecl() {
throw new Error("x");
}
const fromDecl = throwsDecl;
// ^? const fromDecl: () => void
const throwsArrow = () => {
throw new Error("x");
};
const fromArrow = throwsArrow;
// ^? const fromArrow: () => neverSo always write : never explicitly on helpers like fail. For the narrowing in getPort to work, TypeScript also requires the function's type to be explicitly annotated.
Function declarations vs arrow functions
Both are fully typed. The differences are mostly JavaScript differences, plus a couple of TypeScript ones:
function f() {} | const f = () => {} | |
|---|---|---|
| Hoisted (callable before its line) | yes | no |
Own this | yes (can declare a this parameter) | no, uses the surrounding this |
| Overloads | yes, with overload signatures | only via a call-signature type |
| Typed with a type alias | no | yes: const f: BinaryOp = ... |
| Only-throws body infers | void | never |
A common style: function for top-level, exported functions (hoisting, overloads, readable stack traces); arrows for callbacks and when you want to type the whole function with an alias.
Spot the error
This code has three problems under strict. Find them.
type Mapper = (string) => number;
function total(prices?: number[], currency: string) {
return prices.reduce((sum, p) => sum + p, 0);
}Show the fix
(string) => numberdeclares a parameter namedstringwith an implicitanytype. Function types need a name and a type:(value: string) => number.prices?is optional but comes before the requiredcurrency: A required parameter cannot follow an optional parameter.prices.reducefails because an optionalpricesis possiblyundefined.
Put the required parameter first and give prices a default, which also removes undefined from its type:
type Mapper = (value: string) => number;
function total(currency: string, prices: number[] = []): string {
const sum = prices.reduce((acc, p) => acc + p, 0);
return `${sum} ${currency}`;
}Try it
Experiment with overload order and void callbacks. Swap the two overload signatures of toArray and hover one again.
function toArray(value: string): string[];
function toArray(value: string[]): string[];
function toArray(value: string | string[]): string[] {
return Array.isArray(value) ? value : [value];
}
const one = toArray("a");
const many = toArray(["a", "b"]);
type OnItem = (item: string) => void;
function each(items: string[], fn: OnItem) {
for (const item of items) fn(item);
}
const seen: string[] = [];
each(many, (item) => seen.push(item)); // returns number, still fine
function assertNever(value: never): never {
throw new Error(`Unexpected: ${String(value)}`);
}▶ Try it in the TypeScript Playground
Recap
- Annotate parameters always; annotate return types on exported and recursive functions, let inference handle small helpers.
x?: TgivesT | undefinedinside;x = defaultinfers the type and is neverundefinedinside. Optional can't precede required.- Rest parameters take an array or tuple type. Spreading an array into fixed parameters needs a tuple.
- Function types:
(a: number) => string, or an object type with a call signature{ (a: number): string; prop: T }. Parameter names are mandatory. - Overloads: callers see only the overload signatures; the implementation signature is hidden; first matching overload wins.
- A
thisparameter typesthisand catches detached method calls. It's erased at compile time. () => voidaccepts functions that return values; an explicit: voidon a declaration does not.- Functions that always throw return
never. Annotate it explicitly: declarations infervoid.
Interview cards
1 / 7