TSer/02 · Functions and object types
CHAPTER 02 · Parameters, returns, interfaces

A signature says what goes in and out

One line records the shape of every parameter and the shape of the result. The person who writes the function does not have to explain it, the person who calls it does not have to guess, and the compiler checks that both sides keep their side of the deal.

§01

Reading a signature: five parts, one line

A delivery note lists what arrives and what leaves. A function signature does the same job for a function. Click each part.

item: MenuItemParameter · name: type

Left of the colon is the parameter name, which is how the body refers to the value. Right of the colon is the type, which is the shape the caller has to provide. Pass something that is not a MenuItem and the call does not compile.

Start from the difference. Here is the same function twice. On the left you have to read the body to find out what it wants. On the right the first line already says it.

menu.js
1// menu.js - nothing about the shapes is written down
2function makeOrder(item, size, toppings) {
3 // What is item? Which values may size take?
4 // Can toppings be left out? What comes back?
5 // The answers are only in the head of whoever wrote this.
6}
menu.ts
1// menu.ts - the same information, written into the signature
2function makeOrder(
3 item: MenuItem, // in: one item from the menu
4 size: Size, // in: "small" | "medium" | "large"
5 toppings?: Topping[], // in, may be omitted: the toppings
6): Order { // out: one complete order
7 // ...
8}
This line is documentation for people and a contract that the compiler enforces. Documentation goes out of date quietly. A contract does not: change the signature without changing the callers and the build fails.

Parameter types are usually required: the compiler has nothing to read them from. The return type is usually not required, because the compiler reads it from the return statements.

The return type is inferred
1function subtotal(prices: number[]) {
2 return prices.reduce((sum, p) => sum + p, 0);
3}
4// Hover subtotal and TypeScript shows:
5// function subtotal(prices: number[]): number
6// The return type was not written. It was read from the return statement.

So when is writing the return type still worth it?

Two situations. The first is a public boundary: a function that other modules, other teams, or a published package will call. There the annotation is the promise, and it stops an accidental change to the body from silently changing the type everyone depends on.

The second is that the error appears in a better place. With an annotation, a mistake is reported at the definition, on the line you are editing. Without one, the wrong type flows out and the error appears at some call site far away, or nowhere at all. The worst case is any:

How any escapes
1// JSON.parse returns any, so the inferred return type is any too.
2function loadOrder(json: string) {
3 return JSON.parse(json);
4}
5const order = loadOrder('{"total": 25}');
6order.tatol.toFixed(2); // Misspelled. order is any, so nothing is reported.
7
8// Fix: write the return type. any now stops at the function boundary.
9function loadOrder2(json: string): Order {
10 return JSON.parse(json);
11}
12const order2 = loadOrder2('{"total": 25}');
13order2.tatol;
14// Property 'tatol' does not exist on type 'Order'.

Writing : Order puts a gate at the exit. The body may still infer any internally, but callers receive an Order, and a misspelled field is caught immediately.

§02

Three ways to make a parameter flexible

Real functions do not require every argument every time. Optional parameters, default values, and rest parameters each have their own rules.

params.ts
1// 1. Optional parameter: a ? after the name.
2function makeTea(base: string, topping?: string) {
3 // Inside the function, topping is string | undefined.
4 return topping ? base + " + " + topping : base;
5}
6makeTea("Oolong"); // ok, the argument is left out
7makeTea("Oolong", "boba"); // ok
8
9// 2. Default value: the type is read from the default.
10function pourSugar(base: string, sugar = 50) {
11 // Inside the function, sugar is number. It is never undefined.
12 return base + " (" + sugar + "% sugar)";
13}
14pourSugar("Milk Green"); // sugar is 50
15pourSugar("Milk Green", undefined); // sugar is 50 here as well
16
17// 3. Rest parameter: collects the remaining arguments into an array.
18function addToppings(base: string, ...toppings: string[]) {
19 return base + " + " + toppings.join(" + ");
20}
21addToppings("Milk Green", "boba", "coconut jelly", "pudding");

Optional and default are not the same thing

Both let the caller leave the argument out. The difference is what the parameter looks like inside the function. With topping?: string the type is string | undefined, so you have to check it before using it. With sugar = 50 the type is plain number: if the argument is missing, the default runs first, so undefined never reaches the body.

