Lesson 7 of 20

Functions & Types

Giving a Function Type a Name

In JavaScript, functions are values: you pass them around, store them in variables and return them from other functions. So they need types like any other value, and TypeScript writes a function type as a parameter list, an arrow, and a return type — (a: number, b: number) => number. Note the arrow. Inside a type, => separates the parameters from the return type; it has nothing to do with arrow-function syntax in the code itself.

Naming that type with a type alias pays off the moment two functions share a shape, or a shape appears in several signatures. Instead of repeating the parameter list everywhere, you declare type MathFn = (a: number, b: number) => number once and use the name.

There is a second benefit that is easy to miss. When you assign a function to a variable whose type is already known, TypeScript infers the parameter types from that type — this is contextual typing. You write const add: MathFn = (a, b) => a + b with no annotations inside the arrow function at all, and a and b are still fully typed. Annotating them again is redundant and gives you two places to update.

The parameter names in a function type are documentation only. TypeScript matches parameters by position, not by name, so a MathFn implementation may call them anything it likes.

Example
type MathFn = (a: number, b: number) => number;

// No annotations needed inside — contextual typing supplies them
const add: MathFn = (a, b) => a + b;
const subtract: MathFn = (x, y) => x - y;   // names need not match

const wrong: MathFn = (a, b) => `${a}${b}`;
// ERROR: Type 'string' is not assignable to type 'number'.

// Functions as parameters: the type goes where the parameter goes
function applyTwice(value: number, fn: (n: number) => number): number {
  return fn(fn(value));
}

applyTwice(3, n => n * 2);   // 12 — n is inferred as number

// An object with a call signature can also describe a function,
// which is how you type a function that carries properties
interface Retryable {
  (url: string): Promise<Response>;
  attempts: number;
}
Notes
  • Function types can be written with an interface too, using a call signature: interface MathFn { (a: number, b: number): number }. The type alias form is shorter and is what most codebases use for plain functions.

Optional, Default and Rest Parameters

A ? after a parameter name makes it optional: callers may leave it out. Inside the function, its type becomes string | undefined, so under strict you have to deal with the missing case before using it. Optional parameters must come after all the required ones, for the obvious reason that arguments are matched by position.

A default value does something similar but better in most cases. role: string = "student" is optional at the call site, and inside the function it is plain string, because the default has already filled the gap. You get the flexibility without the null check. You cannot write both ? and a default on the same parameter — that is a compile error, since the default already implies optional.

Now the distinction that catches people. greeting?: string and greeting: string | undefined are not the same thing. Both allow the value to be undefined, but only the first lets a caller omit the argument entirely. The second forces every caller to pass something, even if that something is undefined. That sounds pedantic until you use it deliberately: it is how you make sure nobody forgets an argument that is genuinely allowed to be empty.

Rest parameters collect any number of trailing arguments into an array, so the annotation is an array type. A rest parameter has to be last, and there can only be one.

Example
// Optional: caller may omit it, and it is possibly undefined inside
function greet(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}

greet("Ananya");            // OK
greet("Ananya", "Dr.");     // OK

// Default: optional outside, guaranteed inside
function createUser(name: string, role: string = "student") {
  return { name, role: role.toUpperCase() };   // no check needed
}

// Optional must come after required
// function bad(title?: string, name: string) {}
// ERROR: A required parameter cannot follow an optional parameter.

// Not the same thing:
function a(note?: string) {}
function b(note: string | undefined) {}

a();            // OK
b();            // ERROR: Expected 1 arguments, but got 0.
b(undefined);   // OK — you had to say so on purpose

// Rest parameters are typed as an array
function total(...amounts: number[]): number {
  return amounts.reduce((sum, n) => sum + n, 0);
}

total(199, 499, 89);   // 787
Notes
  • The compiler option exactOptionalPropertyTypes sharpens this further for object properties, distinguishing "the property is absent" from "the property is present and set to undefined". It is not part of strict; add it deliberately if that difference matters to your data.

void Is Looser Than It Looks

A function type with a void return does not mean "this function must return nothing". It means "whatever this function returns will be ignored". Those are different, and the difference is deliberate.

Because of it, a function that does return a value is perfectly assignable to a void-returning type. That is not a hole in the type system; it is what makes ordinary JavaScript pleasant to write. arr.forEach(x => list.push(x)) works even though push returns a number, and you are not forced to wrap the body in braces just to throw the value away.

