3.3 interface vs type
One of the first sources of confusion when learning TypeScript is the question: "Should I use interface or type?" The two tools are often interchangeable, but they have key differences and one is more appropriate in certain situations.
This chapter compares them technically and provides a practical decision-making guide.
Complete Summary of Key Differences
| Feature | interface | type |
|---|---|---|
| Define object structure | Yes | Yes |
| Primitive type alias | No | type Name = string |
| Union types | No | type A = B | C |
| Intersection types | Similar via extends | type A = B & C |
| Literal types | No | type Dir = "left" | "right" |
| Declaration merging | Yes (duplicate same-name declarations) | No (causes an error) |
| How to extend | extends keyword | & intersection |
| Recursive types | Limited | Natural and easy |
| Class implements | Yes | Yes (for object types) |
| Tuple/array types | Limited | type Pair = [string, number] |
| Conditional types | No | type A = T extends U ? X : Y |
| Mapped types | No | type A = { [K in keyof T]: ... } |
| Error message readability | Shows the interface name | May show the full structure inline |
Differences in Extension
interface: the extends Keyword
interface Animal {
name: string;
sound(): string;
}
interface Pet extends Animal {
owner: string;
vaccinated: boolean;
}
interface ServiceAnimal extends Animal {
serviceType: "guide" | "therapy" | "search-rescue";
certificationId: string;
}
// Multiple extension
interface SpecialPet extends Pet, ServiceAnimal {
specialTrait: string;
}
extends performs a type compatibility check at extension time. Defining a property that conflicts with the parent interface immediately raises an error.
interface Base {
value: string;
}
// interface Derived extends Base {
// value: number; // Error: 'number' is not compatible with 'string'
// }
type: the & Intersection
type Animal = {
name: string;
sound(): string;
};
type Pet = Animal & {
owner: string;
vaccinated: boolean;
};
// Conflict detection happens at a different time
type Base = { value: string };
type Derived = Base & { value: number };
// Derived.value is string & number = never
// The error does not occur immediately; it becomes never at the point of use
Practical difference: extends checks compatibility at the point of extension, enabling earlier error detection. & is more flexible but may detect conflicts later.
Declaration Merging: interface Only
This is the biggest technical difference between the two tools.
interface Declaration Merging
// Can be declared with the same name in multiple files or in the same file
interface UserConfig {
theme: "light" | "dark";
}
interface UserConfig {
language: "ko" | "en" | "ja";
}
interface UserConfig {
notifications: boolean;
}
// Automatically merged
const config: UserConfig = {
theme: "dark",
language: "en",
notifications: true,
};
type Cannot Be Merged
type UserSettings = { theme: "light" | "dark" };
// type UserSettings = { language: string }; // Error: duplicate identifier
The Primary Use of Declaration Merging: Module Augmentation
// Extend a third-party library's types without touching them
// types/express-session/index.d.ts
import "express-session";
declare module "express-session" {
interface SessionData {
userId?: string;
cartItems?: string[];
lastVisited?: Date;
}
}
// Now session.userId is accessible with full type safety
import { Request } from "express";
function getCart(req: Request): string[] {
return req.session.cartItems ?? [];
}
Recursive Types
A recursive type is one that references itself. type expresses this more naturally.
Recursive Types with type
// JSON value type (recursive)
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
const data: JsonValue = {
name: "Alice",
age: 30,
tags: ["ts", "dev"],
address: {
city: "New York",
zip: "10001",
},
};
// Tree structure
type TreeNode<T> = {
value: T;
children: TreeNode<T>[];
};
const tree: TreeNode<string> = {
value: "root",
children: [
{
value: "branch-1",
children: [
{ value: "leaf-1-1", children: [] },
{ value: "leaf-1-2", children: [] },
],
},
{ value: "branch-2", children: [] },
],
};
Recursive Types with interface (Limited)
// interface works too, but direct recursion is slightly more verbose
interface TreeNodeInterface<T> {
value: T;
children: TreeNodeInterface<T>[];
}
// Complex recursive unions like JSON cannot be expressed with interface
// interface JsonValue { ... } cannot express the | null | boolean ... union
When interface? When type? — Practical Guidelines
When to Choose interface
1. Defining the object structure of a public API
Define the public interface of a library or module with interface. This allows users to extend it via declaration merging.
// Library code
export interface PluginConfig {
name: string;
version: string;
}
// Users can extend it in their own code
declare module "my-library" {
interface PluginConfig {
customField: string;
}
}
2. Defining a contract for a class
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: string): Promise<boolean>;
}
class UserRepository implements Repository<User> {
// Failing to implement all methods causes a compile error
async findById(id: string): Promise<User | null> { ... }
async findAll(): Promise<User[]> { ... }
async save(user: User): Promise<User> { ... }
async delete(id: string): Promise<boolean> { ... }
}
3. Expressing object-oriented type hierarchies
interface Shape {
area(): number;
perimeter(): number;
}
interface TwoDShape extends Shape {
x: number;
y: number;
}
interface ThreeDShape extends Shape {
volume(): number;
}
When to Choose type
1. Defining union or intersection types
// Not possible with interface
type Status = "active" | "inactive" | "pending";
type ID = string | number;
type Result<T> = { success: true; data: T } | { success: false; error: string };
2. Naming primitive types or tuples
type Timestamp = number; // Unix timestamp
type RGB = [number, number, number];
type HSL = [number, number, number];
type Pair<A, B> = [A, B];
3. Combining utility types, conditional types, or mapped types
// Conditional type — not possible with interface
type NonNullable2<T> = T extends null | undefined ? never : T;
// Mapped type
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
// Complex combinations
type ApiEndpoints = {
[K in keyof UserService as `/${Lowercase<string & K>}`]: UserService[K];
};
4. When you need recursive types
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
In Classes: implements Works with Both
A class can use implements with either an interface or an object-shaped type.
interface Printable {
print(): void;
}
type Saveable = {
save(): Promise<void>;
};
// Both can be implemented
class Document implements Printable, Saveable {
print(): void {
console.log("Printing document");
}
async save(): Promise<void> {
console.log("Saving document");
}
}
However, you cannot implements a union type.
type StringOrNumber = string | number;
// class MyClass implements StringOrNumber {} // Error: cannot implement a union type
Practical Example: Library Type Extension (interface) and Complex Unions (type)
Library Type Extension (using interface)
// Add custom metadata to Axios interceptors
// types/axios/index.d.ts
import "axios";
declare module "axios" {
interface AxiosRequestConfig {
_retryCount?: number;
_startTime?: number;
skipAuth?: boolean;
cacheKey?: string;
}
interface AxiosResponse {
duration?: number; // response time in ms
}
}
// Actual usage
import axios from "axios";
axios.interceptors.request.use((config) => {
config._startTime = Date.now();
config._retryCount = 0;
return config;
});
axios.interceptors.response.use((response) => {
if (response.config._startTime) {
response.duration = Date.now() - response.config._startTime;
}
return response;
});
Complex Union State Management (using type)
// Redux-like action types
type UserAction =
| { type: "USER_FETCH_START" }
| { type: "USER_FETCH_SUCCESS"; payload: User }
| { type: "USER_FETCH_ERROR"; error: string }
| { type: "USER_UPDATE"; payload: Partial<User> }
| { type: "USER_DELETE"; id: string }
| { type: "USER_LOGOUT" };
type AuthAction =
| { type: "AUTH_LOGIN"; payload: { token: string; user: User } }
| { type: "AUTH_LOGOUT" }
| { type: "AUTH_TOKEN_REFRESH"; token: string };
type AppAction = UserAction | AuthAction;
// Reducer: all cases are enforced
function userReducer(state: User | null, action: UserAction): User | null {
switch (action.type) {
case "USER_FETCH_SUCCESS":
return action.payload;
case "USER_UPDATE":
return state ? { ...state, ...action.payload } : null;
case "USER_DELETE":
case "USER_LOGOUT":
return null;
case "USER_FETCH_START":
case "USER_FETCH_ERROR":
return state;
}
}
Pro Tips: Team Conventions and ESLint Rules
TypeScript's Official Recommendation
The TypeScript team's official position: "Either is fine, but consistency matters." They do recommend defaulting to interface and using type only when necessary (the TypeScript codebase itself follows this approach).
Enforcing Conventions with ESLint Rules
// .eslintrc.json or eslint.config.js
{
"rules": {
// "interface-over-type": enforce interface as the default
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
// Or enforce type as the default
"@typescript-eslint/consistent-type-definitions": ["error", "type"]
}
}
Recommended Strategy by Project Type
Open-source libraries / SDKs:
- Public types as
interface(users must be able to extend them) - Internal implementation types may freely use
type
General application development:
- Domain models (User, Order, etc.) as
interface - Utility, union, and state types as
type
Practical rules to reduce team confusion:
// Rule 1: interface is required whenever declaration merging is needed
// Rule 2: unions, intersections, and literals always use type
// Rule 3: for everything else, pick one and be consistent across the team
// Enable strict mode in tsconfig.json to ensure type safety
// tsconfig.json
{
"compilerOptions": {
"strict": true, // Strict type checking
"noImplicitAny": true, // Disallow implicit any
"strictNullChecks": true, // Strict null/undefined checks
"noUncheckedIndexedAccess": true, // Index access includes undefined
"exactOptionalPropertyTypes": true // Strict handling of optional properties
}
}
Summary Table
| Situation | Recommended Tool | Reason |
|---|---|---|
| Defining object structure (general) | interface | Readable, easy to extend |
| Public API types | interface | Users can extend via declaration merging |
| Class contracts | interface | implements intent is clear |
| Union types | type | Not expressible with interface |
| Intersection/mixin | type or interface extends | Choose based on context |
| Primitive type aliases | type | Not possible with interface |
| Tuple types | type | Awkward with interface |
| Conditional/mapped types | type | Not possible with interface |
| Recursive types | type | More natural expression |
| Library type augmentation | interface (declaration merging) | Not possible with type |
What's Next...
Section 3.4 covers index signatures and Record, exploring how to handle objects with dynamic keys in a type-safe way. It covers the difference between [key: string]: T index signatures and the Record<K, V> utility type, as well as how to catch runtime errors at compile time using the noUncheckedIndexedAccess compiler option.