Chapter 4

Interface vs Type Alias: A Decision Tree

The Core Differences at a Glance

Both interface and type can describe object shapes, but they work differently under the hood:

// Defined with interface
interface Point {
  x: number;
  y: number;
}

// Defined with type — looks identical
type Point2 = {
  x: number;
  y: number;
};

const p1: Point  = { x: 1, y: 2 };
const p2: Point2 = { x: 1, y: 2 };

Surface similarity aside, three key differences determine which to reach for: declaration merging, computational power, and the range of types each can express.

Difference 1: Declaration Merging

An interface can be declared multiple times; TypeScript automatically merges the declarations. type cannot — a duplicate declaration is an error.

// interface merges automatically
interface Config {
  host: string;
}

interface Config {
  port: number;
}

// Config now has both host and port
const cfg: Config = { host: "localhost", port: 3000 };

// type does not support merging
type Config2 = { host: string; };
// type Config2 = { port: number; };  // Error: duplicate identifier

The practical use of declaration merging: augmenting third-party library types.

// Extend Express's Request type with a custom property
// This is idiomatic in Express applications
declare global {
  namespace Express {
    interface Request {
      user?: { id: string; role: string };
    }
  }
}

import { Request, Response } from "express";

function authMiddleware(req: Request, res: Response, next: () => void) {
  req.user = { id: "123", role: "admin" };  // No error
  next();
}

Difference 2: type Can Be Computed; interface Cannot

type can express unions, intersections, mapped types, and conditional types. interface is limited to fixed object shapes.

// type can be a union
type ID = string | number;

// type can be a mapped type (make all properties optional)
type Partial<T> = {
  [K in keyof T]?: T[K];
};

// type can be a conditional type
type IsString<T> = T extends string ? true : false;
type A = IsString<string>;  // true
type B = IsString<number>;  // false

// type can alias primitives and function shapes
type UserId = string;
type Callback = (err: Error | null, result: string) => void;

// interface cannot do any of this:
// interface ID = string | number;        // Syntax error
// interface Callback = (x: number) => void;  // Must use object syntax

Difference 3: How Extension Works

interface uses extends with early, clear errors. type uses & intersection, but conflicts are silent at merge time and only surface at use sites.

// interface extends — straightforward inheritance
interface Animal {
  name: string;
}

interface Dog extends Animal {
  breed: string;
}

const dog: Dog = { name: "Rex", breed: "Labrador" };

// type uses intersection
type AnimalType = { name: string };
type DogType = AnimalType & { breed: string };

const dog2: DogType = { name: "Rex", breed: "Labrador" };

Conflict behaviour:

interface A1 { value: string; }
interface B1 extends A1 { value: number; }
// Error: Interface 'B1' incorrectly extends interface 'A1'
// TypeScript reports this at the extends clause — easy to find

type A2 = { value: string };
type B2 = A2 & { value: number };
// No error here — but B2.value is string & number = never
// The error only appears when you try to assign to B2.value

readonly and Optional ? Properties

Both interface and type support these modifiers:

interface UserProfile {
  readonly id: string;         // Cannot be changed after creation
  name: string;
  email?: string;              // Optional property
  readonly createdAt: Date;
}

const profile: UserProfile = {
  id: "u-001",
  name: "Alice",
  createdAt: new Date(),
};

profile.name = "Bob";         // OK
profile.id = "u-002";         // Error: Cannot assign to 'id' — it is read-only

// Readonly arrays
interface Config {
  readonly allowedOrigins: readonly string[];
}

const config: Config = {
  allowedOrigins: ["https://example.com"],
};

config.allowedOrigins.push("https://evil.com");  // Error: push does not exist on readonly string[]

Index Signatures

Use an index signature when key names are unknown but the value type is fixed:

interface ScoreBoard {
  [playerName: string]: number;
}

const scores: ScoreBoard = {
  alice: 100,
  bob: 85,
};

scores.charlie = 90;   // OK
scores.dave = "high";  // Error: string is not assignable to number

Named properties must be compatible with the index signature's value type:

interface BadConfig {
  [key: string]: number;
  name: string;    // Error: string is not compatible with number
  count: number;   // OK
}

// Fix: widen the index signature value type
interface GoodConfig {
  [key: string]: number | string;
  name: string;    // OK
  count: number;   // OK
}

Prefer Record<string, T> over raw index signatures — it's cleaner:

type ScoreBoard2 = Record<string, number>;

// Restrict the key set
type StatusMap = Record<"active" | "inactive" | "banned", number>;

const userCounts: StatusMap = {
  active: 150,
  inactive: 30,
  banned: 5,
};

Classes and Interfaces

A class can implement one or more interfaces, which act as contracts:

interface Serializable {
  serialize(): string;
  deserialize(data: string): void;
}

interface Loggable {
  log(message: string): void;
}

class DatabaseRecord implements Serializable, Loggable {
  private data: Record<string, unknown> = {};

  serialize(): string {
    return JSON.stringify(this.data);
  }

  deserialize(json: string): void {
    this.data = JSON.parse(json);
  }

  log(message: string): void {
    console.log(`[DatabaseRecord] ${message}`);
  }
}

type objects can also be used with implements, but interface communicates intent more clearly.

Decision Tree: Which One to Use?

What do you need?
│
├─ Union type?  (A | B)                              → type
├─ Primitive alias?  (type UserId = string)          → type
├─ Mapped type?  (Partial<T>, Pick<T, K>)            → type
├─ Conditional type?  (T extends U ? X : Y)          → type
├─ Shorthand function type?  (type Fn = () => void)  → type
│
├─ Contract for a class?  (implements)               → interface
├─ Public API object shape?  (may be extended)       → interface
├─ Need declaration merging?  (augment 3rd-party)    → interface
│
└─ Plain object shape, none of the above?            → follow team convention

Quick Comparison Table

Scenario interface type
Define object shape
Union types
Extension / merging extends &
Declaration merging
Mapped / conditional types
implements on class ✅ (preferred)
Augment third-party types ✅ (preferred) Possible but awkward
Error message clarity Higher Lower for unions/intersections

Anti-Patterns

// Anti-pattern: trying to use interface for union types — impossible
// interface Status = "active" | "inactive";  // Syntax error

// Workaround that is ugly and imprecise:
interface Status {
  value: "active" | "inactive";
}

// Correct:
type Status = "active" | "inactive";

// ----

// Anti-pattern: using type for public API shapes that consumers should extend
type PluginOptions = {
  timeout: number;
};

// Consumers cannot use declaration merging with type.
// They must resort to intersection, which is worse DX.

// Correct: use interface for extensible public API shapes
interface PluginOptions {
  timeout: number;
}

// Consumer can cleanly augment:
interface PluginOptions {
  retryCount: number;  // Declaration merging — clean and idiomatic
}

Key Takeaways

Key Point Notes
Declaration merging interface only — use to augment third-party types
Computed types type only — unions, mapped types, conditional types
extends vs & interface extends errors early; type & conflicts hide until use
readonly Both support it — prevent accidental mutation
? optional Both support it — distinct from T | undefined in strictness
Index signatures Both support it — prefer Record<string, T>
Public API types Prefer interface for consumer extensibility
Internal types Either works — pick one and stay consistent

Next chapter: function types in depth — overloads, generic functions, callable objects, and the subtle difference between void and undefined.

Rate this chapter
4.5  / 5  (73 ratings)

💬 Comments