The trap is on the other side. If you write a function whose return type you annotated void, and a caller ignores it, fine. But if you rely on a callback's return value while typing that callback as void, TypeScript will not stop the callback from returning something useless — it will simply never let you read it. Type the callback's real return type when you actually use the result.

There is one more rule specific to void: a function whose declared return type is void cannot itself return a value with return someValue. The looseness applies to assignment between function types, not to the body of a function you annotated yourself.

Example
type Handler = (item: string) => void;

const collected: string[] = [];

// Returns a number (push does), assigned to a void type — allowed
const h: Handler = item => collected.push(item);

// This is why the everyday case just works
["a", "b"].forEach(item => collected.push(item));

// But the return value is unreachable through the void type
const result = h("c");   // result: void — you cannot use it

// Annotating your own function void does forbid returning a value
function save(item: string): void {
  // return item.length;
  // ERROR: Type 'number' is not assignable to type 'void'.
  console.log(item);
}

// If you need the result, say so in the type
type Validator = (value: string) => boolean;
const isFilled: Validator = value => value.trim().length > 0;
Notes
  • This is one of the few TypeScript rules that exists purely for ergonomics. Knowing the reason stops it feeling arbitrary the day you notice it.

Typing Callbacks in Real Code

Most function types you write will describe callbacks: an event handler, a comparison function, a click handler on a component prop, a retry hook. The pattern is always the same — decide what the caller will be given and what you expect back, then write that as the parameter's type.

Being specific here pays for itself immediately. A callback typed (item: any) => void gives the person writing it nothing; a callback typed (student: Student) => void gives them autocomplete on every field. And when you later change Student, every callback in the codebase that used a removed field lights up.

Two habits are worth adopting. Give callbacks the narrowest parameters they need, rather than passing the whole object out of laziness — it keeps the contract honest. And prefer a named type alias for any callback signature used more than once, so that the shape has a single definition you can change in one place.

Example
interface Student {
  id: number;
  name: string;
  marks: number;
}

// A named callback type, used in several places
type StudentVisitor = (student: Student, index: number) => void;

function eachStudent(list: Student[], visit: StudentVisitor): void {
  list.forEach((s, i) => visit(s, i));
}

eachStudent(students, (s, i) => {
  console.log(`${i + 1}. ${s.name} scored ${s.marks}`);   // both typed
});

// A comparator: two in, a number out
type Comparator<T> = (a: T, b: T) => number;

const byMarks: Comparator<Student> = (a, b) => b.marks - a.marks;
const sorted = [...students].sort(byMarks);

// A callback that may fail, typed honestly
type Parser = (raw: string) => number | null;

const toMarks: Parser = raw => {
  const n = Number(raw);
  return Number.isFinite(n) ? n : null;
};

Overloads: Several Signatures, One Function

An overload lets one function present different signatures to its callers. You write the signatures first, with no bodies, and then a single implementation that has to be compatible with all of them.

Two rules about the implementation signature surprise everybody. It is not one of the callable signatures — callers can only use the ones you listed above it — so a wide implementation signature such as (value: string | number) does not let anyone pass a string | number. And the implementation must handle every case its overloads promise, which TypeScript checks only loosely; it is your responsibility to keep the body honest.

Overloads earn their place when the return type depends on the argument type in a way a union cannot express. parse("5") returning a number while parse(5) returns a string is a genuine case. When the return type is the same regardless, a union parameter is simpler, easier to read, and does not need a second declaration to be kept in sync.

The common mistake is writing overloads that a plain optional parameter would handle. If your two signatures differ only by an extra argument, delete them both and use ?.

Example
// The return type genuinely depends on the argument type
function parseValue(input: string): number;
function parseValue(input: number): string;
function parseValue(input: string | number): string | number {
  return typeof input === "string" ? Number(input) : String(input);
}

const n = parseValue("42");   // number
const s = parseValue(42);     // string

// The implementation signature is not callable
const mixed: string | number = "42";
// parseValue(mixed);
// ERROR: No overload matches this call.

// Overloads that should not exist — an optional parameter is enough
function greetA(name: string): string;
function greetA(name: string, title: string): string;
function greetA(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}

// Same behaviour, half the code
function greetB(name: string, title?: string): string {
  return title ? `${title} ${name}` : name;
}
Notes
  • Overload resolution picks the first signature that matches, not the best one. Put the more specific signatures above the more general ones, or the general one will swallow every call.
  • Before writing an overload, check whether a generic solves the problem instead — generics are usually the better tool when the return type simply mirrors the input type. That is the subject of a later lesson.
Ask AI