Passing undefined explicitly also triggers the default, which is why pourSugar("Milk Green", undefined) gives 50 too. When a parameter has a sensible default, prefer the default over ?: it removes a check from every line of the body.

An optional parameter cannot be followed by a required one

Wrong order
1function bad(topping?: string, base: string) {}
2// ~~~~
3// A required parameter cannot follow an optional parameter.
4
5// Arguments are matched by position. In the call bad("Oolong")
6// there is no way to tell whether "Oolong" is topping or base.

Arguments are matched by position. If an optional parameter sits in the middle, every required parameter after it can no longer be identified. The rule is: required first, then optional and defaulted ones.

A common question: is topping?: string the same as topping: string | undefined? No.

Optional is not the same as | undefined
1function a(topping?: string) {}
2function b(topping: string | undefined) {}
3
4a(); // ok - the argument may be left out entirely
5b(); // Expected 1 arguments, but got 0.
6b(undefined); // ok - the argument is required, undefined is a valid value
? controls whether the argument may be left out. | undefined controls which values the argument may hold. The second one still requires you to pass something.

A rest parameter is typed as an array. It can also be typed as a tuple, which fixes the type of each leading position while still accepting a variable number of arguments after them. This is what makes variadic tuple types useful in practice.

A rest parameter typed as a tuple
1// A rest parameter is typed as an array, or as a tuple.
2// A tuple gives the leading positions their own types and names.
3function order(...args: [name: string, size: Size, ...extras: string[]]) {
4 const [name, size, ...extras] = args;
5 return name + " " + size + " +" + extras.length;
6}
7
8order("Oolong", "large", "boba"); // ok
9order("Oolong", "grande");
10// Argument of type '"grande"' is not assignable to parameter of type 'Size'.
11order("Oolong");
12// Expected at least 2 arguments, but got 1.
§03

Function types: a function has a shape too

Functions are passed around as values, so the type system needs a way to say which functions are acceptable.

A function type expression
1// A function type expression describes the shape of a function.
2type PriceFormatter = (price: number) => string;
3// ^ takes one number, returns one string
4
5const usd: PriceFormatter = (p) => "$" + p.toFixed(2);
6// p carries no annotation, and it is not any. Its type comes from
7// PriceFormatter. Reading a type from the surrounding context like
8// this is called contextual typing.
A callback parameter
1// A parameter can be a function itself. Write down its shape too.
2function onEachItem(
3 items: MenuItem[],
4 cb: (item: MenuItem, index: number) => void,
5) {
6 items.forEach((it, i) => cb(it, i));
7}

Notice that the callback returns void. In a function type, void does not mean "you must return nothing". It means "the caller ignores the return value". A function that returns something is therefore still acceptable.

void accepts a returned value
1const collected: number[] = [];
2
3[1, 2, 3].forEach((n) => collected.push(n));
4// push returns a number: the new length of the array.
5// forEach asks for a callback of type (value, index, array) => void.
6// This compiles anyway.
This is deliberate. A one-line arrow function returns the value of its expression whether you want it or not. If (…) => void rejected that, most forEach callbacks would have to be written with a block body just to throw the value away.
The other two meanings of void
1// void on a declaration means "this function returns nothing".
2function log(msg: string): void {
3 return msg.length;
4 // Type 'number' is not assignable to type 'void'.
5}
6
7// A variable of type void is a third case. Almost nothing fits it.
8let nothing: void;
9nothing = undefined; // ok
10nothing = 1; // Type 'number' is not assignable to type 'void'.
Three cases, one keyword. In a function type, void is a promise by the caller not to look. On a declaration, it is a requirement on the function itself. On a variable, it is an ordinary type that only undefined satisfies.
§04

Which function fits where another is expected

Once functions have types, the compiler needs a rule for comparing them. The rule for return types and the rule for parameter types run in opposite directions.

Return types: more specific is fine
1interface Animal { name: string }
2interface Dog extends Animal { breed: string }
3
4// Return type: a function that returns Dog fits where a function
5// returning Animal is expected. Every Dog is already an Animal, so
6// the caller still gets everything it was promised.
7type MakeAnimal = () => Animal;
8const makeDog: MakeAnimal = (): Dog => ({ name: "Rex", breed: "corgi" });

