Mert Özen Further With Every Line
Frontend TypeScript Basics — #10

Typing Functions in TypeScript: Parameters, Return Types and Overloads

Mert Özen Nov 3, 2026 8 min 0 views
Typing Functions in TypeScript: Parameters, Return Types and Overloads

We cover parameter and return types, optional, default and rest parameters, function type expressions and call signatures, the subtlety of void returns, overloads and the this parameter.

Version note: The code in this post targets TypeScript 7.0 and Node 26.

Predict First

In this post we'll take a close look at the types of functions. The first question is about void. Which lines in the code below error?

type Logger = (message: string) => void;

const log: Logger = (message) => message.length;

const numbers = [1, 2, 3];
const copy: number[] = [];
numbers.forEach((n) => copy.push(n));

function reset(): void {
  return 0;
}

We know void means "this function doesn't return a value." log returns a number, and so does copy.push; reset returns 0. How many of those three spots are errors? The answer is in the section on the subtlety of void returns.

Parameters and Return Types

In the post on inference we set the basic rule: you annotate parameters, and TypeScript usually infers the return type. Writing the return type explicitly is a contract; if the body changes and you accidentally start returning a different type, you see the error inside the function, not in the dozens of places that use it.

function calculateTotal(price: number, quantity: number): number {
  return price * quantity;
}

// Error: Expected 2 arguments, but got 1.
calculateTotal(10);
// Error: Expected 2 arguments, but got 3.
calculateTotal(10, 2, 5);

In JavaScript, calling a function with too few or too many arguments is allowed; missing parameters become undefined and extra ones are silently ignored. TypeScript checks the number of arguments too. The parameter list becomes a contract describing exactly what the function expects.

Optional, Default and Rest Parameters

Not every parameter has to be required. TypeScript lets you describe the patterns you already write in JavaScript at the type level as well:

function greet(name: string, greeting?: string) {
  // greeting: string | undefined
  return `${greeting ?? "Hello"}, ${name}!`;
}

function repeat(text: string, times = 2) {
  // times: number
  return text.repeat(times);
}

function sum(...numbers: number[]) {
  return numbers.reduce((total, n) => total + n, 0);
}

console.log(greet("Ada")); // Hello, Ada!
console.log(greet("Ada", "Welcome")); // Welcome, Ada!
console.log(repeat("ha")); // haha
console.log(sum(1, 2, 3)); // 6

A ? after the parameter name makes it optional. Inside the function its type is string | undefined, so you have to deal with the undefined case before using it. Optional parameters must come after the required ones; if you write it the other way around, TypeScript says A required parameter cannot follow an optional parameter.

A parameter with a default value usually needs no annotation: when you write times = 2, TypeScript infers the type as number. For the caller, this parameter is optional, but inside the function it is never undefined.

The type of a rest parameter is always an array: ...numbers: number[] means "as many numbers as you like." It can also be combined with the tuples we saw earlier, but in everyday code a plain array is enough.

A Function's Own Type

So far we've typed functions where we defined them. But if you take a function as a parameter or keep it in a variable, how do you write that function's type? With a function type expression:

type Formatter = (value: number) => string;

const toCurrency: Formatter = (value) => `$${value.toFixed(2)}`;
const toPercent: Formatter = (value) => `${Math.round(value * 100)}%`;

function formatAll(values: number[], format: Formatter) {
  return values.map(format);
}

console.log(formatAll([0.25, 0.5], toPercent)); // [ '25%', '50%' ]
console.log(toCurrency(3)); // $3.00

(value: number) => string means "a function that takes a number and returns a string." It looks like an arrow function, but it's a type, not a value; to the right of the arrow is the return type, not a body. The parameter name in the type is there only for readability; you can give the parameter of the function you assign any name you like.

Notice that we didn't annotate value in toCurrency and toPercent. Since TypeScript knows which type the function is being assigned to, it takes the parameter type from there. This is called contextual typing, and it's also why you never write types in the callbacks you pass to methods like map, filter and forEach.

One more subtlety: a function that takes fewer parameters can be assigned to a function type that expects more. const ignore: Formatter = () => "n/a"; is valid. Since JavaScript ignores extra arguments anyway, this is safe; it's also the rule that lets you pass a one-parameter callback to forEach instead of a three-parameter one.

Call Signatures: Functions with Properties

In JavaScript, functions are objects too, and they can carry properties. A function type expression can't express that; for this you write a call signature inside an object type:

type Validator = {
  (value: string): boolean;
  description: string;
};

function isEmail(value: string) {
  return value.includes("@");
}
isEmail.description = "Contains an @ sign";

const check: Validator = isEmail;
console.log(check.description, check("ada@example.com")); // Contains an @ sign true

The (value: string): boolean line says this object can be called with a string and returns a boolean. The difference from a function type expression is that a colon takes the place of the arrow. You can write the same type with an interface instead of a type alias; the body stays exactly the same. TypeScript also recognizes properties you assign to a function right after declaring it; without the description line, the check line would give an error starting with Property 'description' is missing.

The Subtlety of void Returns

The answer to the opening question: only the return 0 inside reset errors.

error TS2322: Type 'number' is not assignable to type 'void'.

At first glance this looks inconsistent, but there's a sensible distinction behind it. When you write : void directly on a function declaration, you promise "this function won't return a value," and TypeScript holds you to that promise. A void in a function type says something different: "I don't care about the return value." A function matching the Logger type may return a value; that value just won't be used.

Without this rule, an innocent line like numbers.forEach((n) => copy.push(n)) wouldn't compile. The callback forEach expects has a void return type, while push returns the new length, a number. TypeScript is flexible here because forEach never looks at that value anyway.

There's a consequence too: if you write const result = log("hi");, the type of result is void, even if it actually holds a number. The type says the value shouldn't be used; you can't rely on it for anything.

Overloads

Sometimes a function's return type depends on the argument it receives. Give it a single string and you want a single number back; give it an array of strings and you want an array of numbers. If you write it with a union, the return type is always number | number[], and the caller has to narrow every time. Overloads are the way to bake that relationship into the type:

function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]) {
  return Array.isArray(input) ? input.map(Number) : Number(input);
}

const single = parse("42"); // single: number
const many = parse(["1", "2"]); // many: number[]

The first two lines are overload signatures: they have no body and list the ways the function can be called from outside. The third is the implementation signature; it must be broad enough to cover all the overloads, and it holds the actual code. On a call, TypeScript tries the overloads from top to bottom and picks the first one that fits. The implementation signature isn't visible from outside; you can only call the function in the shapes you wrote in the overloads.

Overload signatures are pure type information and are erased when compiling to JavaScript. That means they work directly with the node file.ts we talked about earlier.

Still, overloads shouldn't be your first choice. If the return type doesn't depend on the argument, a single signature with a union parameter is both shorter and more flexible. TypeScript's own documentation also recommends union parameters over overloads whenever possible. You'll see why in the pitfalls section.

The this Parameter

In the JavaScript series we saw that this changes depending on how a function is called. When you detach a method from its object and call it on its own, this is no longer that object. TypeScript can catch this mistake, as long as you tell it what this should be:

function handleClick(this: { label: string }) {
  console.log(`Clicked: ${this.label}`);
}

const button = { label: "Save", handleClick };
button.handleClick(); // Clicked: Save

const detached = button.handleClick;
// Error: The 'this' context of type 'void' is not assignable to method's 'this' of type '{ label: string; }'.
detached();

The this parameter always goes first in the list, and it isn't a real parameter: you don't pass an argument for it when calling the function, and it's erased entirely when compiling to JavaScript. Its only job is to tell the compiler which this the function must be called with.

What if you don't annotate it? Since noImplicitThis, part of the strict family, is on, using a this whose type isn't known makes TypeScript say 'this' implicitly has type 'any' because it does not have a type annotation. Inside classes and object methods the type of this is already known, so you'll mostly need this parameter in standalone functions.

Common Pitfalls

The first pitfall is thinking an optional parameter and a parameter that accepts undefined are the same:

function logA(message?: string) {
  console.log(message ?? "(empty)");
}

function logB(message: string | undefined) {
  console.log(message ?? "(empty)");
}

logA();
// Error: Expected 1 arguments, but got 0.
logB();

Inside both, the type of message is string | undefined. The difference is on the caller's side: you can call logA with no arguments, but you have to pass logB something explicitly, undefined if need be. Choose based on what you want.

The second pitfall is passing a union to overloads:

function parse(input: string): number;
function parse(input: string[]): number[];
function parse(input: string | string[]) {
  return Array.isArray(input) ? input.map(Number) : Number(input);
}

function parseAny(input: string | string[]) {
  // Error: No overload matches this call.
  return parse(input);
}

You have a string | string[], and the implementation accepts exactly that. But the implementation isn't visible from outside, and none of the overloads accepts this union on its own. Either you add a third overload, or you write a single signature with a union parameter from the start. The more overloads you have, the more of this kind of friction you get.

The third pitfall is using Function as a function type. const fn: Function = () => 1; compiles, but you can call fn with any number of arguments and its return type is any. In effect, you've switched type checking off. Always write the function's real shape instead: () => number.

A Small Experiment

★☆☆ In the greet function, move the greeting parameter in front of name and read the error message. Then give greeting a default value of "Hello"; do you still need the ?? operator?

★★☆ Does it compile? Guess first: const toLabel: Formatter = (value: string) => value;. And const toLabel: Formatter = (value) => value;? Compare the two error messages.

★★☆ Fix the error in the parseAny example by adding a third overload to parse. What did the return type of parseAny become?

★★★ Write a Counter type: when called with no arguments it returns the next number, and it has a method called reset(): void. Use a call signature, then write a function that matches this type.

In this post we saw that a function taking fewer parameters can be assigned to a function type that expects more. That's just one example of TypeScript comparing types by their shape rather than by their name. In the next post we'll look at that idea itself, structural typing, along with the closely related intersections, and try to build an intuition for which type is assignable to which, and why.