TypeScript Interfaces vs Type Aliases

TypeScript gives you two ways to name a shape: interface and type. For plain object shapes they look almost identical, and beginners often pick one at random. But the two constructs are backed by different compiler machinery, support different features, and behave differently in a few situations that matter once your codebase grows. This lesson walks through exactly what each one can and can’t do, where they overlap, and how to choose confidently instead of guessing.

Overview: two names, two mechanisms

An interface declares a named object type (or function/constructor type). It is built specifically to describe the shape of objects, classes, and functions, and it participates in a feature called declaration merging: if you declare the same interface name twice, TypeScript combines the members into one type instead of raising an error.

A type alias is more general — it gives a name to any type, not just object shapes. That includes primitives, unions, tuples, function types, mapped types, and conditional types. A type alias is just a label; once declared, it cannot be reopened or merged with another declaration of the same name.

Both are purely compile-time constructs. Whichever you use, TypeScript checks your code structurally (an object is compatible with a type if it has the right shape, regardless of which name was used to declare that shape) and then erases the interface or type entirely during compilation. If you look at the emitted JavaScript, there is no trace of interface Foo or type Foo — they exist only to catch mistakes before the code runs.

What they share

  • Both can describe an object’s properties, optional properties (prop?:), readonly properties, method signatures, and index signatures.
  • Both can be generic: interface Box<T> and type Box<T> both work.
  • Both can be implemented by classes with implements.
  • Both are checked structurally — an object literal doesn’t need to “declare” which interface or type it satisfies; it just needs to have compatible properties.

What only interfaces can do

  • Declaration merging — declaring the same interface name more than once merges the members. This is how libraries like Express or the DOM let you augment existing global types.
  • Interfaces read slightly more naturally in class-heavy, object-oriented code, and are what most public library APIs use for object shapes.

What only type aliases can do

  • Name a union type: type Status = "idle" | "loading" | "error";
  • Name a tuple type: type Point = [number, number];
  • Name a primitive alias: type ID = string | number;
  • Name a function type directly: type Handler = (event: Event) => void;
  • Use mapped types and conditional types, e.g. type Partial2<T> = { [K in keyof T]?: T[K] };

That asymmetry is the core of the decision: interfaces are a subset of what type aliases can express, specialized for object shapes and augmentable via merging. Type aliases can do everything an interface can for plain object shapes, plus everything else.

Syntax

Feature Interface Type alias
Object shape interface User { name: string; } type User = { name: string };
Extending / combining interface Admin extends User { role: string; } type Admin = User & { role: string };
Union type Not possible type Status = "on" | "off";
Redeclaring the same name Merges members (allowed) Error: duplicate identifier
Generics interface Box<T> { value: T; } type Box<T> = { value: T };

Both forms end with the members inside curly braces for object shapes; the key syntactic difference is that a type declaration is an assignment (type Name = ...;, ending in a semicolon) while an interface declaration is not (interface Name { ... }, no trailing = or semicolon required).

Examples

Example 1: The same object shape, two ways

For a plain object shape, an interface and a type alias produce identical type-checking behavior.

interface UserI {
  id: number;
  name: string;
  isActive: boolean;
}

type UserT = {
  id: number;
  name: string;
  isActive: boolean;
};

function greetInterface(user: UserI): string {
  return `Hello, ${user.name}! (id: ${user.id})`;
}

function greetType(user: UserT): string {
  return `Hello, ${user.name}! (id: ${user.id})`;
}

const alice: UserI = { id: 1, name: "Alice", isActive: true };
const bob: UserT = { id: 2, name: "Bob", isActive: false };

console.log(greetInterface(alice));
console.log(greetType(bob));

Output:

Hello, Alice! (id: 1)
Hello, Bob! (id: 2)

Because TypeScript checks structurally, you could even pass an object typed as UserT into greetInterface and it would work — the compiler cares about shape, not which keyword declared it.

Example 2: Extending vs. only-type-alias features

This example shows interface extension alongside the union, tuple, and function types that only a type alias can name.

interface Animal {
  name: string;
}

interface Bird extends Animal {
  canFly: true;
  wingspan: number;
}

type Vehicle = {
  wheels: number;
};

type Car = Vehicle & {
  brand: string;
};

type Status = "idle" | "loading" | "success" | "error";
type Coordinates = [number, number];
type Logger = (message: string) => void;

const sparrow: Bird = { name: "Sparrow", canFly: true, wingspan: 15 };
const sedan: Car = { wheels: 4, brand: "Toyota" };

const currentStatus: Status = "loading";
const point: Coordinates = [10, 20];
const log: Logger = (message) => console.log(`[LOG] ${message}`);

console.log(`${sparrow.name} has a wingspan of ${sparrow.wingspan}cm`);
console.log(`${sedan.brand} has ${sedan.wheels} wheels`);
console.log(`Status: ${currentStatus}`);
console.log(`Point: (${point[0]}, ${point[1]})`);
log("Type aliases can name unions, tuples, and functions");

Output:

Sparrow has a wingspan of 15cm
Toyota has 4 wheels
Status: loading
Point: (10, 20)
[LOG] Type aliases can name unions, tuples, and functions

Bird extends Animal works exactly like Car = Vehicle & { brand: string } — both combine two shapes into one. But Status, Coordinates, and Logger have no interface equivalent: an interface cannot be a union, a tuple, or a bare function signature assigned to a name.

Example 3: Declaration merging (interfaces only)

interface Config {
  apiUrl: string;
}

interface Config {
  timeout: number;
}

const settings: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

console.log(`Connecting to ${settings.apiUrl} with a ${settings.timeout}ms timeout`);

