Skip to content

Functions

A function type describes parameters and a return type. You can write it inline or as a named type:

type BinaryOp = (a: number, b: number) => number;
const add: BinaryOp = (a, b) => a + b;
// a and b are inferred as number from the context — no annotations needed

Parameters support optional (?), default values, and rest:

function make(name: string, count = 1, ...tags: string[]): void {
// count defaults to 1; tags collects the rest into string[]
}

An optional parameter x?: number is number | undefined; a defaulted parameter count = 1 is simply number from the callee’s view, but callers may omit it.

Overloads — and why a union is often better

Section titled “Overloads — and why a union is often better”

Overloads let one function present multiple call signatures. You write several signature lines followed by one implementation:

function len(x: string): number;
function len(x: unknown[]): number;
function len(x: string | unknown[]): number {
return x.length;
}
len("hi"); // ✅ 2
len([1, 2, 3]); // ✅ 3

Overloads are powerful but easy to overuse. When the signatures differ only in a parameter type — not in the relationship between parameters and return — a single union parameter is simpler and clearer:

// Simpler than two overloads:
function len(x: string | unknown[]): number {
return x.length;
}

Reach for real overloads only when the return type genuinely depends on which input shape you got in a way a union can’t express — which is exactly the case a union parameter can’t reproduce:

function makeElement(tag: "a"): HTMLAnchorElement;
function makeElement(tag: "img"): HTMLImageElement;
function makeElement(tag: string): HTMLElement {
return document.createElement(tag);
}
const link = makeElement("a");
link.href = "https://example.com"; // ✅ only exists on HTMLAnchorElement -- overloads know it's that specific type
// @expect-error TS2339
function makeElementUnion(tag: "a" | "img"): HTMLAnchorElement | HTMLImageElement {
return document.createElement(tag);
}
const linkU = makeElementUnion("a");
linkU.href = "https://example.com"; // ❌ still a union -- the compiler can't tell which member you got
// Property 'href' does not exist on type 'HTMLAnchorElement | HTMLImageElement'.

The union version loses the link between which literal you passed and which member you got back — the return type is always the full union, even though you know at the call site it must be HTMLAnchorElement. Overloads exist for exactly this: correlating one specific input with one specific output, which no single union signature can express.

Parameter bivariance and strictFunctionTypes

Section titled “Parameter bivariance and strictFunctionTypes”

Function parameters are normally contravariant: a function that accepts a wider type is safely assignable where a function accepting a narrower type is expected (it can handle everything the narrower one could, and more). Assigning the other way around — a narrower-accepting function where a wider one is expected — should be unsound, since the caller might pass something the narrower function can’t handle.

strictFunctionTypes (part of strict) enforces exactly that contravariance rule — but only for functions written as a property of type (...) => .... Functions written with method shorthand syntax get an intentional carve-out called bivariance, kept for backward compatibility with patterns like array methods and old-style generic overriding:

interface Animal { name: string }
interface Dog extends Animal { breed: string }
interface ShelterMethod {
handle(a: Animal): void; // method shorthand syntax
}
interface ShelterProp {
handle: (a: Animal) => void; // property function syntax
}
const byMethod: ShelterMethod = {
handle(a: Dog) { console.log(a.breed); }, // narrower param -- unsound if a plain Animal shows up
};
// @expect-error TS2322
interface Animal { name: string }
interface Dog extends Animal { breed: string }
interface ShelterProp {
handle: (a: Animal) => void; // property function syntax
}
const byProp: ShelterProp = {
handle: (a: Dog) => { console.log(a.breed); }, // same narrowing, property syntax
};
// ❌ Types of parameters 'a' and 'a' are incompatible.
// Property 'breed' is missing in type 'Animal' but required in type 'Dog'.

Both assignments narrow the parameter from Animal to Dog — equally unsound, since ShelterMethod/ShelterProp promise their handle accepts any Animal, and a plain Animal has no breed. byMethod compiles clean under --strict because it’s written with method shorthand; byProp fails with TS2322 because it’s written as a property holding a function value. Disabling strictFunctionTypes (--strictFunctionTypes false) makes byProp compile too — the flag doesn’t add a new check, it just stops the carve-out from applying.

Method shorthand vs property function under strictFunctionTypes

interface Animal { name: string }
interface Dog extends Animal { breed: string }
interface ShelterMethod {
handle(a: Animal): void; // method shorthand -- bivariant, narrowing the param compiles
}
interface ShelterProp {
handle: (a: Animal) => void; // property function -- checked contravariantly under strict
}
const byMethod: ShelterMethod = {
handle(a: Dog) { console.log(a.breed); }, // compiles even though it's unsound
};
// const byProp: ShelterProp = {
// handle: (a: Dog) => { console.log(a.breed); }, // would be TS2322 under strict
// };
Open in TS Playground