That direction is easy to accept: the caller asked for an Animal and got something that is an Animal plus more. Parameters work the other way, and this is where people get surprised.

Parameters: more specific is rejected
1// Parameter type: the opposite direction. Under strictFunctionTypes,
2// a function type accepts a parameter that is the same or wider.
3type FeedAnimal = (a: Animal) => void;
4
5const feedDog: FeedAnimal = (d: Dog) => console.log(d.breed);
6// Type '(d: Dog) => void' is not assignable to type 'FeedAnimal'.
7// Types of parameters 'd' and 'a' are incompatible.
8// Property 'breed' is missing in type 'Animal' but required in type 'Dog'.
9
10// Wider is fine: FeedDog may be called with any Dog, and every Dog
11// is an Animal, so a function that accepts any Animal can do the job.
12type FeedDog = (d: Dog) => void;
13const feedAnimal: FeedDog = (a: Animal) => console.log(a.name);

Read it from the caller's side. Something holding a FeedAnimal may call it with any animal, including a cat. A function that reads d.breed would then fail. So the compiler rejects it. Checking parameters in this reversed direction is called contravariance, and it is switched on by the strictFunctionTypes flag, which strict turns on.

The exception: methods are still checked in both directions

strictFunctionTypes only applies to function type positions. A member declared with method syntaxfeed(a: Animal): void rather than feed: (a: Animal) => void — keeps the older rule, where the parameter may go in either direction. That is called bivariance.

Method syntax versus property syntax
1// The exception: a member written with method syntax is checked in
2// both directions, even under strictFunctionTypes.
3interface Feeder { feed(a: Animal): void }
4const f: Feeder = { feed(d: Dog) { console.log(d.breed); } }; // no error
5
6// Write the same member as a property and the strict rule applies.
7interface Feeder2 { feed: (a: Animal) => void }
8const g: Feeder2 = { feed: (d: Dog) => console.log(d.breed) };
9// Type '(d: Dog) => void' is not assignable to type '(a: Animal) => void'.
10
11// This is why arrays behave the way they do. Array<T> declares push,
12// forEach and the rest with method syntax:
13declare const dogs: Dog[];
14const animals: Animal[] = dogs; // accepted
15animals.push({ name: "Whiskers" }); // accepted - dogs now holds a non-Dog

This is a known and deliberate unsoundness. It is not a rule that happens to be safe. The last two lines above compile and then put a plain Animal into an array that is really a Dog[]. TypeScript accepts it because rejecting it would break Array<Dog> being usable as Array<Animal>, along with a large amount of existing JavaScript. The team chose usability over soundness here, and documented the choice.

What to do with this: if you want the strict check on a member of an interface, declare it with property syntax. If you are wondering why an assignment you expected to fail was accepted, check whether method syntax is involved.

One more rule surprises almost everyone: a function with fewer parameters fits where one with more parameters is expected.

Fewer parameters is allowed
1// A function with fewer parameters fits where one with more is expected.
2[1, 2, 3].map((x) => x * 2);
3// map calls the callback with three arguments: value, index, array.
4// A callback that declares one parameter simply ignores the other two.
5// Plain JavaScript already works this way, so the rule costs nothing.
6
7// The opposite is rejected. Extra parameters would never be filled in:
8declare function each(cb: (v: number, i: number, all: number[]) => void): void;
9each((v: number, i: number, all: number[], extra: string) => {});
10// Target signature provides too few arguments. Expected 4 or more, but got 3.

Why array.map(x => x) works

map calls its callback with three arguments, but you almost always write a callback that takes one. In JavaScript, extra arguments are simply ignored, so a callback that declares fewer parameters is always safe to call. The type system allows exactly what the language already allows.

The reverse is not safe. A callback that declares a fourth parameter would read an argument nobody passes, so the compiler rejects it.

§05

Overloads and the this parameter

Two more things a signature can express. You will meet both while reading the types of third-party libraries.

Some functions behave differently depending on what you pass: give them a string and one thing comes back, give them an array and something else comes back. You can describe that with several overload signatures followed by one implementation signature.

Overload signatures
1// Two overload signatures, then one implementation signature.
2function price(item: string): number;
3function price(items: string[]): number[];
4function price(x: string | string[]): number | number[] {
5 return Array.isArray(x) ? x.map(() => 10) : 10;
6}
7
8const one = price("Oolong"); // number
9const many = price(["Oolong", "Tea"]); // number[]
10
11// The implementation signature is not part of the public type.
12// A union argument matches neither overload, so this is an error:
13declare const mixed: string | string[];
14price(mixed);
15// No overload matches this call.

Two rules people get wrong

First: the implementation signature is not callable from outside. It only has to be compatible with every overload above it. In the example, price is implemented for string | string[], but calling it with a string | string[] value is an error, because neither overload accepts that type.

Second: TypeScript picks the first overload that matches, in the order you wrote them. It does not look for the best match. So overload order is part of the API: write the more specific signatures first.

Order changes the result
1// TypeScript takes the first overload that matches, in source order.
2function fmt(x: unknown): string;
3function fmt(x: number): number;
4function fmt(x: any): any { return x; }
5
6const a = fmt(1); // string - the unknown signature matched first
7
8// Put the more specific signature first and the result changes.
9function fmt2(x: number): number;
10function fmt2(x: unknown): string;
11function fmt2(x: any): any { return x; }
12
13const b = fmt2(1); // number

The other extra is a parameter named this. It is not a real parameter. It tells the compiler what this must be when the function runs.

The this parameter
1declare const button: HTMLButtonElement;
2
3// A first parameter named this is not a real parameter. It declares
4// what this must be when the function runs. It is erased at compile
5// time, so it does not exist at runtime and does not shift the other
6// parameters: onClick still takes exactly one argument.
7function onClick(this: HTMLButtonElement, ev: MouseEvent) {
8 console.log(this.disabled, ev.type);
9}
10
11button.addEventListener("click", onClick); // ok - this will be the button
12onClick(new MouseEvent("click"));
13// The 'this' context of type 'void' is not assignable to method's 'this'
14// of type 'HTMLButtonElement'.
15
16// An arrow function takes this from the surrounding scope, so it has
17// no this of its own to declare:
18const arrow = (this: HTMLElement) => {};
19// An arrow function cannot have a 'this' parameter.
The this parameter is erased along with every other type, so the compiled JavaScript has a one-parameter function. It changes nothing at runtime. It only lets the compiler reject a call where this would be wrong.

A signature can also narrow a type

A return type can be written as x is Dog or asserts x is Dog. Those are type predicates and assertion signatures: signatures that tell the compiler what a check has proved. They belong with narrowing, so chapter 03 covers them.

§06

Object types: optional, readonly, index signatures

Chapter 01 gave objects a basic shape. Three modifiers extend it: which fields may be missing, which may not be changed, and what to do when the keys are not known in advance.

menu-item.ts
1interface MenuItem {
2 readonly id: number; // readonly: once listed, the id cannot change
3 name: string;
4 price: number;
5 desc?: string; // optional property: it may be missing
6}
7
8const jasmine: MenuItem = { id: 1, name: "Jasmine Milk Green", price: 16 };
9
10jasmine.price = 18; // ok, prices change
11jasmine.id = 2; // Cannot assign to 'id' because it is
12 // a read-only property.

readonly is a compile-time check, not a lock

readonly stops you at compile time only. It leaves no trace in the emitted JavaScript, so at runtime the property can still be written. For a real runtime freeze you need Object.freeze. The compile-time check is still worth having: it catches the accidental writes, which are almost all of them.

Sometimes you cannot list the keys in advance. A stock table may get any SKU tomorrow. An index signature describes only the type of the keys and the type of the values.

An index signature
1// The stock table: which SKUs exist is decided at runtime.
2// The shape is fixed: the key is a string, the value is a number.
3interface Inventory {
4 [sku: string]: number;
5}
6
7const stock: Inventory = { "tea-001": 30, "tea-002": 12 };
8stock["tea-003"] = 50; // ok, any new key is allowed
9stock["tea-001"] = "many";
10// Type 'string' is not assignable to type 'number'.

When to use an index signature

If you can write the field names out, write them out. The compiler then checks your spelling. Use an index signature only when the keys are decided at runtime: SKUs, user input, a dictionary built from data. It is more permissive, and the cost of that is exactly the spelling check you just gave up.

§07

interface vs type: a smaller difference than you have heard

Both describe the shape of an object, and in most cases they are interchangeable. Here is the syntax side by side, then the abilities that only one of them has.

With interface
1interface MenuItem {
2 name: string;
3 price: number;
4}
5
6interface ToppedItem extends MenuItem {
7 toppings: string[];
8}
With type
1type MenuItem = {
2 name: string;
3 price: number;
4};
5
6type ToppedItem = MenuItem & {
7 toppings: string[];
8};

The right side uses &, an intersection type. It is worth a closer look, because the next chapter contrasts it with unions.

Intersection types
1// A & B describes one value that satisfies A and B at the same time.
2// The result has every member of both.
3type Priced = { price: number };
4type Named = { name: string };
5type Item = Priced & Named;
6
7const item: Item = { price: 16, name: "Jasmine Milk Green" }; // ok
8const half: Item = { price: 16 };
9// Property 'name' is missing in type '{ price: number; }' but
10// required in type 'Named'.
11
12// & is not | . A union value is one of its members, and you must check
13// which one before you use it. An intersection value is all of them at
14// once, so no check is needed.
extends reports a conflict, & does not
1// When two sides disagree, extends reports it and & does not.
2interface A { price: number }
3interface B extends A { price: string }
4// Interface 'B' incorrectly extends interface 'A'.
5// Types of property 'price' are incompatible.
6// Type 'string' is not assignable to type 'number'.
7
8type C = { price: number } & { price: string }; // no error here
9declare const c: C;
10c.price; // the type of price is never, so nothing can be done with it
An intersection of two conflicting property types is reduced to never without an error at the declaration, so the problem only shows up later, at the place that tries to use the property. That is the practical reason to prefer extends when you are extending an object type.
AbilityinterfacetypeNotes
Describe an object shapeMost everyday code. Either one works.
Extend an existing shapeextends&extends reports a conflict at the declaration
Merge two declarations of the same nameonly interfaceduplicate identifierdeclaration merging, used to extend global types
Union ("s" | "m")only typeOnly type can express one out of several
Mapped and conditional typesonly typeType-level programming, chapters 06 and 07
Only interface: declaration merging
1// declaration merging: two interfaces with the same name are combined.
2// Only interface can do this.
3
4// One condition: a plain interface declaration only lands in the global
5// scope inside a script file, meaning a file with no import and no
6// export. Almost every file in a real project is a module, and there the
7// declaration below creates a local Window instead:
8// error TS2339: Property 'teaShopVersion' does not exist on
9// type 'Window & typeof globalThis'.
10// Inside a module, wrap it in declare global:
11declare global {
12 interface Window {
13 teaShopVersion: string;
14 }
15}
16// Now it really merges with the built-in Window:
17window.teaShopVersion; // ok
18
19// Two type aliases with the same name are an error:
20type Size = "small";
21type Size = "large"; // Duplicate identifier 'Size'.
Only type: unions and mapped types
1// Unions and mapped types can only be written with type.
2type Size = "small" | "medium" | "large"; // one of three
3type SoldOut = { [K in Size]: boolean }; // a mapped type, chapter 07

So which one should you use?

The current advice in the TypeScript handbook is plain: pick either one and stay consistent within a codebase. If you need a union or a mapped type, only type can express it. If you are writing a library and want users to be able to extend a declaration, use interface. The claim that interface is always faster to compile is not a rule you should base a decision on.

§08

Putting it together: the full makeOrder

One function that uses most of this chapter: a literal union, an interface, readonly, a default value, and an explicit return type.

tea-shop.ts
1type Size = "small" | "medium" | "large";
2type Topping = "boba" | "coconut jelly" | "pudding" | "taro balls";
3
4interface MenuItem {
5 readonly id: number;
6 name: string;
7 price: number;
8}
9
10interface Order {
11 item: MenuItem;
12 size: Size;
13 toppings: Topping[];
14 total: number;
15}
16
17function makeOrder(
18 item: MenuItem,
19 size: Size,
20 toppings: Topping[] = [], // default value: no toppings means an empty array
21): Order {
22 const sizeFee = size === "large" ? 3 : size === "medium" ? 1 : 0;
23 const toppingFee = toppings.length * 2;
24 return { item, size, toppings, total: item.price + sizeFee + toppingFee };
25}
The highlighted signature is the one from the top of this chapter. Note that toppings uses a default value instead of ?. Inside the body its type is Topping[], never undefined, so toppings.length needs no check.

