Objects and Interfaces

This section documents the current TypeScript release line as published at the official TypeScript documentation, which is the reference these pages are written and verified against. No specific patch version is pinned.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since TypeScript iterates quickly.

This section’s bibliography lists the reference material consulted while preparing these pages.

Object types are the core of most TypeScript codebases. You can write them inline as \{ x: number }, name them with a type alias, or declare them as an interface. This page covers the trade-offs between those forms, the modifiers that make a shape precise, and the merging behaviour that only interface has. The reference is Object Types in the handbook.

interface vs. type alias

Both describe the shape of an object, and a class can implements either. The differences:

  • An interface can only describe object shapes (including callable/newable ones). A type alias can name anything — unions, intersections, primitives, tuples, mapped and conditional types.

  • An interface participates in declaration merging (see below); a type alias is fixed once declared and re-declaring it is a "Duplicate identifier" error.

  • interface X extends Y produces better error messages and lets the compiler cache the flattened shape; type X = Y & Z silently yields never for members whose primitive types conflict.

// interface: a named object shape, open to extension and merging
interface Point {
  x: number;
  y: number;
}

// type alias: a name for any type at all
type Id = string | number;
type Pair = [number, number];
type Coord = { x: number; y: number };

// extends vs. &
interface Point3D extends Point {
  z: number;
}
type Coord3D = Coord & { z: number };

// conflicting members: intersection gives `never`, extends gives an error
type Bad = { a: string } & { a: number }; // a: never

Practical rule: default to interface for public object shapes and anything consumers might extend or augment; reach for type when you need a union, tuple, or computed type, or when you deliberately want to prevent merging. Whichever you choose, pick one and be consistent — mixing them for no reason just adds noise. The handbook summarises this in Differences Between Type Aliases and Interfaces.

Property modifiers

Three modifiers refine a property: ? makes it optional, readonly blocks reassignment, and an index signature opens the type to arbitrary keys.

interface Account {
  readonly id: string;     // cannot be reassigned after creation
  name: string;
  nickname?: string;       // optional: type is string | undefined
}

const a: Account = { id: "a1", name: "Ada" };
a.name = "Ada L.";         // ok
// a.id = "a2";            // Error: Cannot assign to 'id', it is a read-only property
a.nickname?.toUpperCase(); // narrow the optional before use

readonly is shallow and structural: it stops assignment through that reference, but a mutable alias to the same object can still change it. It is a compile-time guard, not a runtime freeze.

Index signatures and more precise alternatives

