Typing callbacks
Callbacks are everywhere in JavaScript—event handlers, promise chains, array methods, custom async logic—but most developers type them poorly or not at all. When you understand how to write callback types that work with TypeScript's type system instead of against it, you gain confidence, catch bugs earlier, and make refactoring safer. Today we're looking at how to type callbacks in ways that actually help.
Why callback typing matters
I've seen callbacks typed as Function, any, or left completely untyped. The result is always the same: bugs slip through, intellisense fails, and refactoring becomes terrifying. A well-typed callback makes the contract explicit. The calling code knows exactly what arguments it receives, what it should return, and what happens if it breaks that contract.
Consider a simple handler function passed to an event emitter. Without proper types, you're guessing at the parameters. With types, TypeScript catches mistakes before runtime.
Type parameters and return values
The foundation of callback typing is getting the parameter and return types right. A callback type is just a function signature—parameters in, return type out. TypeScript enforces both ends.
Let's say you're building a custom array processor:
// Without callback types—this is what bad looks like
function processArray(arr: number[], cb) {
for (let i = 0; i < arr.length; i++) {
cb(arr[i], i, arr);
}
}
// Calling code has no idea what parameters cb expects
processArray([1, 2, 3], (val) => {
// What is val? What other parameters exist?
console.log(val);
});Now with proper typing:
// Callback type explicitly defined
type ArrayProcessor = (value: number, index: number, array: number[]) => void;
function processArray(arr: number[], cb: ArrayProcessor) {
for (let i = 0; i < arr.length; i++) {
cb(arr[i], i, arr);
}
}
// IDE knows exactly what parameters are available
processArray([1, 2, 3], (val, idx, arr) => {
console.log(`Value ${val} at index ${idx}`);
});This works because TypeScript infers parameter types from the callback type definition. You don't have to annotate every parameter—the type flows in from the function signature.
The return type matters equally. If your callback is supposed to return a boolean to indicate success, the type enforces that:
type Validator = (input: string) => boolean;
function validateInput(value: string, validator: Validator): boolean {
return validator(value);
}
// TypeScript catches this—callback must return boolean, not string
validateInput("test", (v) => {
return "valid"; // Error: Type 'string' is not assignable to type 'boolean'
});Void return types accept any return
This is where TypeScript's callback behavior surprises most people. When a callback type specifies void as the return type, it accepts functions that return anything. This is intentional and useful in event handlers and fire-and-forget scenarios.
Why? Because in real code, you often pass a function that returns something (like an array method result) to a callback that ignores that return value. Forcing strict void compliance would create friction:
type EventHandler = (event: Event) => void;
// This is valid even though map returns an array
document.addEventListener('click', (e) => {
[1, 2, 3].map((n) => n * 2); // Functions with non-void returns are OK
});
// But this still works
const handler: EventHandler = (e) => {
console.log('Clicked');
// Implicitly returns undefined
};The practical advantage: you can pass methods that have return values to void callbacks without type errors. But be careful—if your handler needs to return something meaningful to the caller, use a specific return type instead:
type Filter = (item: number) => boolean;
function findFirst(arr: number[], predicate: Filter): number | undefined {
for (const item of arr) {
if (predicate(item)) {
return item;
}
}
return undefined;
}
// Return type matters here—TypeScript enforces it
const result = findFirst([1, 2, 3, 4], (n) => n > 2); // Must return booleanAvoid Function and any
I want to be direct: typing a callback as Function or any defeats the entire purpose of TypeScript. You've disabled type checking, given up on intellisense, and are back to guessing what parameters exist.
// Don't do this
function setup(cb: Function) {
cb("hello", 42, true); // Caller has no idea what cb expects
}
function execute(cb: any) {
cb.someMethod(); // What methods exist? Who knows.
}
// This is what you lose:
setup((x) => {
// x is typed as 'any'—no intellisense, no type safety
x.toUpperCase(); // Might work, might crash
});Instead, name the callback type and make the contract explicit. It takes 10 seconds and saves hours of debugging:
// Much better
type MessageHandler = (message: string, code: number, success: boolean) => void;
function setup(cb: MessageHandler) {
cb("hello", 42, true);
}
setup((msg, code, ok) => {
// All three parameters are properly typed
console.log(`${msg} (${code}): ${ok}`);
});There are rare cases where you genuinely don't know the callback signature—genuinely rare. In those cases, make it explicit with (...args: unknown[]) => unknown rather than hiding behind any. At least you're being honest about the uncertainty.
Name complex callback types
When a callback has multiple parameters, multiple return type branches, or reappears in several functions, give it a name. This improves readability and makes refactoring safer because changes to the type ripple through all its uses.
In a real e-commerce platform, I typed all async callbacks with clear names:
// Clear, reusable callback types
type AsyncSuccessHandler = (data: T) => void;
type AsyncErrorHandler = (error: Error) => void;
type AsyncCallback = {
onSuccess: AsyncSuccessHandler;
onError: AsyncErrorHandler;
};
// Now these handlers can be used consistently across the codebase
function fetchProduct(id: string, callbacks: AsyncCallback) {
productService.get(id)
.then(callbacks.onSuccess)
.catch(callbacks.onError);
}
function fetchOrder(id: string, callbacks: AsyncCallback) {
orderService.get(id)
.then(callbacks.onSuccess)
.catch(callbacks.onError);
}Named types also serve as documentation. Someone reading the code immediately understands that AsyncCallback has both success and error handlers.
For callbacks with optional parameters, be explicit:
type Logger = (message: string, level?: 'info' | 'warn' | 'error') => void;
function configureLogger(log: Logger) {
log("Starting", "info");
log("Warning detected", "warn");
log("Oops"); // level is optional
}
configureLogger((msg, level = 'info') => {
console.log(`[${level}] ${msg}`);
});Common mistakes
- Typing callbacks as
Functionorany—strips all type information and defeats the purpose. Always provide the full signature. - Forgetting to name complex callbacks—inline types for simple one-off callbacks are fine, but if it appears twice, give it a name and reuse it.
- Assuming parameters are required when they should be optional—if a callback might be called without an argument, mark it optional. TypeScript will catch mismatches.
- Incorrectly using
voidwhen you need a specific return type—remember thatvoidcallbacks accept any return, which is fine for event handlers but wrong for filters or reducers. - Not considering generic types when callbacks need to work with multiple data types—use generics to make callback types reusable across different contexts.
Key takeaways
- Always type callback parameters and return types explicitly. TypeScript infers parameter types from the callback definition, saving you from manual annotation.
voidreturn types accept functions that return anything—useful for event handlers, but use specific return types when the result matters.- Never type callbacks as
Functionorany. You lose type safety and intellisense. Name the type instead. - For complex or reused callbacks, define a named type. It improves readability, enables consistency, and makes refactoring safer.
- Use generics in callback types when they need to work with different data types across your codebase.






Comments
Loading comments…