TypeScript Function Types
A TypeScript function type describes what a function takes and what it hands back, and says nothing about how it works. You write it as (value: number) => string. Once you read that line fluently, a big chunk of the type system stops being mysterious — callbacks, event handlers, higher-order functions and most of the built-in utility types are built on top of it.
In this article we will cover the syntax, the two directions you can annotate from, call signatures, overloads, generic signatures, and the two assignability rules that surprise nearly everyone: why a void return accepts a function that returns something, and why parameters are checked backwards.
Writing a TypeScript function type
The syntax is a parameter list, an arrow, and a return type:
type Formatter = (value: number, digits: number) => string
const toFixedString: Formatter = (value, digits) => value.toFixed(digits)
function runFormatter(fmt: Formatter, n: number): string {
return fmt(n, 2)
}
console.log(runFormatter(toFixedString, 3.14159)) // "3.14"Two details are easy to miss. Parameter names are mandatory — (string) => number does not mean "takes a string", it means "takes a parameter named string", and TypeScript rejects it with Parameter has a name but no type. And those names are documentation only. Rename every parameter in a function type and assignability does not change; only the positions and the types matter.
The arrow here is also not the arrow of an arrow function. In a type position it separates parameters from the return type, and the return type is never optional — there is no inference inside a function type expression.
Two directions: annotate the variable, or annotate the parameter
You almost never annotate both sides. Either you name the function type and let the implementation's parameters be inferred, or you write the parameter types inline and skip the named type:
type Comparator = (a: string, b: string) => number
// Direction 1: annotate the variable, parameters are inferred
const byLength: Comparator = (a, b) => a.length - b.length
// Direction 2: annotate the parameters, no named type in sight
function sortStrings(items: string[], compare: (a: string, b: string) => number): string[] {
return [...items].sort(compare)
}
sortStrings(['bbb', 'a', 'cc'], byLength)
sortStrings(['bbb', 'a', 'cc'], (a, b) => a.localeCompare(b))byLength never says a: string and it does not need to. The annotation on the variable flows down into the arrow function — this is contextual typing, and it is why callbacks passed straight into a function call rarely need annotations either. In the last line, a and b are known to be strings because sortStrings already said so.
If you find yourself writing const byLength: Comparator = (a: string, b: string) => ..., delete the inner annotations. They are noise, and they can only ever disagree with the type you already declared.
Call signatures let a function carry properties
A function type expression can only describe a plain callable. When a function also has properties — a logger with a level, a middleware with a name — you need an object type with a call signature: the same parameter list and return type, written inside braces with a : instead of =>.
interface Logger {
(message: string): void
level: 'debug' | 'info' | 'error'
}
const log: Logger = Object.assign(
(message: string) => console.log(`[${log.level}] ${message}`),
{ level: 'info' as const },
)
log('server started')
log.level = 'error'Object.assign is the usual way to build one of these, because it produces a value that is genuinely both a function and an object. An interface with a call signature is also how you compose callables with intersection types — Logger & Disposable is a function you can call and also clean up.
Objects whose values are all functions are common enough to deserve their own tool. When you need to derive one shape of handlers from another — every key of a state object turned into a setter, say — that is a job for mapped types rather than writing each signature by hand.
Optional, default, and rest parameters
Function types carry the same parameter modifiers as declarations. Optional parameters get a ?, and rest parameters get an array type:
type RequestFn = (url: string, retries?: number, ...tags: string[]) => Promise<string>
const fetchWithRetry: RequestFn = async (url, retries = 3, ...tags) => {
console.log(url, retries, tags)
return 'ok'
}
fetchWithRetry('/users')
fetchWithRetry('/users', 5)
fetchWithRetry('/users', 5, 'critical', 'api')Note where the default value lives. Defaults belong to the implementation, not the type — retries?: number in the type, retries = 3 in the function. A default is a runtime concern, so there is nowhere to put it in a type, and the ? is what tells callers they may leave it out.
Overloads, and when a union beats them
Overloads describe a function whose return type depends on which argument types it received. You write the signatures first, then one implementation that covers all of them:
function parseInput(value: string): string[]
function parseInput(value: number): number[]
function parseInput(value: string | number): string[] | number[] {
return typeof value === 'string' ? value.split(',') : [value]
}
const words = parseInput('a,b,c') // string[]
const nums = parseInput(42) // number[]The implementation signature is not callable from outside — only the two overloads above it are. That is the whole point: parseInput('a') is known to be string[], not string[] | number[], so callers never have to narrow the result.
Reach for overloads only when that input-to-output link actually exists. If the return type is the same no matter what, a union type parameter or an optional parameter is simpler and easier to read:
type Pad = (input: string, width: number, filler?: string) => string
const padStart: Pad = (input, width, filler = ' ') => input.padStart(width, filler)
console.log(padStart('7', 3, '0')) // "007"Two overloads that differ only in arity are almost always an optional parameter wearing a disguise.
Generic function types
A generic function type puts its type parameters immediately before the parameter list. This is the piece people get wrong, because there are two different things you might want:
type Mapper = <T, U>(items: T[], fn: (item: T) => U) => U[]
const mapAll: Mapper = (items, fn) => items.map(fn)
const lengths = mapAll(['one', 'three'], (s) => s.length) // number[]
type Box<T> = (value: T) => { value: T }
const boxNumber: Box<number> = (value) => ({ value })Mapper is a generic function: the <T, U> sits inside the type, so T and U are chosen fresh at every call site. Box<T> is a generic type alias for a function: the <T> sits on the alias, so you fix it once when you write Box<number>, and the resulting function is not generic at all.
The rule of thumb: if the caller should pick the types, the parameters go inside the arrow. If you pick them when declaring the type, they go on the alias.
The two rules that surprise people
Everything above is syntax. These two are behaviour, and they are where the real bugs and the real confusion live.
A void return accepts a function that returns something
A function type ending in void does not mean "must return nothing". It means "I will ignore whatever you return":
type Callback = (item: string) => void
const collected: number[] = []
// ✅ push returns a number, and that is fine
const cb: Callback = (item) => collected.push(item.length)
function forEachItem(items: string[], run: Callback): void {
for (const item of items) {
run(item)
}
}
forEachItem(['a', 'bb'], cb)This is deliberate, and you rely on it constantly — items.forEach((x) => arr.push(x)) only compiles because of it. The catch is that the permission is one-directional. The caller of a void-returning type gets void back and cannot use the value, even though a value is really there. If you need the result, say so in the type; void is a promise that you will throw it away.
Parameters are checked backwards
Return types behave the way you would guess: a function returning Circle is assignable where one returning Shape is expected. Parameters go the other way. A function that accepts less specific arguments can stand in for one that accepts more specific ones:
interface Shape {
kind: string
}
interface Circle extends Shape {
radius: number
}
type ShapeFn = (s: Shape) => void
type CircleFn = (c: Circle) => void
declare const drawShape: ShapeFn
// ✅ safe: something handling any Shape can handle a Circle
const drawCircle: CircleFn = drawShape
// ❌ Type 'CircleFn' is not assignable to type 'ShapeFn'
// const drawAnyShape: ShapeFn = drawCircleRead the failing line as a caller would. Anyone holding a ShapeFn may pass a bare Shape — no radius — and drawCircle would read a property that is not there. Rejecting it is correct. This is contravariance, and it applies because strictFunctionTypes is on, which it is under strict.
There is one exception worth knowing, because it looks like an inconsistency:
interface Painter {
paint(c: Circle): void
}
declare const painter: Painter
// ✅ allowed — methods are still checked bivariantly
const loose: { paint(s: Shape): void } = painterWritten as a method (paint(c: Circle): void) the parameter is checked bivariantly and the unsound direction is permitted. Written as a property with a function type (paint: (c: Circle) => void) it is checked strictly and the same assignment fails. The reason is compatibility: Array<T> and friends declare methods, and making them contravariant would break enormous amounts of working code. Keep it in mind when a variance error appears or fails to appear where you did not expect it.
Capturing the type of a function you already have
Writing function types by hand is often unnecessary. If the function exists, derive its type from it with typeof and pull the pieces apart with Parameters and ReturnType:
function createSession(userId: string, ttlSeconds: number) {
return { userId, expiresAt: Date.now() + ttlSeconds * 1000 }
}
type CreateSession = typeof createSession
type SessionArgs = Parameters<typeof createSession> // [userId: string, ttlSeconds: number]
type Session = ReturnType<typeof createSession> // { userId: string; expiresAt: number }
const fakeCreateSession: CreateSession = (userId, ttlSeconds) => ({
userId,
expiresAt: ttlSeconds,
})
const args: SessionArgs = ['u_1', 60]
const session: Session = fakeCreateSession(...args)Nothing here declares a signature twice. createSession is the source of truth, and the mock, the argument tuple and the result type all follow from it. This is the standard approach for test doubles, wrappers and decorators, where the whole requirement is "match this existing function exactly".
Skip the Function type
TypeScript has a built-in Function type. Avoid it. It accepts any number of arguments of any type and returns any, so it switches off checking at exactly the point you were trying to add some:
declare function registerLegacy(cb: Function): void
const looseHandler: Function = (id: string) => id.toUpperCase()
looseHandler(1, 2, 3, 'anything') // ✅ compiles, returns anyIf you truly do not care about the signature, (...args: never[]) => unknown says "some function" without handing back any. Usually, though, you do know the shape — write it down.
Where to practice
Function types get much easier once you have taken one apart yourself:
- Get Return Type – implement
ReturnType<T>and see howinferreaches into a function type - Type Lookup – write generic signatures that narrow on a key
The takeaway
A function type is a parameter list, an arrow and a return type, and most of the work is deciding where to put it: on the variable and let the implementation infer, or on the parameter and let the call site infer. Use a call signature when the function also carries properties, overloads only when the return type genuinely depends on the input, and type parameters inside the arrow when the caller should choose them.
Then remember the two rules that are not guessable. void means "your return value is ignored", not "return nothing". And parameters are checked in the opposite direction from return types, except for methods, which stay bivariant for backward compatibility. Those two account for most of the function-type errors that look wrong at first glance — and once they click, they stop being surprising.
Become a TypeScript Pro
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges