Skip to content
TR

Phase 1 · Foundations · Lesson 1.3

Beginner

Typing functions

Parameters, return types, optional and rest parameters, function types, overloads, the `this` parameter, and the `void` and `never` return types that trip people up in interviews.

20 min

Functions are where types pay off most. A typed function signature is a contract: callers can't pass the wrong thing, and the body can't return the wrong thing. It's also where TypeScript has its strangest rules, like a void callback that is allowed to return a number. This lesson covers both.

Parameters and return types

Annotate each parameter after its name. The return type goes after the parameter list.

function add(a: number, b: number): number {
  return a + b;
}
 
// @ts-expect-error -- Expected 2 arguments, but got 1.
add(1);
 
// @ts-expect-error -- Expected 2 arguments, but got 3.
add(1, 2, 3);

Unlike JavaScript, TypeScript checks the number of arguments too. Missing or extra arguments are errors.

Should you annotate the return type?

TypeScript infers the return type from the return statements, so this is optional:

function double(n: number) {
  return n * 2;
}
 
const result = double(21);
//    ^? const result: number   (the inferred return type)

When to write it anyway:

  • Public or exported functions. The annotation is the contract. Without it, a change deep in the body silently changes the API.
  • To catch mistakes in the body. With an annotation, a wrong return is reported at the return, not at some far-away caller.
  • Recursive functions whose return depends on themselves. TypeScript can't infer those:
// @ts-expect-error -- 'factorial' implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
function factorial(n: number) {
  return n <= 1 ? 1 : n * factorial(n - 1);
}
 
function factorialOk(n: number): number {
  return n <= 1 ? 1 : n * factorialOk(n - 1);
}

For small internal helpers and inline callbacks, let inference do the work.

Optional and default parameters

A ? makes a parameter optional. Inside the function its type includes undefined.

function greet(name: string, greeting?: string) {
  const g = greeting;
  //    ^? const g: string | undefined
  return `${greeting ?? "Hello"}, ${name}`;
}
 
greet("Ada");
greet("Ada", "Hi");

A default value also makes a parameter optional, and its type is inferred from the default. Inside the body it is never undefined.

function repeat(text: string, times = 2) {
  const t = times;
  //    ^? const t: number
  return text.repeat(times);
}
 
repeat("ab");
repeat("ab", undefined); // passing undefined explicitly uses the default

Ordering rules:

  • An optional (?) parameter can't come before a required one.
  • A default parameter can, but then callers must pass undefined to skip it, which is awkward.
function bad(a?: number, b: number) {}
//                       ~ A required parameter cannot follow an optional parameter.

Rest parameters

...name: T[] collects the remaining arguments into an array. The type must be an array or tuple type.

function sum(...nums: number[]): number {
  return nums.reduce((total, n) => total + n, 0);
}
 
sum();
sum(1, 2, 3);
 
const values = [1, 2, 3];
sum(...values); // spreading an array into a rest parameter is fine

Spreading into a function with fixed parameters is a classic gotcha. An array has unknown length, so TypeScript can't prove it has exactly two items:

const coords = [10, 20];
// @ts-expect-error -- A spread argument must either have a tuple type or be passed to a rest parameter.
Math.atan2(...coords);
 
const fixed = [10, 20] as const; // a tuple: length known
Math.atan2(...fixed);

Function types

Functions are values, so they need types too. The function type expression looks like an arrow function:

type BinaryOp = (a: number, b: number) => number;
 
const multiply: BinaryOp = (a, b) => a * b; // a and b are inferred from BinaryOp
 
function apply(op: BinaryOp, x: number, y: number) {
  return op(x, y);
}
 
apply(multiply, 3, 4);
apply((a, b) => a - b, 3, 4);

Notice (a, b) => a * b needs no annotations. That's contextual typing: the expected type flows into the function.

Call signatures

A function type expression can't describe a function that also has properties. For that, use an object type with a call signature. Note the : instead of =>.

type Formatter = {
  (value: number): string; // call signature
  locale: string;          // plus a property
};
 
function makeFormatter(): Formatter {
  const fn = (value: number) => value.toFixed(2);
  return Object.assign(fn, { locale: "en" });
}
 
const format = makeFormatter();
format(3.14159);
format.locale;

There is also a construct signature, written new (...args) => T or { new (x: string): T }, for things called with new.

Quick check

What is the type of n inside the arrow function?

type Handler = (n: number) => void;
const h: Handler = (n) => console.log(n);

Overloads

Sometimes one function accepts different argument shapes, and the return type depends on which. Overload signatures list each shape; a single implementation signature follows with the body.

function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
  return typeof value === "string" ? Number(value) : String(value);
}
 
const a = parse("42");
//    ^? const a: number
const b = parse(42);
//    ^? const b: string

Rules that interviewers probe:

  1. The implementation signature is not callable. Callers only see the overloads above it. Even though the implementation accepts string | number, you can't call parse with a union:
function parse(value: string): number;
function parse(value: number): string;
function parse(value: string | number): string | number {
  return typeof value === "string" ? Number(value) : String(value);
}
 
declare const input: string | number;
// @ts-expect-error -- No overload matches this call.
parse(input);
  1. The implementation must be compatible with every overload. Its parameters must accept each overload's arguments and its return type must cover each overload's return.
  2. Order matters. TypeScript picks the first overload that matches, top to bottom. Put specific signatures before general ones, or the general one swallows every call:
function describe(value: unknown): string;
function describe(value: string): "text"; // never chosen: the line above matches first
function describe(value: unknown): string {
  return typeof value === "string" ? "text" : "other";
}
 
