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
interfacecan only describe object shapes (including callable/newable ones). Atypealias can name anything — unions, intersections, primitives, tuples, mapped and conditional types. -
An
interfaceparticipates in declaration merging (see below); atypealias is fixed once declared and re-declaring it is a "Duplicate identifier" error. -
interface X extends Yproduces better error messages and lets the compiler cache the flattened shape;type X = Y & Zsilently yieldsneverfor 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
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