TypeScript offers two primary ways to define object shapes: types and interfaces. While they often appear interchangeable, understanding their differences helps you write more maintainable TypeScript code and choose the right tool for each situation.
This guide explores the practical differences between types and interfaces, their unique capabilities, and when to use each in your TypeScript projects.
What are TypeScript interfaces?
Interfaces in TypeScript define the structure of objects. They describe what properties an object should have and what types those properties should be:
interface User {
id: number;
name: string;
email: string;
isActive: boolean;
}
const user: User = {
id: 1,
name: "John Doe",
email: "john@example.com",
isActive: true
};
Interfaces are particularly useful when you need to describe the shape of objects throughout your application. They provide clear contracts that your code must follow, and TypeScript will enforce these contracts at compile time. When you try to assign a value that doesn't match the interface structure, TypeScript immediately catches the error before your code runs.
The name "interface" comes from object-oriented programming, where interfaces define contracts that classes must implement. In TypeScript, interfaces serve a broader purpose beyond just class contracts. They're commonly used to type plain JavaScript objects, function parameters, and return values.
You can also define optional properties and readonly properties in interfaces:
interface User {
readonly id: number;
name: string;
email: string;
phone?: string;
address?: {
street: string;
city: string;
country: string;
};
}
const user: User = {
id: 1,
name: "John Doe",
email: "john@example.com"
};
// user.id = 2; // Error: Cannot assign to 'id'
The readonly modifier prevents properties from being modified after the object is created. This is useful for immutable data structures or when you want to ensure certain values remain constant. Optional properties, marked with ?, allow you to define properties that may or may not be present on an object.
Interfaces also support index signatures, which allow you to define objects with dynamic property names:
interface StringMap {
[key: string]: string;
}
const translations: StringMap = {
hello: "Hola",
goodbye: "Adiós",
thanks: "Gracias"
};
interface NumberDictionary {
[index: string]: number;
length: number;
name: string; // Error: must be number
}
What are TypeScript types?
Type aliases create names for any type, not just object shapes. They're more flexible than interfaces and can represent primitives, unions, intersections, and complex type compositions:
type UserID = number;
type Status = "active" | "inactive" | "pending";
type User = {
id: UserID;
name: string;
status: Status;
};
The key word here is "alias"—types create alternative names for existing types. This might seem simple, but it becomes powerful when you need to create complex type definitions or reuse type patterns across your application.
Types shine when you need to create union types, intersection types, or alias primitive types for better code readability. They can represent any valid TypeScript type, making them incredibly versatile:
type ID = string | number;
type Coordinates = [number, number];
type Callback = (data: string) => void;
type ApiResponse<T> = {
data: T;
status: number;
headers: Record<string, string>;
};
One of the most powerful aspects of type aliases is their ability to represent things that interfaces simply cannot. While interfaces are restricted to describing object shapes, types can describe any valid TypeScript type, including primitives, unions, tuples, and more complex type expressions.
Types also support all the same object shape features as interfaces, including optional and readonly properties:
type Product = {
readonly id: string;
name: string;
price: number;
description?: string;
tags: string[];
};
const product: Product = {
id: "prod-123",
name: "Laptop",
price: 999,
tags: ["electronics", "computers"]
};
Types vs interfaces: a quick comparison
Understanding when to use each requires knowing their capabilities and limitations:
| Feature | Interface | Type |
|---|---|---|
| Object shape definition | Yes | Yes |
| Extending other types | Via extends |
Via & intersection |
| Declaration merging | Yes | No |
| Union types | No | Yes |
| Intersection types | No | Yes |
| Primitive type aliases | No | Yes |
| Tuple types | No | Yes |
| Mapped types | No | Yes |
| Conditional types | No | Yes |
| Function types | Yes | Yes |
| Class implementation | Yes | Yes |
| Performance | Slightly faster | Slightly slower |
| Error messages | Often clearer | Can be verbose |
This comparison reveals that types are technically more powerful since they can do everything interfaces can do, plus additional features. However, this doesn't mean you should always use types. Interfaces have their own advantages, particularly around declaration merging, performance, and readability.
Extending and composing types
Both interfaces and types support extension and composition, but they handle it differently. Understanding these differences helps you choose the right approach for your specific needs.
Interfaces use the extends keyword to inherit from other interfaces:
interface Person {
name: string;
age: number;
}
interface Employee extends Person {
employeeId: number;
department: string;
}
const employee: Employee = {
name: "Jane Smith",
age: 30,
employeeId: 12345,
department: "Engineering"
};
The extends keyword creates a clear inheritance relationship. When you extend an interface, you're creating a new interface that includes all properties from the base interface plus any additional properties you define. This is intuitive for developers coming from object-oriented programming backgrounds.
You can extend multiple interfaces at once, which is useful for composing functionality from different sources:
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
interface Deletable {
deletedAt?: Date;
isDeleted: boolean;
}
interface User extends Identifiable, Timestamped, Deletable {
name: string;
email: string;
}
This composition pattern is common in domain modeling where entities share common traits. For example, most database entities need timestamps and unique identifiers. Rather than repeating these properties across multiple interfaces, you can define them once and extend them wherever needed.
Types use intersection types (&) to combine multiple types:
type Person = {
name: string;
age: number;
};
type Employee = Person & {
employeeId: number;
department: string;
};
const employee: Employee = {
name: "Jane Smith",
age: 30,
employeeId: 12345,
department: "Engineering"
};
Intersection types work by combining all properties from multiple types into a single type. The resulting type must satisfy all constituent types simultaneously. While this achieves similar results to interface extension, the syntax and mental model are slightly different.
Types can also combine with union types for more complex compositions:
type BasePost = {
title: string;
content: string;
author: string;
};
type DraftPost = BasePost & {
status: "draft";
lastEdited: Date;
};
type PublishedPost = BasePost & {
status: "published";
publishedAt: Date;
views: number;
};
type ArchivedPost = BasePost & {
status: "archived";
archivedAt: Date;
reason: string;
};
type Post = DraftPost | PublishedPost | ArchivedPost;
This pattern creates what's called a discriminated union. The status property acts as a discriminator, allowing TypeScript to narrow the type based on its value. When you check post.status === "published", TypeScript knows the post has publishedAt and views properties.
The flexibility of combining intersections and unions is one of the main advantages of types over interfaces. You can model complex domain logic directly in the type system, catching errors at compile time that would otherwise only appear at runtime.
Declaration merging with interfaces
One unique feature of interfaces is declaration merging. When you declare an interface with the same name multiple times, TypeScript automatically merges them into a single interface:
interface User {
id: number;
name: string;
}
interface User {
email: string;
}
interface User {
createdAt: Date;
}
// TypeScript merges all three declarations
const user: User = {
id: 1,
name: "John Doe",
email: "john@example.com",
createdAt: new Date()
};
At first glance, declaration merging might seem like a bug waiting to happen. Why would you want to declare the same interface multiple times? However, this feature becomes incredibly valuable in specific scenarios, particularly when working with third-party libraries or building extensible systems.
This feature is particularly useful when extending third-party library types or when building plugin systems. For example, extending the Express Request object to add custom properties:
// Extending Express Request type
declare global {
namespace Express {
interface Request {
user?: {
id: string;
email: string;
roles: string[];
};
requestId: string;
startTime: number;
}
}
}
// Now Request has the user, requestId, and startTime properties
app.use((req, res, next) => {
req.requestId = generateId();
req.startTime = Date.now();
next();
});
app.get('/profile', (req, res) => {
const userId = req.user?.id;
const duration = Date.now() - req.startTime;
console.log(`Request ${req.requestId} took ${duration}ms`);
});
This pattern is essential when working with Express middleware. Each middleware might add different properties to the request object, and declaration merging allows you to extend the type definition incrementally across different files.
This is also how popular libraries like @types/node add type definitions incrementally across multiple files. Each file can declare the same interface, and TypeScript merges them all together. The Node.js types are split across dozens of files, each declaring parts of global interfaces like NodeJS.ProcessEnv or NodeJS.Global.
Another common use case is plugin systems. If you're building a framework that supports plugins, declaration merging allows plugin authors to extend your core types:
// Core framework
interface PluginAPI {
registerCommand(name: string, handler: Function): void;
}
// Plugin A adds methods
interface PluginAPI {
logInfo(message: string): void;
logError(error: Error): void;
}
// Plugin B adds more methods
interface PluginAPI {
showNotification(text: string): void;
}
// All plugins' methods are now available
const api: PluginAPI = {
registerCommand: (name, handler) => {},
logInfo: (msg) => console.log(msg),
logError: (err) => console.error(err),
showNotification: (text) => alert(text)
};
Types don't support declaration merging. Attempting to declare a type twice results in an error:
type User = {
id: number;
name: string;
};
// Error: Duplicate identifier 'User'
type User = {
email: string;
};
This is by design. Types are meant to be explicit and complete in a single declaration. While this limits flexibility in certain scenarios, it also makes code more predictable and easier to understand at a glance.
Union and intersection types
Types excel at creating unions and intersections, which interfaces can't do directly. These are fundamental building blocks for modeling complex domain logic in TypeScript.
Union types represent values that can be one of several types:
type Success = {
status: "success";
data: any;
};
type Error = {
status: "error";
message: string;
code: number;
};
type Result = Success | Error;
function handleResult(result: Result) {
if (result.status === "success") {
console.log(result.data);
} else {
console.log(`Error ${result.code}: ${result.message}`);
}
}
Union types are particularly useful for representing mutually exclusive states. In the example above, a result is either successful or failed—it can't be both simultaneously. TypeScript enforces this constraint at compile time.
This pattern is incredibly useful for discriminated unions, where TypeScript can narrow types based on a common property:
type Circle = {
kind: "circle";
radius: number;
};
type Rectangle = {
kind: "rectangle";
width: number;
height: number;
};
type Triangle = {
kind: "triangle";
base: number;
height: number;
};
type Shape = Circle | Rectangle | Triangle;
function getArea(shape: Shape): number {
switch (shape.kind) {
case "circle":
return Math.PI * shape.radius ** 2;
case "rectangle":
return shape.width * shape.height;
case "triangle":
return (shape.base * shape.height) / 2;
}
}
The kind property serves as a discriminator. Once you check its value, TypeScript knows exactly which type you're working with and gives you access to the appropriate properties. This technique is also called tagged unions or algebraic data types.
Discriminated unions are exhaustive by default. If you add a new shape type but forget to handle it in your switch statement, TypeScript will report an error. This makes your code more maintainable and less prone to bugs as your application evolves.
Union types are also useful for representing loading states in applications:
type LoadingState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
type UserState = LoadingState<User>;
function UserProfile({ state }: { state: UserState }) {
switch (state.status) {
case "idle":
return <div>Click to load user</div>;
case "loading":
return <div>Loading...</div>;
case "success":
return <div>Welcome, {state.data.name}!</div>;
case "error":
return <div>Error: {state.error}</div>;
}
}
This pattern makes impossible states impossible to represent. You can't have status: "success" without data, or status: "error" without an error message. The type system enforces these constraints automatically.
Intersection types combine multiple types into one, requiring the value to satisfy all types:
type Draggable = {
drag: () => void;
dragStart: (x: number, y: number) => void;
dragEnd: () => void;
};
type Resizable = {
resize: (width: number, height: number) => void;
minWidth: number;
minHeight: number;
};
type Rotatable = {
rotate: (degrees: number) => void;
currentRotation: number;
};
type UIElement = Draggable & Resizable & Rotatable & {
render: () => void;
id: string;
};
Intersection types are useful for composing behavior from multiple sources. In this example, UIElement combines dragging, resizing, and rotating capabilities into a single type. Any value of type UIElement must implement all these properties and methods.
The difference between unions and intersections can be confusing at first. Unions represent "either/or" relationships (A or B), while intersections represent "both/and" relationships (A and B). Unions typically make types broader (accepting more values), while intersections make types narrower (requiring more properties).
Primitive type aliases
Types can create aliases for primitive types, making your code more self-documenting and easier to refactor:
type UserID = string;
type Timestamp = number;
type EmailAddress = string;
type PositiveNumber = number;
type User = {
id: UserID;
email: EmailAddress;
createdAt: Timestamp;
loginCount: PositiveNumber;
};
function getUserById(id: UserID): User | null {
return null;
}
function sendEmail(
to: EmailAddress,
subject: string,
body: string
): void {
// Implementation
}
These aliases don't provide runtime validation (the types are erased after compilation), but they make your code more readable and self-documenting. When you see EmailAddress instead of string, you immediately know what kind of string is expected.
Primitive type aliases also make refactoring easier. If you later decide to change UserID from string to number, you only need to update it in one place. All functions that use UserID automatically get the new type.
This technique is particularly valuable in large codebases where the same primitive types are used throughout the application. Instead of scattering string and number types everywhere, you create semantic aliases that convey meaning:
type Milliseconds = number;
type Percentage = number;
type Degrees = number;
type Pixels = number;
function delay(duration: Milliseconds): Promise<void> {
return new Promise(resolve => setTimeout(resolve, duration));
}
function rotate(element: Element, angle: Degrees): void {
element.style.transform = `rotate(${angle}deg)`;
}
function setOpacity(element: Element, opacity: Percentage): void {
element.style.opacity = String(opacity / 100);
}
While Milliseconds, Percentage, and Degrees are all just numbers at runtime, the type aliases make the code's intent crystal clear. You're less likely to accidentally pass degrees where milliseconds are expected.
Interfaces can't create aliases for primitives:
// Error: An interface can only extend an object type
interface UserID extends string {}
This limitation stems from how interfaces work. They're designed specifically for describing object shapes, not for aliasing primitive types or creating more complex type expressions.
When to use interfaces vs types
After understanding all the differences, here's practical guidance on when to use each. The choice often comes down to specific requirements and team conventions.
Use interfaces when:
You're defining object shapes that might be extended later. Interfaces are perfect for public APIs where consumers might want to extend your types:
// Library code
export interface PluginOptions {
enabled: boolean;
config: Record<string, any>;
}
// Consumers can extend via declaration merging
Declaration merging makes interfaces ideal for plugin systems, library APIs, and any scenario where third-party code might need to extend your types.
You're working with classes and want to define contracts:
interface Repository<T> {
find(id: string): Promise<T | null>;
save(entity: T): Promise<void>;
delete(id: string): Promise<void>;
findAll(): Promise<T[]>;
}
class UserRepository implements Repository<User> {
async find(id: string) { /* ... */ }
async save(user: User) { /* ... */ }
async delete(id: string) { /* ... */ }
async findAll() { /* ... */ }
}
Interfaces feel natural for defining class contracts, especially for developers familiar with object-oriented programming.
You want better IDE autocomplete and clearer error messages for object types. Interfaces are optimized for these scenarios and generally provide a better developer experience when working with object shapes.
Use types when:
You need unions, intersections, or any advanced type features:
type Result<T> = Success<T> | Failure;
type Middleware = (req: Request, res: Response) => void | Promise<void>;
type DeepPartial<T> = { [K in keyof T]?: DeepPartial<T[K]> };
These patterns are impossible with interfaces. Any time you need type-level computation or complex type transformations, types are your only option.
You're creating utility types or type helpers:
type Nullable<T> = T | null;
type ValueOf<T> = T[keyof T];
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type NonOptional<T> = { [K in keyof T]-?: T[K] };
Utility types leverage advanced type features that only type aliases support.
You're aliasing primitives or creating type combinations:
type ID = string | number;
type Timestamp = number;
type Coordinates = [number, number];
type Status = "active" | "inactive" | "suspended";
These simple aliases improve code readability and maintainability.
Final thoughts
Types and interfaces in TypeScript both define contracts, but they shine in different scenarios. Interfaces are best for object shapes, class contracts, and public APIs that might be extended. Types are ideal for unions, intersections, and advanced type utilities.
Modern TypeScript has made type aliases very powerful. Types can express most patterns that interfaces can, plus more advanced behavior. This is why many developers lean toward types for flexibility.
Interfaces are still valuable in the right context. They work especially well in large or extensible codebases where declaration merging and clear error messages help.
As a rule of thumb, use interfaces for simple object shapes and class implementations, and types for advanced type logic. Consistency in your codebase matters more than always choosing one over the other.