Skip to main content
Advertisement

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.

Advertisement