An index signature like [key: string]: T says "any string key maps to T`". It is the bluntest tool available — it erases key safety and, without `noUncheckedIndexedAccess, pretends every lookup succeeds. Prefer a type that states the real keys:

// Blunt: every string key allowed, misspellings pass, lookups look total
interface Bag {
  [key: string]: number;
}

// Precise: a fixed key set
type Scores = Record<"math" | "science", number>;

// Precise: keys derived by a mapped type
type Flags = { [K in "read" | "write" | "exec"]: boolean };

// Precise: genuinely dynamic keys, with an API that admits absence
const counts = new Map<string, number>();
counts.get("missing"); // number | undefined -- honest

Record<K, V>, mapped types, and Map all appear again in Type Manipulation and Utility Types. When you do keep a string index signature, turn on noUncheckedIndexedAccess so bag["nope"] is typed number | undefined.

Why numeric index signatures are a trap

[index: number]: T looks like it models obj[0], but JavaScript object keys are always strings: obj[0] and obj["0"] are the same slot. TypeScript also requires the numeric index type to be assignable to the string one, and the runtime coercion means a numeric index signature is fiction for plain objects.

interface ByNumber {
  [index: number]: string;
  [key: string]: string; // required: number index must be compatible with string index
}

Use a numeric index only for array-like types; for real keyed-by-number data use an array or a Map<number, T>.

Excess-property checking vs. plain assignability

A fresh object literal assigned straight into a typed slot is checked for excess properties. The same object routed through a variable is only checked for structural assignability, and extra properties are ignored.

interface Options {
  width: number;
  height?: number;
}

function render(o: Options): void {}

render({ width: 10, height: 5 });
// render({ width: 10, depth: 5 }); // Error: 'depth' does not exist in type 'Options'

const raw = { width: 10, depth: 5 };
render(raw);                        // ok: plain assignability, 'depth' ignored

Excess-property checking is a lint-like safety net for typos, not part of structural compatibility. Do not design around it.

Nested types, extends, and overridden properties

An interface can extend one or many bases. A property redeclared in the derived type must stay assignable to the version it overrides.

interface Address {
  city: string;
  zip: string;
}
interface Person {
  name: string;
  address: Address; // nested object type
}
interface Timestamped {
  createdAt: Date;
}

// multiple extends: several bases merged into one
interface User extends Person, Timestamped {
  address: Address & { country: string }; // narrower override, still assignable to Person['address']
  role: "admin" | "user";
  // name: number;                         // Error: incompatible override of Person.name
}

Call signatures, construct signatures, and hybrid types

An interface body can hold a bare (…​) call signature, a new (…​) construct signature, or both alongside ordinary members — a hybrid type, the classic way older UMD libraries were typed.

// call signature: a callable value that also carries properties
interface Logger {
  (message: string): void;
  level: "info" | "warn";
  reset(): void;
}

// construct signature: a newable value
interface DateFactory {
  new (value: number): Date;
}

// hybrid: callable AND newable AND has members
interface JQueryLike {
  (selector: string): unknown;
  new (html: string): unknown;
  readonly version: string;
}

const log = ((msg: string) => console.log(msg)) as Logger;
log.level = "info";
log.reset = () => {};

Interface declaration merging

Declaring the same interface name twice in a scope merges the members. This is unique to interface (namespace and enum also merge; type aliases do not).

interface Box { width: number; }
interface Box { height: number; }

const b: Box = { width: 4, height: 3 }; // Box === { width: number; height: number }

Augmenting global interfaces (Window)

Merging is how you teach TypeScript about globals a library or your bootstrap code adds. Put the augmentation in a file that is already a module (export \{} if needed) and wrap it in declare global:

// global.d.ts
export {};

declare global {
  interface Window {
    __APP_VERSION__: string;
  }
}

// anywhere in the app
window.__APP_VERSION__ = "1.2.3"; // typed, no error

The full rule set — including which declarations merge and how ambient modules are augmented — is in Declaration Merging. For shipping these augmentations as part of a package, see Declaration Files.

readonly for mutation safety, and doc comments

Mark inputs you must not mutate as readonly, and use readonly T[] (or ReadonlyArray<T>) so the mutating array methods disappear from the type.

interface Config {
  readonly hosts: readonly string[];
}

function connect(c: Config): void {
  // c.hosts.push("x"); // Error: 'push' does not exist on 'readonly string[]'
  const [first] = c.hosts; // reading is fine
}

const cfg = { hosts: ["a", "b"] } as const; // literal types + deep readonly
connect(cfg);

Do not restate type information in doc comments — the compiler already knows the types, and a prose copy drifts out of date. Comment the intent the types cannot express.

// Redundant: repeats the signature, will rot
/** @param id The string id. @returns the number of events. */
function countA(id: string): number { return 0; }

// Useful: says what the types can't
/** Events seen since the last reset. Throws if `id` was never registered. */
function countB(id: string): number { return 0; }

readonly classes and parameter properties are covered in Classes.

Choosing: type vs. interface

flowchart TD A["Naming a type"] --> B["Is it a union, tuple, primitive,
or a mapped/conditional type?"] B -->|"Yes"| C["Use a type alias"] B -->|"No"| D["Object shape that others may
extend or augment?"] D -->|"Yes"| E["Use an interface"] D -->|"No"| F["Does the team already
standardise on one?"] F -->|"On interface"| E F -->|"On type"| C F -->|"No convention"| E