On this page

Interfaces and type aliases

15 min read TextCh. 2 — Compound Types

Describing object shapes

When working with objects in TypeScript, you need a way to describe their structure: which properties exist, what types they have, which are optional, and which should never be modified after initialization. TypeScript provides two mechanisms: interfaces and type aliases.

Interfaces

An interface declares the shape of an object. It specifies property names and their types, whether properties are required or optional, and whether they are readonly.

interface BlogPost {
  id: number;
  title: string;
  body: string;
  publishedAt: Date;
  tags: string[];
}

Any object that has at least these properties with the correct types is a valid BlogPost. TypeScript uses structural typing — the concrete type of the object does not matter, only its shape.

const post: BlogPost = {
  id: 1,
  title: "Getting started with TypeScript",
  body: "...",
  publishedAt: new Date(),
  tags: ["typescript", "tutorial"],
};

Optional properties

Add ? after the property name to make it optional. An optional property can be undefined or absent entirely.

interface UserProfile {
  id: number;
  name: string;
  bio?: string;           // may be undefined
  avatarUrl?: string;     // may be undefined
}

const user: UserProfile = { id: 1, name: "Alice" }; // bio and avatarUrl omitted — OK

When accessing an optional property, TypeScript narrows the type to T | undefined and forces you to handle the missing case:

function displayBio(profile: UserProfile): string {
  // profile.bio is string | undefined here
  return profile.bio ?? "No bio provided";
}

Readonly properties

The readonly modifier prevents a property from being reassigned after the object is created.

interface Config {
  readonly apiBaseUrl: string;
  readonly maxRetries: number;
  timeout: number; // mutable
}

const config: Config = {
  apiBaseUrl: "https://api.example.com",
  maxRetries: 3,
  timeout: 5000,
};

config.timeout = 10000;    // OK — timeout is mutable
config.maxRetries = 5;     // Error: Cannot assign to 'maxRetries' because it is a read-only property

Extending interfaces

Interfaces can inherit from one or more other interfaces using extends:

interface Animal {
  name: string;
  sound(): string;
}

interface Pet extends Animal {
  owner: string;
}

interface ServiceAnimal extends Pet {
  certificationNumber: string;
  tasks: string[];
}

const guide: ServiceAnimal = {
  name: "Rex",
  owner: "Alice",
  certificationNumber: "SA-2024-001",
  tasks: ["navigate", "alert"],
  sound() { return "Woof"; },
};

Multiple inheritance is allowed with interfaces: interface C extends A, B { ... }.

Declaration merging

One unique feature of interfaces is declaration merging: if you declare an interface with the same name twice, TypeScript merges the two declarations into a single interface:

interface Window {
  title: string;
}

interface Window {
  statusBar: string; // merged with the first declaration
}

// Now Window has both title and statusBar
const win: Window = { title: "My App", statusBar: "Ready" };

This is heavily used to extend third-party type definitions. For example, to add a custom property to Express's Request object you merge into its interface. Inside your own application code, prefer extends over merging for clarity.

Type aliases

A type alias assigns a name to any TypeScript type — not just object shapes. This is the key distinction from interfaces.

// Alias for a primitive
type UserId = number;
type Email = string;

// Alias for a union
type Theme = "light" | "dark" | "system";

// Alias for a tuple
type Point = [x: number, y: number];

// Alias for a function type
type Comparator<T> = (a: T, b: T) => number;

// Alias for an object shape
type Address = {
  street: string;
  city: string;
  country: string;
  postalCode?: string;
};

Intersection types with &

While interfaces use extends for inheritance, type aliases combine types using the & operator, called an intersection:

type Timestamped = {
  createdAt: Date;
  updatedAt: Date;
};

type WithAuthor = {
  authorId: number;
  authorName: string;
};

type Article = {
  title: string;
  body: string;
} & Timestamped & WithAuthor;

const article: Article = {
  title: "TypeScript in 2026",
  body: "...",
  createdAt: new Date(),
  updatedAt: new Date(),
  authorId: 42,
  authorName: "Alice",
};

Intersections are additive — the resulting type has all properties from all intersected types. If two intersected types declare the same property name with incompatible types, the resulting property type becomes never.

Index signatures

When an object can have an arbitrary number of properties where all values have the same type, use an index signature:

interface TranslationMap {
  [key: string]: string;
}

const translations: TranslationMap = {
  "greeting": "Hello",
  "farewell": "Goodbye",
};

// Dynamically access properties
function translate(key: string, map: TranslationMap): string {
  return map[key] ?? `[${key}]`;
}

The built-in Record<K, V> utility type is a cleaner alternative when you know the set of allowed keys:

// Only "en" | "es" | "fr" are valid keys
type SupportedLocale = "en" | "es" | "fr";
type LocalizedStrings = Record<SupportedLocale, string>;

const greetings: LocalizedStrings = {
  en: "Hello",
  es: "Hola",
  fr: "Bonjour",
};

interface vs type: when to use each

Both work for describing object shapes. The practical differences are small but important:

Feature interface type
Object shapes Yes Yes
Unions and intersections No Yes
Primitive aliases No Yes
Tuple types No Yes
Declaration merging Yes No
extends keyword Yes No (use &)
Error messages Often clearer Sometimes verbose

Rule of thumb: Use interface for the public API of a class or module — anything you expect consumers to extend or implement. Use type for unions, utility types, computed types, and internal shapes that do not need extension.

Practice

  1. Define an interface Product with required and optional properties. Extend it to create an interface DigitalProduct that adds a downloadUrl field. Create a value of each type and verify TypeScript enforces the shapes.
  2. Use intersection types to compose a type AuditedRecord by combining a base type with a Timestamped type. Write a factory function that creates an AuditedRecord from a plain object.
  3. Implement an index signature for a simple in-memory cache: interface Cache { [key: string]: unknown }. Write a typed get function that accepts a generic type parameter and casts the retrieved value.

Default rule — prefer interface for objects, type for everything else
Use `interface` when defining the shape of objects, classes, or APIs that might be extended. Use `type` when you need unions, intersections, mapped types, conditional types, or aliases for primitives. Both work for object shapes — the real differences are declaration merging (interface only) and the ability to represent non-object types (type only).
Readonly does not deep-freeze objects
The `readonly` modifier in TypeScript only prevents reassignment of the property itself. It does NOT make nested objects or arrays immutable. If a readonly property holds an array, that array can still be mutated with `push`, `pop`, etc. For deep immutability, use `Readonly<T>` recursively or a library like Immer.
Declaration merging is a library authoring tool
Declaration merging lets you add properties to an existing interface across multiple files. This is primarily useful when writing TypeScript declaration files for libraries or when extending third-party types (e.g., adding custom properties to `Express.Request`). Avoid using it inside application code — prefer explicit extension with `extends` instead.
// Interface: describes the shape of an object
interface User {
  readonly id: number;
  name: string;
  email: string;
  age?: number;          // optional property
}

// Extending an interface
interface AdminUser extends User {
  role: "admin";
  permissions: string[];
}

// Declaration merging — interfaces can be reopened
interface User {
  createdAt: Date;      // merged into the original User interface
}

const admin: AdminUser = {
  id: 1,
  name: "Alice",
  email: "[email protected]",
  role: "admin",
  permissions: ["read", "write", "delete"],
  createdAt: new Date(),
};