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>andtype 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
interfacenortypeproduces 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.logcalls) 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 resultingidproperty has typenumber & string, which collapses tonever(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 theextendsdeclaration 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
interfacefor public object shapes you expect consumers to extend or augment — especially library-facing APIs, React component props, and class contracts (implements). - Use
typewhenever 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
interfaceblocks 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 reservetypefor 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
typeoforinstanceof); use classes, discriminated unions with a literal tag, or runtime validation libraries for that.
Practice Exercises
- Exercise 1: Define a
typealias namedShapethat 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 functionarea(shape: Shape): numberthat computes the area for each case. (Hint: this union cannot be expressed as a singleinterface— think about why.) - Exercise 2: Declare an
interface Vehiclewith abrand: stringproperty. In a second, separateinterface Vehicledeclaration, add atopSpeedKmh: numberproperty. 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 typeBrokenwith a value forvalue. What error do you get, and at which line does it appear? Compare that to what happens if you write the same conflict using twointerfacedeclarations connected withextends.
Summary
- Both
interfaceandtypedescribe shapes and are checked structurally; both are fully erased at compile time with no runtime footprint. - Only
typealiases can name unions, tuples, primitive aliases, bare function types, and mapped/conditional types. - Only
interfacedeclarations support declaration merging — redeclaring the same name adds members instead of erroring, which is how you augment library types. - Interface
extendsconflicts are caught immediately at the declaration; type alias intersection conflicts collapse silently toneverand only surface when you try to construct a value. - Default to
interfacefor extensible object shapes and public APIs; reach fortypewhenever you need a union, tuple, function type, or advanced type-level composition.