Output:

Connecting to https://api.example.com with a 5000ms timeout

Both interface Config declarations refer to the same type — TypeScript merges their members, so Config ends up requiring both apiUrl and timeout. This is exactly how you can extend a third-party library’s types (for example, adding a custom property to Express’s Request interface) without editing the library’s source. A type alias cannot do this at all; declaring type Config twice is a compile error.

Under the hood

A few things are worth understanding about what the compiler actually does:

  • Erasure. Neither interface nor type produces any JavaScript output. Run either example above through the TypeScript compiler and look at the emitted .js — the interfaces, type aliases, and all type annotations are gone; only the runtime logic (variables, function bodies, console.log calls) remains.
  • Structural identity. The compiler doesn’t track “this object was built to satisfy UserI” — it re-checks the object’s actual members against the target type at every assignment. Two differently-named types with identical members are fully interchangeable.
  • Merging happens at declaration time, not at use time. When the compiler sees a second interface Config { ... }, it doesn’t create a second type — it adds the new members to the existing declaration’s member list before any type-checking of usages occurs.
  • Intersections can produce never, silently. type A = { id: number } & { id: string } is legal TypeScript — the type alias itself compiles fine — but the resulting id property has type number & string, which collapses to never (no value can be both at once). The error only surfaces later, when you try to actually construct an object of that type. An equivalent interface extension fails immediately, at the extends declaration itself — see the Common Mistakes section below.

Common Mistakes

Mistake 1: Trying to “merge” a type alias like an interface

Redeclaring a type alias with the same name — expecting it to merge the way interfaces do — is a compile error.

type Config = {
  apiUrl: string;
};

type Config = {
  timeout: number;
};

const settings: Config = { apiUrl: "https://api.example.com", timeout: 5000 };

This reports: Duplicate identifier 'Config'. (reported on both declarations). Type aliases are single, final bindings — the second declaration doesn’t extend the first, it collides with it.

The fix is to declare all the members in a single type alias (or switch to interface if you specifically need mergeable, augmentable declarations):

type Config = {
  apiUrl: string;
  timeout: number;
};

const settings: Config = {
  apiUrl: "https://api.example.com",
  timeout: 5000,
};

console.log(settings.apiUrl, settings.timeout);

Output:

https://api.example.com 5000

Mistake 2: Extending an interface with an incompatible property type

When an interface extends another, every inherited property must stay assignable to its original type. Changing a property’s type in the child interface is a compile error, caught immediately:

interface Base {
  id: number;
}

interface Derived extends Base {
  id: string;
}

This reports: Interface 'Derived' incorrectly extends interface 'Base'. Types of property 'id' are incompatible. Type 'string' is not assignable to type 'number'. Note that the equivalent mistake with a type alias intersection (type Derived = Base & { id: string }) would not error at the declaration — it would silently produce id: never, and only fail later when you try to build an object. The interface version is easier to debug precisely because it fails at the point of the mistake.

The fix is to keep inherited property types consistent, and give new fields their own names:

interface Base {
  id: number;
}

interface Derived extends Base {
  label: string;
}

const item: Derived = { id: 1, label: "widget" };
console.log(`${item.id}: ${item.label}`);

Output:

1: widget

Best Practices

  • Use interface for public object shapes you expect consumers to extend or augment — especially library-facing APIs, React component props, and class contracts (implements).
  • Use type whenever you need a union, tuple, function type, or a type built with mapped/conditional type features — there is no interface equivalent, so this choice isn’t really optional.
  • Don’t rely on accidental declaration merging — if two interface blocks with the same name exist in different files by mistake, TypeScript will silently combine them instead of erroring, which can hide bugs. Keep interface names unique unless merging is intentional (e.g. augmenting a library’s types).
  • Prefer extends/intersections over duplicating members — both interfaces and type aliases support composition; use it instead of copy-pasting property lists.
  • Be consistent within a codebase or team: many style guides pick one as the default for object shapes (commonly interface) and reserve type for unions, tuples, and function types, purely for readability and predictability.
  • Remember both are erased at runtime — never rely on an interface or type alias for a runtime check (like typeof or instanceof); use classes, discriminated unions with a literal tag, or runtime validation libraries for that.

Practice Exercises

  • Exercise 1: Define a type alias named Shape that is a union of three object types: a circle (kind: "circle", radius: number), a square (kind: "square", side: number), and a rectangle (kind: "rectangle", width: number, height: number). Write a function area(shape: Shape): number that computes the area for each case. (Hint: this union cannot be expressed as a single interface — think about why.)
  • Exercise 2: Declare an interface Vehicle with a brand: string property. In a second, separate interface Vehicle declaration, add a topSpeedKmh: number property. Create an object that satisfies the merged interface and log a sentence describing it. Confirm both properties are required.
  • Exercise 3: Try writing type Broken = { value: number } & { value: string }; and then attempt to create a variable of type Broken with a value for value. What error do you get, and at which line does it appear? Compare that to what happens if you write the same conflict using two interface declarations connected with extends.

Summary

  • Both interface and type describe shapes and are checked structurally; both are fully erased at compile time with no runtime footprint.
  • Only type aliases can name unions, tuples, primitive aliases, bare function types, and mapped/conditional types.
  • Only interface declarations support declaration merging — redeclaring the same name adds members instead of erroring, which is how you augment library types.
  • Interface extends conflicts are caught immediately at the declaration; type alias intersection conflicts collapse silently to never and only surface when you try to construct a value.
  • Default to interface for extensible object shapes and public APIs; reach for type whenever you need a union, tuple, function type, or advanced type-level composition.