const d = describe("hi");
//    ^? const d: string

The this parameter

In JavaScript, this depends on how a function is called. TypeScript lets you declare what this must be with a fake first parameter named this. It's erased at compile time and doesn't count as an argument.

interface Counter {
  count: number;
  increment(this: Counter): void;
}
 
const counter: Counter = {
  count: 0,
  increment() {
    this.count++; // this is Counter
  },
};
 
counter.increment(); // fine: called as a method
 
const loose = counter.increment;
// @ts-expect-error -- The 'this' context of type 'void' is not assignable to method's 'this' of type 'Counter'.
loose();

Pulling a method off its object and calling it bare is a real bug (this would be undefined), and the this parameter catches it.

void: "the result is ignored"

A function declared to return void can't return a value:

function log(message: string): void {
  console.log(message);
  // @ts-expect-error -- Type 'number' is not assignable to type 'void'.
  return 42;
}

But a function type returning void accepts functions that return anything. This is deliberate:

type Callback = (item: number) => void;
 
const double: Callback = (item) => item * 2; // returns number: allowed
 
const results: number[] = [];
[1, 2, 3].forEach((n) => results.push(n)); // push returns a number; forEach wants void

Why? Array.prototype.forEach ignores the callback's result. If () => void rejected value-returning functions, forEach(n => results.push(n)) would be an error, and that pattern is everywhere. So the rule is: a void return in a function type means "the caller won't use the result", not "must return nothing".

The return value is still hidden from whoever calls through the void type:

type Callback = (item: number) => void;
const cb: Callback = (item) => item * 2;
 
const r = cb(1);
//    ^? const r: void

Quick check

Which line is a type error?

const f: () => void = () => "x";
function g(): void { return "x"; }
 
f();

never: functions that don't return

A function that always throws, or loops forever, has return type never. Its call is a dead end, and TypeScript uses that for narrowing.

function fail(message: string): never {
  throw new Error(message);
}
 
function getPort(value: string | undefined): number {
  if (value === undefined) fail("PORT is required");
  return Number(value); // value is string here: fail() never returns
}

The gotcha: inference differs between declarations and expressions. A function declaration that only throws is inferred as void, for backward compatibility. An arrow function or function expression is inferred as never.

function throwsDecl() {
  throw new Error("x");
}
const fromDecl = throwsDecl;
//    ^? const fromDecl: () => void
 
const throwsArrow = () => {
  throw new Error("x");
};
const fromArrow = throwsArrow;
//    ^? const fromArrow: () => never

So always write : never explicitly on helpers like fail. For the narrowing in getPort to work, TypeScript also requires the function's type to be explicitly annotated.

Function declarations vs arrow functions

Both are fully typed. The differences are mostly JavaScript differences, plus a couple of TypeScript ones:

function f() {}const f = () => {}
Hoisted (callable before its line)yesno
Own thisyes (can declare a this parameter)no, uses the surrounding this
Overloadsyes, with overload signaturesonly via a call-signature type
Typed with a type aliasnoyes: const f: BinaryOp = ...
Only-throws body infersvoidnever

A common style: function for top-level, exported functions (hoisting, overloads, readable stack traces); arrows for callbacks and when you want to type the whole function with an alias.

Spot the error

This code has three problems under strict. Find them.

type Mapper = (string) => number;
 
function total(prices?: number[], currency: string) {
  return prices.reduce((sum, p) => sum + p, 0);
}
Show the fix
  1. (string) => number declares a parameter named string with an implicit any type. Function types need a name and a type: (value: string) => number.
  2. prices? is optional but comes before the required currency: A required parameter cannot follow an optional parameter.
  3. prices.reduce fails because an optional prices is possibly undefined.

Put the required parameter first and give prices a default, which also removes undefined from its type:

type Mapper = (value: string) => number;
 
function total(currency: string, prices: number[] = []): string {
  const sum = prices.reduce((acc, p) => acc + p, 0);
  return `${sum} ${currency}`;
}

Try it

Experiment with overload order and void callbacks. Swap the two overload signatures of toArray and hover one again.

function toArray(value: string): string[];
function toArray(value: string[]): string[];
function toArray(value: string | string[]): string[] {
  return Array.isArray(value) ? value : [value];
}
 
const one = toArray("a");
const many = toArray(["a", "b"]);
 
type OnItem = (item: string) => void;
function each(items: string[], fn: OnItem) {
  for (const item of items) fn(item);
}
 
const seen: string[] = [];
each(many, (item) => seen.push(item)); // returns number, still fine
 
function assertNever(value: never): never {
  throw new Error(`Unexpected: ${String(value)}`);
}

▶ Try it in the TypeScript Playground

Recap

  • Annotate parameters always; annotate return types on exported and recursive functions, let inference handle small helpers.
  • x?: T gives T | undefined inside; x = default infers the type and is never undefined inside. Optional can't precede required.
  • Rest parameters take an array or tuple type. Spreading an array into fixed parameters needs a tuple.
  • Function types: (a: number) => string, or an object type with a call signature { (a: number): string; prop: T }. Parameter names are mandatory.
  • Overloads: callers see only the overload signatures; the implementation signature is hidden; first matching overload wins.
  • A this parameter types this and catches detached method calls. It's erased at compile time.
  • () => void accepts functions that return values; an explicit : void on a declaration does not.
  • Functions that always throw return never. Annotate it explicitly: declarations infer void.

Interview cards

1 / 7