Typing Functions and Callbacks in TypeScript
TypeScript function types describe the arguments a function accepts and the value it returns. The same syntax lets you describe callbacks passed to other functions. Once a function type is known, TypeScript can often infer its callback parameters for you.
The function types and generics overview shows how these signatures connect to reusable helpers.
How Do You Type Function Parameters and Returns in TypeScript?
TypeScript parameter annotations go after each parameter name, and the return annotation follows the closing parenthesis. Standalone parameters without defaults usually need annotations:
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, but 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.
How Does TypeScript Infer Callback Parameter Types?
TypeScript infers callback parameter types from the surrounding function type. This is called contextual typing. In an array’s map callback, for example, the array supplies the item type:
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. Position makes the difference. 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.
Why Can a TypeScript void Callback Return a Value?
A TypeScript callback assigned to a void-returning function type may return a value because the caller agrees to ignore 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.
Use generics when a helper needs to preserve the caller’s input type. To derive parameters or a return type from an existing function, use the built-in Parameters and ReturnType utilities.
Frequently Asked Questions
Why can TypeScript infer a callback parameter without an annotation?
TypeScript can infer a callback parameter when the surrounding expression supplies its function type. This is called contextual typing. Moving that callback into a standalone variable can remove the context, leaving its parameters in need of annotations.
Should TypeScript functions have explicit return types?
Explicit return types are usually helpful on exported TypeScript functions because they prevent an implementation change from silently changing the public return type. 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 in TypeScript?
In TypeScript, a function typed to return undefined must produce undefined, while a void callback type means its caller ignores the result. A function that returns a string is therefore assignable to a void callback type. That rule exists so callbacks such as array forEach accept functions that happen to return something.
Can a TypeScript callback declare fewer parameters than its type?
A TypeScript callback can declare fewer parameters than its expected function type because JavaScript functions can ignore extra arguments. 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]
More on Functions(typescriptlang.org)
-
[2]
Type Inference(typescriptlang.org)
-
[3]
TSConfig Reference(typescriptlang.org)
Read Next
Type parameters explained without the theory: what a generic buys you, how inference fills it in at the call site, constraints with extends, generic types, and defaults.
Pick, Omit, Partial, Required, Record, Readonly, ReturnType, and Awaited: what each one really does, where the checking is looser than it looks, and when to name the result.
Reusable types in TypeScript: function and callback types, type parameters with constraints and defaults, and the built-in utility types that derive one shape from another.