Typing Functions and Callbacks in TypeScript

Published Updated

Functions are where types stop describing data and start describing behaviour. A function type records what a caller must hand over, what it gets back, and what a helper promises to call later. Get these right and most of a codebase types itself, because almost everything else is a value flowing through a function.

This guide covers parameter and return annotations, the optional and rest forms, naming a function type so callbacks stay readable, and the contextual typing that quietly removes most annotations from your day.

Parameters and Return Types

A function annotation has two halves: a type per parameter, and a return type after the parameter list. Parameters are the half you almost always write yourself:

function formatPrice(cents: number, currency: string): string {
  return `${currency}${(cents / 100).toFixed(2)}`;
}

console.log(formatPrice(1999, "$"));
// prints: $19.99

formatPrice("1999", "$");
// tsc output:
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.

The parameters need annotations because a standalone function declaration has no surrounding context to infer them from, and under noImplicitAny, which strict mode enables, leaving them off is an error rather than a silent any.

The return type is different: TypeScript infers it from the body, so : string above is a choice rather than a requirement. It is a useful choice on exported functions, because it pins the contract. Without it, someone editing the body to return null in one branch changes the public type of the function and nothing complains until a distant call site breaks. With it, the error lands on the line that broke the promise.

Async functions follow the same rule with one wrinkle: the annotation describes the promise, not the value. A function declared Promise<User> may return user directly, because an async function wraps its result.

Optional, Default, and Rest Parameters

Three markers change how many arguments a caller may pass, and they behave differently inside the body:

function buildQuery(
  table: string,
  limit = 20,
  order?: "asc" | "desc",
  ...columns: string[]
): string {
  const direction = order ?? "asc";
  const selected = columns.length > 0 ? columns.join(", ") : "*";

  return `SELECT ${selected} FROM ${table} ORDER BY id ${direction} LIMIT ${limit}`;
}

console.log(buildQuery("articles"));
// prints: SELECT * FROM articles ORDER BY id asc LIMIT 20

limit = 20 is a default, so its type is inferred as number and inside the body it is never undefined. order? is optional, so under strictNullChecks its type inside the body includes undefined and the compiler makes you handle that before using it, which the ?? does here. The rest parameter ...columns collects everything left over into an array, so it must come last.

Ordering matters for the optional form: TypeScript rejects a required parameter written after an optional one. JavaScript can still skip a position by passing undefined, so type the earlier parameter "asc" | "desc" | undefined, or give it a default, when a required one has to follow. A default is not bound by the ordering rule.

Naming a Function Type

Once a function is passed around rather than called directly, its type deserves a name. The arrow form reads closest to the function it describes:

type Logger = (message: string, context?: Record<string, unknown>) => void;

const consoleLogger: Logger = (message, context) => {
  console.log(message, context ?? {});
};

function saveArticle(id: string, log: Logger): void {
  log("Saving article", { id });
}

saveArticle("a1", consoleLogger);
// prints: Saving article { id: 'a1' }

Naming the type does two jobs. Every helper that takes a logger now shares one definition, so changing the signature updates all of them at once. And consoleLogger needed no parameter annotations, because the Logger annotation on the variable supplied them.

An interface with a call signature expresses the same thing, and is the form to reach for when the function also carries properties, such as a handler with a displayName. For plain callbacks the arrow form is shorter and more common.

Callbacks and Contextual Typing

Contextual typing is the rule that removes most callback annotations. When a function literal is written directly in a position whose type is already known, TypeScript reads the parameter types from that position:

const cents = [1999, 4500, 250];

const labels = cents.map((amount) => formatPrice(amount, "$"));
// amount is number, inferred from the array's element type

const detached = (amount) => formatPrice(amount, "$");
// tsc output for the last line:
// error TS7006: Parameter 'amount' implicitly has an 'any' type.

The difference is position, not syntax. Inside map, the callback sits in a parameter whose type is known, so amount is number without being told. Pulled out to a standalone const with no annotation, there is no context left and noImplicitAny reports it. Annotating the variable as (amount: number) => string, or adding the annotation to the parameter, restores it.

This is why annotating callback parameters is usually redundant, and occasionally harmful: an annotation that disagrees with the context is an error, and one that merely repeats it goes stale when the surrounding type changes.

Callbacks That Ignore Their Result

A callback type ending in void means the caller ignores the return value, and TypeScript is deliberately lenient about it:

type Visitor = (id: string) => void;

const ids: string[] = [];

const collect: Visitor = (id) => ids.push(id);

collect("a1");
console.log(ids);
// prints: [ 'a1' ]

Array.prototype.push returns a number, yet the assignment is accepted. The rule is that a function returning something is assignable to a function type returning void, because the target has promised not to look at the result. Without it, one-line arrow callbacks would fail constantly.

The leniency has an edge. If your helper actually does use what a callback returns, do not type it void: say boolean or whatever you consume, or the compiler will accept callbacks that return the wrong thing. Note also that void here describes the callback contract; a function whose own declared return type is void still cannot return a value from its body.

Pitfalls and Debugging

Parameter implicitly has an any type. Almost always a function literal that lost its context: extracted to a variable, stored in an untyped object, or passed through a parameter typed as Function or any. Fix the context where you can, since annotating the parameter treats the symptom.

An async function whose return type is not a promise. Declaring function load(): User with async is an error: the declared type must be the global Promise<User>, and even PromiseLike<User> is rejected with TS1064. The mirror-image mistake is forgetting await at the call site and passing a promise where the value was expected.

Method syntax and function-property syntax check differently. Under strictFunctionTypes, which strict mode enables, a function-typed property is checked contravariantly in its parameters, while a method declared with method shorthand keeps the older bivariant check. If a callback assignment is accepted in one form and rejected in the other, this is usually why.

Optional parameters versus optional arguments. Marking a parameter ? makes it optional for callers, and under strictNullChecks its body type includes undefined. If the body never handles that case, add a default instead, which keeps the call site loose and the body narrow.

Frequently Asked Questions

Why does a callback parameter need no annotation?

Because the position it sits in already has a type. When you pass a function literal directly to a parameter whose type is a function type, TypeScript reads the parameter types from that target and applies them, which is called contextual typing. Extract the same literal to a standalone const with no annotation and the context is gone, so noImplicitAny reports it.

Should you write explicit return types?

On exported functions, usually yes: the annotation locks the public contract so an edit inside the body cannot change it silently. On small local helpers and inline callbacks, inference is normally clearer than a repeated annotation. Teams that want the rule enforced use a lint rule rather than a compiler flag, since TypeScript has no strict setting that requires return annotations.

What's the difference between void and undefined?

A function typed to return undefined must actually produce undefined. A function type returning void says the caller should ignore whatever comes back, so a function that returns a string is still assignable to it. That rule exists so callbacks such as array forEach accept functions that happen to return something.

Can a function take fewer arguments than its type declares?

Yes. A function of fewer parameters is assignable to a type expecting more, because ignoring extra arguments is safe in JavaScript. That is why passing an item-only callback to map works even though map calls it with the item, the index, and the array. The reverse is rejected: a function needing more parameters than the type supplies is an error.

Sources

  1. [1]
    More on Functions
    (typescriptlang.org)
  2. [2]
    Type Inference
    (typescriptlang.org)
  3. [3]
    TSConfig Reference
    (typescriptlang.org)