Prefer method shorthand only when you actually want covariant-style overriding (common in class hierarchies with generic methods); for a plain callback property, the property-function form’s stricter check is usually what you want, since it catches a genuine unsoundness the shorthand form silently allows.

this: void: promising a callback ignores this

Section titled “this: void: promising a callback ignores this”

A callback parameter typed this: void is a promise that the function never reads this — which matters because a method that does depend on this isn’t safely assignable to that slot:

// @expect-error TS2322
interface Button { onClick: (this: void, event: string) => void }
class Widget {
label = "save";
handleClick(this: Widget, event: string) { console.log(this.label, event); }
}
const w = new Widget();
const btn: Button = { onClick: w.handleClick };
// ❌ The 'this' types of each signature are incompatible.
// Type 'void' is not assignable to type 'Widget'.

handleClick needs a real Widget bound as this to work, but onClick promises callers can invoke it with this as anything (including nothing, as a bare function reference) — which is exactly what happens when a framework calls btn.onClick(event) as a detached callback. The fix is either an arrow function (which captures this lexically and needs none supplied) or writing the handler itself with this: void and reading captured state instead of this.

In a standalone function, this can be typed with a special first parameter that disappears at the call site (it’s compile-time only):

interface Button { label: string; }
function handleClick(this: Button, event: Event): void {
console.log(this.label); // this is typed as Button
}

This catches the classic bug of a method losing its this when passed as a bare callback. It’s most useful in older this-based APIs; with arrow functions and modern patterns you’ll need it less.

The void return rule that surprises everyone

Section titled “The void return rule that surprises everyone”

A function type that returns void does not mean “the function must return nothing.” It means “the caller will ignore any return value.” So a function that does return something is still assignable to a void-returning type:

type Callback = () => void;
const cb: Callback = () => 42; // ✅ allowed! the 42 is just ignored
flowchart LR
  fn["() => number
(returns 42)"] -->|assignable to| target["() => void
(caller ignores return)"]
  target --> use["callers treat result
as void — safe"]
void means the return is ignored, not forbidden

This is exactly why array.forEach(x => arr.push(x)) type-checks even though push returns a number: forEach wants a void callback and simply ignores whatever comes back. Once you know the rule it stops being mysterious — but almost everyone hits it confused first.

What the checker catches now (TypeScript 5.7–5.8)

Section titled “What the checker catches now (TypeScript 5.7–5.8)”

Three recent checks change how functions type-check, with no new syntax:

  • Never-initialized variables (5.7). A let declared with a type but never assigned is an error even when the use sits inside a nested function.
  • Each branch of a returned conditional is checked (5.8). Previously an any on one side of cond ? a : b masked a wrong type on the other; now every branch must satisfy the declared return type.
  • Callbacks that only return null or undefined. p.catch(() => null) infers the handler’s return type from what you wrote, so the awaited result widens to T | null — not an error at the call site, but a value you must narrow before use.

Never-initialized:

// @expect-error TS2454
function report() {
let result: number; // declared, never assigned
function print() {
console.log(result); // ❌ Variable 'result' is used before being assigned
}
}

Each conditional branch checked:

// @expect-error TS2322
declare const cache: Map<any, any>;
function getUrl(s: string): URL {
return cache.has(s) ? cache.get(s) : s;
// ^ ❌ Type 'string' is not assignable to type 'URL'
}

Callback returning only null/undefined:

// @expect-error TS18047
async function getNumber(): Promise<number> {
throw new Error("boom");
}
async function run() {
const n = await getNumber().catch(() => null); // n: number | null
n.toFixed(2); // ❌ 'n' is possibly 'null'
}

And a reminder from the narrowing lesson: since 5.5 a boolean-returning function whose result is tied to its parameter gets an inferred type predicate, so many small guards no longer need x is T written out.

flowchart TD
  sig["A function's type is its promise:
what it needs in, what it needs from this, what comes out"] --> params["Parameter promise
(checked contravariantly -- but bivariantly for method shorthand)"]
  sig --> thisP["this promise
(this: void means 'never reads this')"]
  sig --> ret["Return promise
(void means 'caller may ignore it')"]
  params --> when["Union parameter: one promise for every input
Overload: a different promise per input shape"]
Every function-typing rule is a promise the compiler checks at the boundary

Every feature in this lesson is the compiler checking one side of a promise at a function’s boundary. A parameter type promises what the function can be called with; strictFunctionTypes is how strictly that promise is checked (except for the bivariant method-shorthand carve-out). A this type promises what the function reads from its receiver — this: void promises it reads nothing. A return type promises what the caller gets back — void uniquely promises the caller doesn’t care. Overloads exist because a single promise sometimes can’t capture the relationship between what came in and what comes out; that’s when you write several promises instead of one.

ChoiceBenefitCost
Union parameterOne signature, simple to read and implementCannot express “this input shape implies that specific output shape”
OverloadsCorrelates a specific input with a specific outputMore declarations to maintain; easy to overuse for cases a union would handle
Method shorthand (handle(a: T))Matches JS method syntax; allows covariant-style overriding in class hierarchiesBivariant — silently allows an unsound narrower-parameter override
Property function (handle: (a: T) => void)Checked contravariantly under strictFunctionTypes — catches real unsoundnessSlightly more ceremony; can’t be overridden the way a class method can
this: voidDocuments and enforces that a callback never depends on bindingRequires rewriting any handler that genuinely needs this
  • A method written with shorthand syntax (handle(a: T)) and a property holding an arrow function (handle: (a: T) => void) are checked identically. They aren’t — method shorthand gets the bivariant carve-out, so it accepts unsound parameter narrowing that the property form correctly rejects under strictFunctionTypes.
  • strictFunctionTypes adds a new kind of error. It doesn’t add a check — it removes an intentional exemption (bivariance) for the property-function form specifically, restoring the normal contravariant check.
  • Storing a class method as a bare callback (const fn = obj.method) is caught by the type checker if the method needs this. It generally isn’t, unless the target signature explicitly declares an incompatible this type — assigning to a plain () => void slot (which has an implicit, unconstrained this) compiles even when the method depends on a real receiver.
  • Overloads are just “old-style” generics and should always be replaced. A union parameter is simpler when the signatures only differ in input type, but overloads are still the correct tool whenever a specific input shape must promise a specific, different output shape.

Before trusting a generated function signature, check for:

  • A method-shorthand interface member (handle(a: Base): void) implemented with a narrower parameter type (handle(a: Derived) { ... }) — this compiles silently due to bivariance, even though it’s unsound; the same narrowing on a property-typed function would correctly fail.
  • A class method assigned to a plain callback type or array of callbacks (onClick: fn or handlers.push(obj.method)) without being bound (.bind(obj)) or wrapped in an arrow (() => obj.method()) — if the method reads this, this compiles and crashes at the first call.
  • Two or more overloads that could be replaced by one union parameter with no loss of precision — a sign of copy-pasted boilerplate rather than a genuine input-to-output correlation.
  • A callback parameter that should be this: void (a truly detached handler) left untyped — nothing stops a this-dependent function from being passed there instead.

🐛 Spot the bug

class Counter {
count = 0;
increment(this: Counter) { this.count++; }
}
const c = new Counter();
const handlers: Array<() => void> = [];
handlers.push(c.increment);
for (const h of handlers) h();
Show the answer

This compiles with zero errors. increment declares this: Counter, but Array<() => void> expects functions with () => void’s implicit, unconstrained this — TypeScript only checks this-compatibility when the target signature itself declares a this type, and here it doesn’t. c.increment slips into the array as a plain unbound function reference. At runtime, calling h() invokes increment with this being undefined (strict-mode class bodies don’t default this to the global object), so this.count++ throws TypeError: Cannot read properties of undefined (reading 'count'). The fix is to store a bound reference — handlers.push(c.increment.bind(c)) or handlers.push(() => c.increment()) — so the receiver travels with the function.

When should you use overloads instead of a single union parameter?
What does a this parameter (function f(this: T, ...)) do at the call site?
Is const cb: () => void = () => 42 allowed?
Why does arr.forEach(x => other.push(x)) type-check even though push returns a number?
Under --strict, which one compiles?
interface ShelterMethod {
  handle(a: Animal): void;
}
interface ShelterProp {
  handle: (a: Animal) => void;
}

const byMethod: ShelterMethod = {
  handle(a: Dog) { console.log(a.breed); },
};
const byProp: ShelterProp = {
  handle: (a: Dog) => { console.log(a.breed); },
};
Does this compile, and what happens when it runs?
class Counter {
  count = 0;
  increment(this: Counter) { this.count++; }
}
const c = new Counter();
const handlers: Array<() => void> = [];
handlers.push(c.increment);
for (const h of handlers) h();