Skip to main content

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​

Featureinterfacetype
Define object structureYesYes
Primitive type aliasNotype Name = string
Union typesNotype A = B | C
Intersection typesSimilar via extendstype A = B & C
Literal typesNotype Dir = "left" | "right"
Declaration mergingYes (duplicate same-name declarations)No (causes an error)
How to extendextends keyword& intersection
Recursive typesLimitedNatural and easy
Class implementsYesYes (for object types)
Tuple/array typesLimitedtype Pair = [string, number]
Conditional typesNotype A = T extends U ? X : Y
Mapped typesNotype A = { [K in keyof T]: ... }
Error message readabilityShows the interface nameMay 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"]
}
}

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​

SituationRecommended ToolReason
Defining object structure (general)interfaceReadable, easy to extend
Public API typesinterfaceUsers can extend via declaration merging
Class contractsinterfaceimplements intent is clear
Union typestypeNot expressible with interface
Intersection/mixintype or interface extendsChoose based on context
Primitive type aliasestypeNot possible with interface
Tuple typestypeAwkward with interface
Conditional/mapped typestypeNot possible with interface
Recursive typestypeMore natural expression
Library type augmentationinterface (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.