Functions
Typing a function
Section titled “Typing a function”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 neededParameters 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"); // ✅ 2len([1, 2, 3]); // ✅ 3Overloads 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 TS2339function 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 TS2322interface 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// }; 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 TS2322interface 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.
Typing this
Section titled “Typing 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 ignoredflowchart LR fn["() => number (returns 42)"] -->|assignable to| target["() => void (caller ignores return)"] target --> use["callers treat result as void — safe"]
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
letdeclared 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
anyon one side ofcond ? a : bmasked a wrong type on the other; now every branch must satisfy the declared return type. - Callbacks that only return
nullorundefined.p.catch(() => null)infers the handler’s return type from what you wrote, so the awaited result widens toT | null— not an error at the call site, but a value you must narrow before use.
Never-initialized:
// @expect-error TS2454function 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 TS2322declare 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 TS18047async 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.
Mental Model
Section titled “Mental Model”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 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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Union parameter | One signature, simple to read and implement | Cannot express “this input shape implies that specific output shape” |
| Overloads | Correlates a specific input with a specific output | More 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 hierarchies | Bivariant — silently allows an unsound narrower-parameter override |
Property function (handle: (a: T) => void) | Checked contravariantly under strictFunctionTypes — catches real unsoundness | Slightly more ceremony; can’t be overridden the way a class method can |
this: void | Documents and enforces that a callback never depends on binding | Requires rewriting any handler that genuinely needs this |
Common Misconceptions
Section titled “Common Misconceptions”- 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 understrictFunctionTypes. strictFunctionTypesadds 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 needsthis. It generally isn’t, unless the target signature explicitly declares an incompatiblethistype — assigning to a plain() => voidslot (which has an implicit, unconstrainedthis) 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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: fnorhandlers.push(obj.method)) without being bound (.bind(obj)) or wrapped in an arrow (() => obj.method()) — if the method readsthis, 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 athis-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.