The signature is fixed. Now you play the compiler. For each of the six calls below, guess whether it compiles, then read what the compiler actually says.

Guess first, then read what the compiler sayscorrect 0 / 6
// one drink is already on the menu: declare const milkTea: MenuItem; function makeOrder(item: MenuItem, size: Size, toppings?: Topping[]): Order
1makeOrder(milkTea, "large")
2makeOrder(milkTea)
3makeOrder(milkTea, "grande")
4makeOrder(milkTea, "medium", ["boba", "pudding"])
5makeOrder("Jasmine Milk Green", "large")
6makeOrder(milkTea, "large", "boba")
Once all six make sense, you have the compiler's habit: when you read a call, you check it against the signature before you run anything.

This shop appears again

In chapter 03 this Order grows a status field: pending, paid, delivered. Written as a discriminated union, it lets the compiler work out which fields exist in each branch on its own.

§09

Practice

Signatures are learned by writing them and reading the errors. Four tasks, all of which fit in the TypeScript Playground.

§10

Quiz

Eleven questions on parameters, void, assignability, overloads, readonly, and interface vs type. A perfect score lights up the sidebar.

QUESTION 01 / 11

function brew(topping?: string, base: string) {} does not compile. Why?

QUESTION 02 / 11

type Cb = () => void; and then const f: Cb = () => 123; — what happens?

QUESTION 03 / 11

function load(json: string) { return JSON.parse(json); } — what is the risk here?

QUESTION 04 / 11

Which of these can only be written with type, not with interface? (choose all that apply)

QUESTION 05 / 11

What happens to a readonly id: number property after compilation to JavaScript?

QUESTION 06 / 11

In the function type (msg: string) => ____, the blank means "whatever this callback returns is ignored". Which type goes there?

QUESTION 07 / 11

What is the real difference between f(t?: string) and g(t: string | undefined)?

QUESTION 08 / 11

[1, 2, 3].map((n) => n * 2) compiles, even though map calls the callback with three arguments. Why?

QUESTION 09 / 11

interface Feeder { feed(a: Animal): void } accepts an object whose feed takes a Dog, even with strict on. What does that tell you?

QUESTION 10 / 11

Given function fmt(x: unknown): string; followed by function fmt(x: number): number; (plus an implementation), what is the type of fmt(1)?

QUESTION 11 / 11

Your team is arguing about interface vs type. Which claim holds?

What to take away from this chapter
  • Parameter types are usually required, return types are usually inferred. Write the return type at a public boundary, and whenever you want a mistake reported at the definition instead of at some distant call site. It also stops any from leaking out of the function.
  • ? lets the caller leave an argument out, and the type inside the function becomes | undefined. A default value also lets the caller leave it out, but the type inside the function does not include undefined. Optional parameters must come last. A rest parameter is an array, or a tuple.
  • void in a function type means the caller ignores the return value, so a function that returns something still fits. void on a declaration forbids returning a value. A variable of type void accepts only undefined.
  • Return types are checked covariantly: returning Dog fits where Animal is expected. Parameters are checked contravariantly under strictFunctionTypes — except for members written with method syntax, which stay bivariant. That exception is a deliberate unsoundness, and it is why Array<Dog> is assignable to Array<Animal>.
  • A function with fewer parameters fits where one with more is expected, because JavaScript ignores extra arguments. That is why array.map(x => x) compiles. Declaring more parameters than the target provides is rejected.
  • With overloads, the implementation signature cannot be called from outside, and TypeScript takes the first matching overload in source order rather than the best one. A this parameter is erased at compile time; arrow functions cannot have one.
  • Object types take three modifiers: ? for a field that may be missing, readonly for a compile-time-only write check, and [key: string]: T for keys that are only known at runtime.
  • interface and type are interchangeable most of the time. Unions and mapped types need type; declaration merging needs interface. A & B is an intersection: one value that satisfies both at once.