TypeScript offers two ways to name a type: interfaces and type aliases. They overlap significantly — both can describe object shapes, both can be extended, and both work with generics. But they have key differences that affect which to use in specific situations. Understanding those differences, plus TypeScript’s structural typing system, is what takes you from writing types mechanically to using them intentionally.
Structural Typing
TypeScript uses structural typing (also called duck typing), not nominal typing. Two types are compatible if they have the same structure — the names don’t matter. This is the foundation everything else builds on.
interface Point2D {
x: number;
y: number;
}
// This plain object literal is compatible with Point2D — same structure
const origin: Point2D = { x: 0, y: 0 };
// A class with extra properties is also compatible — structural typing is permissive about extras
class Point3D {
constructor(public x: number, public y: number, public z: number) {}
}
const p3d = new Point3D(1, 2, 3);
const p2d: Point2D = p3d; // ✅ valid — Point3D has all the required fields
function distance(a: Point2D, b: Point2D): number {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
// Works — Point3D satisfies Point2D structurally
console.log(distance(p3d, origin));
This matters when designing APIs: consumers can pass any object that satisfies your interface’s shape, not just objects explicitly typed as that interface.
Type Aliases
A type alias gives a name to any type expression — including unions, intersections, tuples, mapped types, and conditional types. This is broader than what interfaces can do.
// Union type — only possible with type aliases
type Status = 'active' | 'inactive' | 'pending' | 'suspended';
// Tuple type — represents a fixed-length array with typed positions
type Coordinate = [number, number];
type RGB = [number, number, number];
// Function type
type Predicate<T> = (value: T) => boolean;
type AsyncFn<T, R> = (arg: T) => Promise<R>;
// Discriminated union — each variant has a unique literal type field
type Result<T> =
| { success: true; data: T }
| { success: false; error: string; code: number };
// Usage: TypeScript narrows the type based on the success field
function handleResult<T>(result: Result<T>) {
if (result.success) {
console.log(result.data); // TypeScript knows data exists here
} else {
console.error(`Error ${result.code}: ${result.error}`); // knows error and code exist
}
}
Discriminated unions are one of TypeScript’s most powerful patterns. They model state machines explicitly — you can’t accidentally access error on a success result.
Interfaces
Interfaces define object shapes and are the right tool for two specific use cases: describing objects you’ll extend or implement, and defining public API contracts.
The defining feature of interfaces is declaration merging: you can declare the same interface name multiple times, and TypeScript merges the declarations. Type aliases cannot be re-declared.
interface UserBase {
id: number;
email: string;
}
// Declaration merging — add fields to an existing interface
interface UserBase {
createdAt: Date;
}
// The merged interface has all three fields
const user: UserBase = { id: 1, email: '[email protected]', createdAt: new Date() };
Declaration merging is how TypeScript’s library typings work — you extend existing interfaces to add platform-specific properties (like extending Window or Request).
Interface extension composes cleanly:
interface Named {
name: string;
}
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface User extends Named, Timestamped {
id: number;
email: string;
role: 'admin' | 'user';
}
// Partial and Pick utility types work with both interfaces and type aliases
type CreateUserDto = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UserSummary = Pick<User, 'id' | 'name' | 'email'>;
Interface vs Type Alias: The Practical Guide
The teams that argue most about this are often missing the actual decision criteria. Use the right tool for the purpose:
| Use case | Prefer |
|---|---|
| Object shape with possible extension | interface |
| Union or intersection type | type |
| Discriminated union | type |
| Function or callback signature | type |
Class implements contract |
interface |
| Extending a third-party library’s type | interface (declaration merging) |
| Generic utility types | type |
| Tuples | type |
The TypeScript team’s own recommendation: use interface until you need a feature only type provides. The key type-only features are union types, tuples, and conditional types.
// interface — describes an object shape, intended for extension
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(filter?: Partial<T>): Promise<T[]>;
create(data: Omit<T, 'id'>): Promise<T>;
update(id: string, data: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
// type — a union of possible states, can't be expressed as an interface
type ConnectionState =
| { status: 'connecting' }
| { status: 'connected'; since: Date }
| { status: 'error'; message: string }
| { status: 'disconnected' };
Generics with Interfaces and Type Aliases
Both support generics. Generic interfaces describe flexible contracts; generic type aliases build utility types:
// Generic interface — a typed API response wrapper
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: number;
}
interface PaginatedResponse<T> extends ApiResponse<T[]> {
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// Usage — TypeScript knows the exact shape of data
async function fetchUser(id: string): Promise<ApiResponse<User>> {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// The caller gets full type inference
const result = await fetchUser('123');
console.log(result.data.email); // TypeScript knows email exists
Generic type aliases build TypeScript’s utility types (and your own):
// Recursive utility type — make all nested fields optional
type DeepPartial<T> = {
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
// Extract the resolved type from a Promise
type Awaited<T> = T extends Promise<infer U> ? U : T;
// Require at least one of the specified keys
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> = Pick<T, Exclude<keyof T, Keys>>
& { [K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>> }[Keys];
Built-in Utility Types
TypeScript ships a set of utility types that should be in your daily toolkit:
interface User {
id: number;
name: string;
email: string;
password: string;
role: 'admin' | 'user';
createdAt: Date;
}
// Partial — all fields become optional (useful for update operations)
type UpdateUserDto = Partial<User>;
// Required — all fields become required (inverse of Partial)
type RequiredUser = Required<User>;
// Pick — select a subset of fields
type UserPreview = Pick<User, 'id' | 'name' | 'email'>;
// Omit — exclude specific fields
type CreateUserDto = Omit<User, 'id' | 'createdAt'>;
type SafeUser = Omit<User, 'password'>; // Strip sensitive fields before serializing
// Readonly — prevent mutation (all fields become readonly)
type ImmutableUser = Readonly<User>;
// Record — map keys to a value type
type UserById = Record<number, User>;
const userCache: UserById = {};
// ReturnType — extract the return type of a function
function getUser() { return { id: 1, name: 'Alice' }; }
type UserShape = ReturnType<typeof getUser>; // { id: number; name: string }
// Parameters — extract the parameter types of a function
type GetUserParams = Parameters<typeof getUser>; // []
These compose well. SafeUser = Omit<User, 'password'> combined with Readonly<SafeUser> gives you an immutable view of a user object safe to pass to clients.
Designing Service Contracts with Interfaces
Interfaces work well as contracts between layers — the service layer declares what it needs, and any class that satisfies the interface can be used:
// Define the contract as an interface
interface IUserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
create(data: CreateUserDto): Promise<User>;
update(id: string, data: UpdateUserDto): Promise<User | null>;
delete(id: string): Promise<boolean>;
}
// A concrete implementation — MongoDB
class MongoUserRepository implements IUserRepository {
async findById(id: string): Promise<User | null> {
return UserModel.findById(id).lean();
}
async findByEmail(email: string): Promise<User | null> {
return UserModel.findOne({ email }).lean();
}
async create(data: CreateUserDto): Promise<User> {
const doc = await UserModel.create(data);
return doc.toObject();
}
async update(id: string, data: UpdateUserDto): Promise<User | null> {
return UserModel.findByIdAndUpdate(id, data, { new: true }).lean();
}
async delete(id: string): Promise<boolean> {
const result = await UserModel.findByIdAndDelete(id);
return result !== null;
}
}
// A test double that also satisfies the same interface
class InMemoryUserRepository implements IUserRepository {
private store = new Map<string, User>();
async findById(id: string) { return this.store.get(id) ?? null; }
async findByEmail(email: string) {
return [...this.store.values()].find(u => u.email === email) ?? null;
}
async create(data: CreateUserDto) {
const user = { ...data, id: String(Date.now()), createdAt: new Date() };
this.store.set(user.id, user);
return user;
}
async update(id: string, data: UpdateUserDto) {
const user = this.store.get(id);
if (!user) return null;
const updated = { ...user, ...data };
this.store.set(id, updated);
return updated;
}
async delete(id: string) { return this.store.delete(id); }
}
// The service depends on the interface, not the implementation
class UserService {
constructor(private repo: IUserRepository) {}
async getUserByEmail(email: string): Promise<SafeUser> {
const user = await this.repo.findByEmail(email);
if (!user) throw new Error('User not found');
const { password: _, ...safe } = user;
return safe;
}
}
// In production
const service = new UserService(new MongoUserRepository());
// In tests
const testService = new UserService(new InMemoryUserRepository());
This pattern — depending on interfaces, not concrete classes — makes code testable without mocking frameworks and swappable without changing the service.
Summary
TypeScript’s type system works best when you understand the tools and their purposes:
- Interface for object shapes that will be extended, implemented by classes, or need declaration merging
- Type alias for unions, discriminated unions, function types, tuples, and complex mapped/conditional types
- Structural typing means any object that satisfies a type’s shape is compatible — design your interfaces around the minimum structure you need
- Utility types (
Partial,Pick,Omit,Readonly,Record) are composable and eliminate repetitive type declarations - Generic interfaces as service contracts let you swap implementations without changing consumers — the foundation of testable, maintainable code
Resources
- TypeScript Handbook: Object Types
- TypeScript Handbook: Type Manipulation
- TypeScript Utility Types
- TypeScript Generics
- TypeScript Deep Dive (Basarat)
Comments