Skip to content

Functions and Objects

Functions and object shapes are where you’ll spend most of your annotation budget. This page covers both, plus the structural rules that decide whether one type fits another.

A function type has parameter types and a return type.

function add(a: number, b: number): number {
return a + b;
}
const multiply = (a: number, b: number): number => a * b;

The return type can almost always be inferred, so it’s optional:

function add(a: number, b: number) {
return a + b; // inferred: number
}

Parameters can never be inferred from the body — annotate them, always. With noImplicitAny on, the compiler tells you so:

function add(a, b) { return a + b; }
// error TS7006: Parameter 'a' implicitly has an 'any' type.

To describe a function as a value — a callback parameter, a stored handler — use the arrow syntax:

type Transform = (input: string) => string;
function apply(value: string, fn: Transform): string {
return fn(value);
}
apply('abc', (s) => s.toUpperCase()); // `s` is contextually typed as string

Parameter names in a function type are documentation only; only positions and types matter. (a: string) => void and (input: string) => void are the same type.

// Optional: callers may omit it. Type inside the body is `string | undefined`.
function greet(name: string, title?: string) {
return title ? `${title} ${name}` : name;
}
// Default: also optional for the caller, but never undefined inside the body.
function repeat(text: string, times = 2) {
return text.repeat(times); // `times` is number
}
// Rest: collects the remaining arguments into an array (or a tuple).
function sum(...nums: number[]): number {
return nums.reduce((a, b) => a + b, 0);
}
sum(1, 2, 3); // => 6

Rules worth knowing:

  • Optional parameters must come after required ones. A default parameter may sit anywhere, but callers can only skip it by passing undefined.
  • name?: string is not the same as name: string | undefined. The first lets callers omit the argument entirely; the second requires them to pass something, even if it’s undefined.
  • A rest parameter must be last, and its type must be an array or tuple type.
function tag(...parts: [string, number, ...boolean[]]) {}
tag('a', 1, true, false); // fine
tag('a'); // error: Expected at least 2 arguments

For functions that rely on a dynamic this, declare it as a fake first parameter. It is erased at compile time.

interface Button {
label: string;
onClick(this: Button, event: Event): void;
}

Arrow functions have no this of their own, so this doesn’t apply to them.

void means “the caller must ignore whatever comes back”, not “returns undefined”. A callback that returns a value is assignable to a void-returning parameter type:

const seen: number[] = [];
[1, 2, 3].forEach((n) => seen.push(n)); // push returns number; accepted

Without this rule, half of all real-world callbacks would fail to type-check.

When one function has genuinely different signatures depending on its arguments, write overload signatures followed by a single implementation signature.

function parse(input: string): object;
function parse(input: string, reviver: (k: string, v: unknown) => unknown): object;
function parse(input: Buffer): object;
function parse(input: string | Buffer, reviver?: (k: string, v: unknown) => unknown): object {
const text = typeof input === 'string' ? input : input.toString('utf8');
return JSON.parse(text, reviver);
}

Rules:

  • The implementation signature is not callable. Only the overloads above it are visible to callers.
  • The implementation signature must be compatible with every overload, which usually means unions and optional parameters.
  • Overloads are tried top to bottom; the first match wins. Put more specific ones first.
function len(x: string): number;
function len(x: unknown[]): number;
function len(x: string | unknown[]): number {
return x.length;
}
len('abc'); // 3
len([1, 2]); // 2
len(42); // error: No overload matches this call

Overloads that differ only by return type based on an argument are usually better expressed with a conditional type or a generic:

// Instead of two overloads:
function first<T>(items: T[]): T | undefined {
return items[0];
}

To type “something you can new”:

type Ctor<T> = new (...args: any[]) => T;
function create<T>(C: Ctor<T>): T {
return new C();
}
class Widget {}
const w = create(Widget); // Widget

Describe an object by listing its members:

function print(user: { id: number; name: string }): void {
console.log(user.id, user.name);
}

Inline object types get unreadable quickly, so name them.

interface Options {
url: string;
timeout?: number; // may be absent; type inside is number | undefined
retries?: number;
}
function request(opts: Options) {
const timeout = opts.timeout ?? 5000; // handle the undefined
}
request({ url: '/api' }); // fine
interface Config {
readonly apiKey: string;
readonly endpoints: string[];
}
const c: Config = { apiKey: 'k', endpoints: ['/a'] };
c.apiKey = 'other'; // error: Cannot assign to 'apiKey' because it is a read-only property
c.endpoints.push('/b'); // ALLOWED — readonly is shallow

readonly is a compile-time marker only; nothing is frozen at runtime. It is also not checked during assignability, which surprises people:

interface Mutable { x: number }
interface Frozen { readonly x: number }
let m: Mutable = { x: 1 };
let f: Frozen = m; // allowed
f.x = 2; // error via `f`... but `m.x = 2` still works

Treat readonly as intent and local protection, not a guarantee.

When keys aren’t known ahead of time:

interface StringMap {
[key: string]: string;
}
const headers: StringMap = { 'content-type': 'application/json' };
headers['x-custom'] = 'value';
headers.anything; // typed string

Key type may be string, number, symbol, or a template literal type. Named properties must be compatible with the index signature:

interface Mixed {
[key: string]: number;
length: number; // fine
name: string; // error: Property 'name' of type 'string' is not assignable to
// 'string' index type 'number'
}

Fix that by widening the index signature to number | string, or by using two separate types.

Record<K, V> is usually nicer than writing the index signature by hand, and it supports finite key sets:

type Headers = Record<string, string>;
type Flags = Record<'debug' | 'verbose', boolean>; // both keys required

Structural typing says extra properties are fine — except when you assign an object literal directly. Then TypeScript flags unknown properties, on the theory that you made a typo.

interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2, z: 3 };
// error: Object literal may only specify known properties,
// and 'z' does not exist in type 'Point'.
// But via a variable, it's accepted:
const raw = { x: 1, y: 2, z: 3 };
const q: Point = raw; // fine — `raw` is not a fresh literal

This “freshness” check applies to direct assignments, arguments, and returned literals. Ways around it when the extra property is intentional:

const p = { x: 1, y: 2, z: 3 } as Point; // assertion (blunt)
const p: Point & { z: number } = { x: 1, y: 2, z: 3 }; // widen the type
interface Point { x: number; y: number; [k: string]: unknown } // allow extras

The two overlap heavily. The real differences:

interface type alias
Object shapes Yes Yes
Unions, tuples, primitives No Yes
Mapped / conditional types No Yes
Declaration merging Yes No
Extending extends & intersection
Implemented by classes Yes Yes (if object-shaped)
Error messages Often shorter (name preserved) Sometimes expanded
// Only `type` can do these:
type ID = string | number;
type Pair = [number, number];
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };

Declare an interface twice and the declarations combine. This is how you extend types owned by someone else.

interface Window {
myAppVersion: string;
}
// Now `window.myAppVersion` type-checks anywhere in the project.
type A = { a: number };
type A = { b: number }; // error: Duplicate identifier 'A'

Merging is a feature for library augmentation and a hazard for application code — two files can silently add fields to the same interface. See classes and declarations for the module-augmentation form.

They look equivalent but differ on conflicts.

interface A { x: number }
interface B extends A { x: string }
// error: Interface 'B' incorrectly extends interface 'A'.
type C = { x: number } & { x: string };
// No error at the declaration. `C['x']` is `number & string` = never.
const c: C = { x: 1 }; // error only here, and the message is worse

extends fails fast at the declaration site with a clear message. Intersections defer the problem.

TypeScript compares types by structure, not by name. Two independently declared types with the same members are interchangeable.

interface Duck { quack(): void }
class Robot { quack() { console.log('beep'); } }
function makeItQuack(d: Duck) { d.quack(); }
makeItQuack(new Robot()); // fine — Robot has the right shape

The assignability rule for objects: S is assignable to T if S has, at minimum, every member T requires, with compatible types. Extra members are fine (except for the freshness check above).

interface Named { name: string }
const person = { name: 'Ada', age: 36 };
const n: Named = person; // fine — extra `age` is ignored

This is exactly what you want for JavaScript interop and for functions that accept “anything with a .length”.

Two types that mean different things but share a shape are interchangeable:

type UserId = string;
type OrderId = string;
function getUser(id: UserId) {}
const orderId: OrderId = 'ord_123';
getUser(orderId); // no error — both are just `string`

If you need to keep them apart, use branded types, covered in patterns and gotchas.

Classes with private or protected members are compared nominally — the members must originate from the same declaration.

class A { private secret = 1 }
class B { private secret = 1 }
let a: A = new B(); // error: Types have separate declarations of a private property 'secret'

Same for the ECMAScript #private fields.

  • Annotate parameters always; let return types be inferred except at public boundaries.
  • param?: T allows omission; param: T | undefined requires an explicit argument.
  • void return types accept functions that return values — this makes callbacks work.
  • Overloads hide the implementation signature; prefer unions or generics when possible.
  • readonly is shallow, compile-time only, and ignored during assignability.
  • Index signatures over-promise; pair them with noUncheckedIndexedAccess or use Map.
  • Object literals get excess property checks; variables don’t.
  • Use interface for extensible object shapes, type for everything else.
  • Assignability is structural — shape decides — except for private/protected class members.