Back to KB
Difficulty
Intermediate
Read Time
5 min

type vs interface in TypeScript - What You Really Need to Know

By Codcompass TeamΒ·Β·5 min read

Current Situation Analysis

Developers frequently encounter the type vs interface debate, often relying on oversimplified community heuristics like "use interface for objects, type for everything else." This superficial guidance ignores TypeScript's underlying type system mechanics, leading to inconsistent codebases and hidden failure modes.

Pain Points & Failure Modes:

  • Silent Type Degradation: Using & for object extension masks property conflicts, resolving to never instead of throwing immediate compile-time errors.
  • Augmentation Failures: Attempting to extend global objects or third-party library types with type accidentally overwrites the original definition instead of merging, causing type collisions.
  • Unintentional Type Pollution: Using interface for data transfer objects (DTOs) or API payloads enables accidental declaration merging across modules, breaking type isolation.
  • Why Traditional Methods Fail: Rule-of-thumb guidelines treat type and interface as interchangeable syntactic sugar. In reality, they map to fundamentally different compiler behaviors: structural naming (type) vs. contract declaration with merge capabilities (interface). Without understanding declaration merging, error reporting phases, and type expression limitations, teams introduce brittle type definitions that fail at scale.

WOW Moment: Key Findings

ApproachDeclaration MergingUnion/Conditional SupportConflict Error ReportingAugmentation SafetyRecommended Scope
interfaceβœ… Automatic merge❌ Syntax errorβœ… Immediate at declarationβœ… Safe for globals/libsLibrary APIs, extensible contracts
type❌ Duplicate identifier errorβœ… Full support⚠️ Delayed (resolves to never)❌ Overwrites originalDTOs, unions, mapped/conditional types

Key Findings:

  • interface and type are functionally identical for object shapes, generics, and basic extension.
  • The critical divergence lies in declaration merging (interface only) and type expression flexibility (type only).
  • Error reporting behavior differs significantly: extends fails fast at definition, while & defers failure to usage, creating a silent never trap.

Core Solution

1. Structural Equivalence

Before addressing differences, it's critical to recognize where both constructs produce identical compiler output.

Describing object shapes

interface User {
  id: number;
  name: string;
}

type User = {
  id: number;
  name: string;
};

Identical result. Neither is "better" for describing objects.

Generics

interface Box<T> {
  value: T;
}

type Box<T> = {
  value: T;
};

Both support generics. No difference here.

Extension

// interface extends interface
interface Animal {
  name: string;
}
interface Dog extends Animal {
  breed: string;
}

// type & type
type Animal = { name: string };
type Dog = Animal & { breed: string };

// interface extends type (yes, this works)
type Animal = { name: string };
interface Dog extends Animal {
  breed: string;
}

// type & interface (this works too)
interface Animal {
  name: string;
}
type Dog = Animal & { breed: string };

All four variants compile without issues. extends and & are practically equivalent in most cases, with one exception covered below.

2. Fundamental Divergence

Unions - type only

type Status = "active" | "inactive" | "pending"; // βœ…

interface Status = "active" | "inactive" | "pending"; // ❌ syntax error

interface cannot describe a union. Its syntax requires curly braces and a list of propert

ies. If you need a union, then you have to use type.

Same goes for conditional types, mapped types, and tuple types:

type MaybeString = string | null;
type Readonly<T> = { readonly [K in keyof T]: T[K] };
type Pair = [string, number];

None of the above can be expressed with interface.

Declaration merging - interface only This is the difference that has real practical consequences.

An interface with the same name declared in two places does not cause an error, it merges:

interface Config {
  timeout: number;
}

interface Config {
  retries: number;
}

// Result: Config = { timeout: number; retries: number }
const c: Config = {
  timeout: 3000,
  retries: 3,
};

With type, this is a compile error:

type Config = { timeout: number };
type Config = { retries: number }; // ❌ Duplicate identifier 'Config'

Type conflict during merging What if both interfaces declare the same field with conflicting types?

interface A {
  x: number;
}

interface A {
  x: string; // ❌ error - conflict during merging
}

The compiler reports the error at the declaration site, not at the point of use. The merge attempts to combine number and string on field x which is impossible.

extends vs & - difference in error reporting Technically you get the same result, but the compiler behaves differently when something goes wrong.

interface A {
  x: number;
}

interface B extends A {
  x: string; // ❌ immediate error: "Interface 'B' incorrectly extends interface 'A'"
}
type A = { x: number };
type B = A & { x: string };
// No error at declaration!
// But B.x has type: never

With &, the compiler won't complain at the definition, you'll get never on field x, which only surfaces when you try to use it. This is a subtle trap: never instead of an error can slip through code review unnoticed.

3. Declaration Merging in Practice

Augmenting global objects You want to add a field to the built-in Window:

// globals.d.ts

declare global {
  interface Window {
    analytics: Analytics;
  }
}

Why doesn't type work here?

type Window = { analytics: Analytics }; // ❌

This creates a new type named Window that conflicts with the built-in Window. You're trying to overwrite, instead of extending the original.

interface uses declaration merging. Your declaration gets merged into the existing one. The original Window still exists, analytics is added onto it.

Augmenting types from external libraries A library exports an interface you want to extend without touching node_modules:

// types/tailwindcss-vite.d.ts

import "@tailwindcss/vite";

declare module "@tailwindcss/vite" {
  interface Theme {
    borderRadius: number;
  }
}

The mechanism: declare module tells the compiler "this is an augmentation of an existing module." Inside, you use declaration merging on interface Theme adding a field to what the library already exports.

Note: you import the module (import "@tailwindcss/vite") before augmenting it. Without this, the compiler doesn't know what to look for.

Using type instead of interface here won't work. type is not subject to augmentation.

Pitfall Guide

  1. Silent never Type Trap: Using & to merge conflicting properties suppresses immediate compiler errors. The conflict resolves to never, which only surfaces when the property is accessed, making itζžζ˜“ slip through code review and CI pipelines.
  2. Declaration Merging Conflicts: Declaring the same interface name in multiple files with conflicting property types causes a compile-time error at the declaration site. This is safer than & but requires strict coordination in large codebases to avoid accidental collisions.
  3. Accidental Type Overwriting: Using type instead of interface for global or library augmentation creates a new, isolated type rather than merging. This breaks runtime expectations and causes Property 'X' does not exist on type 'Y' errors.
  4. Missing Module Import for Augmentation: When augmenting external libraries, failing to include import "module-name" before declare module prevents the compiler from resolving the target namespace. The augmentation silently fails or creates a new module scope.
  5. Misapplying Declaration Merging: Using interface for DTOs, API payloads, or internal data structures enables unintended cross-module merging. This leads to type pollution where unrelated modules accidentally extend each other's contracts.
  6. Assuming Interchangeability: Treating type and interface as identical ignores fundamental limitations. interface cannot express unions, mapped types, or conditional types, while type cannot participate in declaration merging. Forcing one pattern everywhere creates technical debt.

Deliverables

  • TypeScript Type Strategy Blueprint: A decision matrix mapping type definitions to architectural boundaries (e.g., DTOs β†’ type, extensible contracts β†’ interface, utility transformations β†’ type). Includes module augmentation patterns and merge-safe naming conventions.
  • Type vs Interface Selection Checklist: A 10-point validation checklist for code reviews covering union/intersection needs, declaration merging intent, error-reporting expectations, and augmentation requirements.
  • Configuration Templates: Pre-configured .d.ts templates for safe global augmentation, library extension, and merge-controlled interface definitions. Includes tsconfig flags to enforce strict declaration merging behavior and prevent accidental type overwrites.