Functions
Parameters, returns, optional arguments, and the two places TypeScript is deliberately more permissive than you would expect.
By the end: Type any function, including callbacks and optional parameters.
Functions are where the annotations you actually have to write live.
Most of this lesson is straightforward, and two parts of it are not. TypeScript is deliberately more permissive about functions than you would expect in two specific places, and both are unsoundness the language accepted on purpose because the alternative made ordinary code painful. Knowing where those holes are is worth more than memorising the syntax around them.
The basics
// A declaration.
function add(a: number, b: number): number {
return a + b;
}
// An arrow function. Same annotations, different syntax.
const multiply = (a: number, b: number): number => a * b;
// A function that returns nothing uses void.
function log(message: string): void {
console.log('log:', message);
}
// One that never returns at all uses never. More on this in lesson 12.
function fail(reason: string): never {
throw new Error(reason);
}
console.log(add(2, 3), multiply(2, 3));
log('started');
try {
fail('deliberate');
} catch (err) {
console.log('caught:', (err as Error).message);
}
Output
Describing a function as a type
// As a type alias, with arrow syntax.
type Comparator = (a: number, b: number) => number;
// Inline in a parameter position, which is the most common by far.
function sortWith(values: number[], compare: (a: number, b: number) => number): number[] {
return [...values].sort(compare);
}
// As a property of an interface, when the function is part of a bigger shape.
interface Formatter {
format: (value: number) => string;
precision: number;
}
const ascending: Comparator = (a, b) => a - b;
const descending: Comparator = (a, b) => b - a;
console.log(sortWith([3, 1, 2], ascending));
console.log(sortWith([3, 1, 2], descending));
const currency: Formatter = {
format: (value) => `£${value.toFixed(2)}`,
precision: 2,
};
console.log(currency.format(9.5));
Output
Optional and default parameters
// Optional: may be omitted, and is then undefined inside the function.
function greet(name: string, title?: string): string {
return title ? `${title} ${name}` : name;
}
// Default: may be omitted, and is then the default. Note there is no `?` and
// no `| undefined` — the parameter type is just string inside the body.
function shout(name: string, punctuation: string = '!'): string {
return `${name.toUpperCase()}${punctuation}`;
}
console.log(greet('Lovelace'));
console.log(greet('Lovelace', 'Ada'));
console.log(shout('hello'));
console.log(shout('hello', '?'));
Output
// A required parameter cannot follow an optional one, because there would be
// no way to supply the second without the first.
function greet(title?: string, name: string): string {
return `${title} ${name}`;
}
console.log(greet('Ada', 'Lovelace'));
What the compiler says
error TS1016: A required parameter cannot follow an optional parameter.
Rest parameters
function total(...values: number[]): number {
return values.reduce((sum, n) => sum + n, 0);
}
console.log(total());
console.log(total(1, 2, 3));
console.log(total(...[4, 5, 6]));
// Mixed: a required first argument, then any number more.
function join(separator: string, ...parts: string[]): string {
return parts.join(separator);
}
console.log(join('-', 'a', 'b', 'c'));
console.log(join('-'));
Output
The first hole: parameter counts
Here is the first place TypeScript is deliberately looser than you might expect. A function that takes fewer parameters is assignable where one taking more is expected.
function each(items: string[], visit: (item: string, index: number) => void): void {
items.forEach((item, i) => visit(item, i));
}
// Uses both parameters.
each(['a', 'b'], (item, index) => console.log(index, item));
// Ignores the index entirely, and is accepted. This is not a bug in the
// checker; it is why `arr.map(x => x * 2)` does not force you to write
// `(x, i, arr) =>` every time.
each(['c', 'd'], (item) => console.log(item));
// Even a function taking nothing at all is fine.
each(['e'], () => console.log('called'));
Output
function each(items: string[], visit: (item: string) => void): void {
items.forEach((item) => visit(item));
}
// `each` only ever passes one argument, so a callback demanding two would
// receive undefined for the second and be wrong about it.
each(['a'], (item: string, index: number) => console.log(index, item));
What the compiler says
error TS2345: Argument of type '(item: string, index: number) => void' is not assignable to parameter of type '(item: string) => void'.
The second hole: parameter types
The other unsoundness is subtler and worth understanding because it explains a whole family of confusing errors elsewhere. When comparing two function types, TypeScript checks parameters bivariantly by default — meaning it accepts them whether the parameter type is wider or narrower than required. Only one of those directions is actually safe.
A handler declared to accept Animal is being given a function that accepts only Dog. That function will read .breed, and an ordinary Animal has none. Under this course's settings the compiler catches it.
class Animal {
constructor(public name: string) {}
}
class Dog extends Animal {
constructor(name: string, public breed: string) { super(name); }
}
type AnimalHandler = (animal: Animal) => string;
// Takes a Dog, which is NARROWER than Animal. Anything holding an
// AnimalHandler is entitled to pass a plain Animal, so this is unsafe.
const dogHandler: AnimalHandler = (dog: Dog) => `${dog.name} is a ${dog.breed}`;
console.log(dogHandler(new Animal('Generic')));
What the compiler says
error TS2322: Type '(dog: Dog) => string' is not assignable to type 'AnimalHandler'.
class Animal {
constructor(public name: string) {}
}
class Dog extends Animal {
constructor(name: string, public breed: string) { super(name); }
}
// Method syntax. Checked bivariantly, so a narrower parameter is accepted.
interface LooseHandler {
handle(animal: Animal): string;
}
const loose: LooseHandler = {
handle(dog: Dog) { return `${dog.name} is a ${dog.breed}`; },
};
// With a real Dog it works.
console.log(loose.handle(new Dog('Rex', 'collie')));
// With a plain Animal it reads .breed on something that has none. The types
// said this was fine; the answer is wrong and nothing warned you.
console.log(loose.handle(new Animal('Generic')));
console.log('');
console.log('Same code with property syntax would not have compiled.');
Output
Overloads
Sometimes a function genuinely behaves differently depending on what it is given, and a union return type would force every caller to narrow. Overloads let you declare several specific signatures backed by one implementation.
// The signatures callers see.
function parse(input: string): string[];
function parse(input: string, asNumbers: true): number[];
// The implementation. It must be compatible with every signature above, and
// it is NOT itself callable — callers only see the two declarations.
function parse(input: string, asNumbers?: boolean): string[] | number[] {
const parts = input.split(',').map((p) => p.trim());
return asNumbers === true ? parts.map(Number) : parts;
}
// Each call gets a precise return type, with no narrowing needed.
const words = parse('a, b, c');
const numbers = parse('1, 2, 3', true);
console.log(words.map((w) => w.toUpperCase()));
console.log(numbers.reduce((a, b) => a + b, 0));
Output
this in functions
interface Counter {
count: number;
increment(this: Counter, by: number): number;
}
const counter: Counter = {
count: 0,
// The first parameter named `this` is not a real parameter — it is erased,
// and it tells the compiler what the function expects to be called on.
increment(this: Counter, by: number): number {
this.count += by;
return this.count;
},
};
console.log(counter.increment(1));
console.log(counter.increment(5));
// Arrow functions have no `this` of their own, which is usually what you want
// inside a method and is the reason they are preferred for callbacks.
const timer = {
label: 'build',
report(): string {
const inner = () => `${this.label} done`;
return inner();
},
};
console.log(timer.report());
Output
interface Counter {
count: number;
increment(this: Counter): number;
}
const counter: Counter = {
count: 0,
increment(this: Counter): number {
return ++this.count;
},
};
// Pulling the method off the object loses the receiver, and the `this`
// parameter is what lets the compiler notice.
const detached = counter.increment;
console.log(detached());
What the compiler says
error TS2684: The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'.
Async functions
// Annotate the resolved type; the Promise wrapper is part of the signature.
async function fetchTitle(id: number): Promise<string> {
// Pretend this is a network call.
await Promise.resolve();
return `Document ${id}`;
}
// Inference works here too: this is Promise<number> without being told.
async function count() {
await Promise.resolve();
return 42;
}
async function main(): Promise<void> {
console.log(await fetchTitle(7));
console.log(await count());
// Promise.all preserves the tuple of types, which is genuinely clever.
const [title, n] = await Promise.all([fetchTitle(1), count()]);
console.log(title, n);
}
await main();
Output
// Async functions always return a Promise, so annotating a bare string is
// a contradiction and the compiler says so directly.
async function fetchTitle(): string {
return 'Document';
}
console.log(fetchTitle());
What the compiler says
error TS1064: The return type of an async function or method must be the global Promise<T> type. Did you mean to write 'Promise<string>'?
void, and the thing about it that surprises people
const results: string[] = [];
// `visit` is declared as returning void.
function each(items: string[], visit: (item: string) => void): void {
for (const item of items) visit(item);
}
// This callback returns a string. It is accepted, because a void return type
// on a callback means "I will ignore whatever comes back", not "you must
// return nothing". Without this rule, `items.forEach(x => arr.push(x))` would
// be an error, since push returns a number.
each(['a', 'b'], (item) => results.push(item));
console.log(results);
console.log('push returned values, and each() discarded them.');
Output
function log(message: string): void {
console.log(message);
return message.length;
}
log('hello');
What the compiler says
error TS2322: Type 'number' is not assignable to type 'void'.
Putting it together
interface RetryOptions {
attempts?: number;
delayMs?: number;
onRetry?: (attempt: number, error: Error) => void;
}
async function withRetry<T>(
operation: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const attempts = options.attempts ?? 3;
const delayMs = options.delayMs ?? 0;
let lastError: Error = new Error('no attempts made');
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
options.onRetry?.(attempt, lastError);
if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
}
}
throw lastError;
}
let calls = 0;
const flaky = async (): Promise<string> => {
calls++;
if (calls < 3) throw new Error(`attempt ${calls} failed`);
return `succeeded on attempt ${calls}`;
};
console.log(
await withRetry(flaky, {
attempts: 5,
onRetry: (attempt, error) => console.log(` retrying after: ${error.message}`),
}),
);
What this compiles to
async function withRetry(operation, options = {}) {
const attempts = options.attempts ?? 3;
const delayMs = options.delayMs ?? 0;
let lastError = new Error('no attempts made');
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await operation();
}
catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
options.onRetry?.(attempt, lastError);
if (delayMs > 0)
await new Promise((r) => setTimeout(r, delayMs));
}
}
throw lastError;
}
let calls = 0;
const flaky = async () => {
calls++;
if (calls < 3)
throw new Error(`attempt ${calls} failed`);
return `succeeded on attempt ${calls}`;
};
console.log(await withRetry(flaky, {
attempts: 5,
onRetry: (attempt, error) => console.log(` retrying after: ${error.message}`),
}));
Output
Your turn
- Write a function
clamp(value, min, max)with all three parameters typed and a defaultedminof0. Confirm that inside the bodyminisnumberand notnumber | undefined. - Write a
type Predicatefor a function taking a string and returning a boolean, then write two values of that type without annotating their parameters. - Write a function taking a callback of type
(item: string, index: number) => void, then call it with a callback that takes no parameters at all. Confirm it is allowed and explain to yourself why that is safe. - Write the same function and try to pass a callback taking three parameters. Read the error.
- Write an
asyncfunction annotated as returningstringrather thanPromise<string>, note the error code, then fix it. - Declare an interface with a method using method syntax and another using property syntax. Assign a function with a narrower parameter type to each. Only one should be rejected.
Recap
- Parameters must be annotated; return types are inferred, and are worth annotating on anything exported.
- Annotate a function as a whole and its parameters get typed from the context, which is tidier than annotating each one.
- A default parameter gives you the plain type in the body; an optional one gives you
| undefinedand a check. Prefer the default when a sensible one exists. - TypeScript accepts callbacks that take fewer parameters than expected, which is safe and is why
map(x => ...)works. - Parameter types are checked bivariantly for method syntax and contravariantly for property syntax. That inconsistency exists for compatibility, and property syntax is the stricter choice.
- A
voidcallback type means the caller discards the result, not that the function must return nothing.