TypeScript Interface vs Type: When to Use Which?
TypeScript gives you two main ways to define object shapes: interfaces and types. While they’re similar, understanding their differences helps you write better, more maintainable code. In this post, we’ll break down when to use each one.
Introduction to TypeScript Interfaces and Types
Core Concepts - Interfaces and Types
Interfaces define object shapes and can be extended or implemented by classes. Types are type aliases that can represent any valid TypeScript type, including primitives, unions, and intersections.
// Interface
interface User {
name: string;
age: number;
}
// Type alias
type UserType = {
name: string;
age: number;
};
Fundamental Differences Between Interfaces and Types
Syntax and Declaration
Interface syntax:
interface Person {
name: string;
age: number;
greet(): void;
}
// Extending interfaces
interface Employee extends Person {
employeeId: string;
department: string;
}
Type alias syntax:
type Person = {
name: string;
age: number;
greet(): void;
};
// Extending types (using intersection)
type Employee = Person & {
employeeId: string;
department: string;
};
Key differences:
- Interfaces use the
interface
keyword, types usetype
- Interfaces can only describe object shapes, types can be anything
- Interfaces support declaration merging, types don’t
Primitive and Complex Types
Interfaces are limited to objects:
// ✅ Valid interface - it represents the shape of an object
interface Config {
apiUrl: string;
timeout: number;
}
// ❌ Invalid - interfaces can't represent primitives directly
// interface StringType = string;
// ❌ Invalid - interfaces can't represent unions directly
// interface StringOrNumber = string | number;
Types can represent anything (both primitives and object shapes):
// ✅ Valid type aliases
type StringType = string;
type NumberType = number;
type StringOrNumber = string | number;
type TupleType = [string, number];
type FunctionType = (x: number) => string;
// ✅ Complex type compositions
type Status = 'loading' | 'success' | 'error';
type ApiResponse<T> = {
data: T;
status: Status;
message?: string;
};
Object and Function Descriptions
Describing objects:
// Both work equally well for simple objects
interface User {
id: number;
name: string;
email: string;
}
type UserType = {
id: number;
name: string;
email: string;
};
// Function types
interface MathFunc {
(x: number, y: number): number;
}
type MathFuncType = (x: number, y: number) => number;
Function overloads:
// Interfaces support function overloads
interface Calculator {
add(x: number, y: number): number;
add(x: string, y: string): string;
}
// Types can also represent overloads
type CalculatorType = {
add(x: number, y: number): number;
add(x: string, y: string): string;
};
Advanced Features and Capabilities
Declaration Merging
Interfaces support declaration merging:
interface User {
name: string;
}
interface User {
age: number;
}
// Result: User has both name and age
const user: User = {
name: "John",
age: 30
};
Types don’t support merging:
type User = {
name: string;
};
// ❌ Error: Duplicate identifier 'User'
// type User = {
// age: number;
// };
Practical use cases for merging:
// Extending library types
interface Window {
myCustomProperty: string;
}
// Now window.myCustomProperty is available
window.myCustomProperty = "Hello";
Extending and Implementing
Extending interfaces:
interface Animal {
name: string;
makeSound(): void;
}
interface Dog extends Animal {
breed: string;
bark(): void;
}
const myDog: Dog = {
name: "Buddy",
breed: "Golden Retriever",
makeSound() {
this.bark();
},
bark() {
console.log("Woof!");
}
};
Extending types (using intersection):
type Animal = {
name: string;
makeSound(): void;
};
type Dog = Animal & {
breed: string;
bark(): void;
};
Class implementation:
interface Vehicle {
start(): void;
stop(): void;
}
class Car implements Vehicle {
start() {
console.log("Car starting...");
}
stop() {
console.log("Car stopping...");
}
}
// Types can also be implemented
type VehicleType = {
start(): void;
stop(): void;
};
class Motorcycle implements VehicleType {
start() {
console.log("Motorcycle starting...");
}
stop() {
console.log("Motorcycle stopping...");
}
}
Specialized Type Features
Mapped types (types only):
type Readonly<T> = {
readonly [P in keyof T]: T[P];
};
type User = {
name: string;
age: number;
};
type ReadonlyUser = Readonly<User>;
// Result: { readonly name: string; readonly age: number; }
Conditional types (types only):
type NonNullable<T> = T extends null | undefined ? never : T;
type StringOrNull = string | null;
type NonNullString = NonNullable<StringOrNull>; // string
Utility types (types only):
type Partial<T> = {
[P in keyof T]?: T[P];
};
type Required<T> = {
[P in keyof T]-?: T[P];
};
type Pick<T, K extends keyof T> = {
[P in K]: T[P];
};
type Omit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;
Performance and Tooling Considerations
Compilation and Runtime Performance
Compilation time:
- Interfaces are generally faster to compile
- Types with complex unions/intersections can slow compilation
- Large projects benefit from interface performance
Runtime performance:
- No runtime difference - both are erased during compilation
- TypeScript is a development tool, not a runtime library
Developer Experience and Tooling
IDE support:
- Both have excellent IntelliSense support
- Interfaces show clearer error messages for missing properties
- Types provide better support for complex type operations
Error messaging:
interface User {
name: string;
age: number;
}
const user: User = {
name: "John"
// Error: Property 'age' is missing in type '{ name: string; }'
};
type UserType = {
name: string;
age: number;
};
const userType: UserType = {
name: "John"
// Similar error message
};
Best Practices and Usage Guidelines
When to Use Interfaces
Use interfaces for:
- Object-oriented design patterns
- API contracts and public interfaces
- When you need declaration merging
- Simple object shapes that might be extended
// Good interface use case
interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
interface UserApiResponse extends ApiResponse<User> {
user: User;
}
Object-oriented patterns:
interface Logger {
log(message: string): void;
error(message: string): void;
}
class ConsoleLogger implements Logger {
log(message: string) {
console.log(message);
}
error(message: string) {
console.error(message);
}
}
When to Use Types
Use types for:
- Complex type compositions
- Union and intersection types
- Mapped and conditional types
- Function types and overloads
- When you need to represent non-object types
// Good type use case
type Status = 'idle' | 'loading' | 'success' | 'error';
type ApiState<T> = {
data: T | null;
status: Status;
error: string | null;
};
type AsyncFunction<T> = (...args: any[]) => Promise<T>;
Complex type operations:
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
type EventMap = {
click: MouseEvent;
keydown: KeyboardEvent;
submit: Event;
};
type EventHandler<T extends keyof EventMap> = (event: EventMap[T]) => void;
Hybrid Approaches
Combining both effectively:
// Use interfaces for public APIs
interface UserService {
getUser(id: number): Promise<User>;
createUser(user: CreateUserRequest): Promise<User>;
}
// Use types for internal implementations
type CreateUserRequest = Omit<User, 'id' | 'createdAt'>;
type UserUpdateRequest = Partial<CreateUserRequest>;
// Use types for utility functions
type ValidationResult<T> = {
isValid: boolean;
errors: string[];
data?: T;
};
Real-world Applications and Case Studies
Frontend Development
React component props:
interface ButtonProps {
children: React.ReactNode;
variant: 'primary' | 'secondary' | 'danger';
size: 'small' | 'medium' | 'large';
onClick?: () => void;
disabled?: boolean;
}
const Button: React.FC<ButtonProps> = ({ children, variant, size, onClick, disabled }) => {
// Component implementation
};
State management:
interface AppState {
user: User | null;
isLoading: boolean;
error: string | null;
}
type AppAction =
| { type: 'SET_USER'; payload: User }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null };
Backend Development
Express.js with TypeScript:
interface RequestBody {
name: string;
email: string;
age?: number;
}
interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
app.post('/users', (req: Request<{}, {}, RequestBody>, res: Response<ApiResponse<User>>) => {
// Route implementation
});
Database models:
interface User {
id: number;
name: string;
email: string;
createdAt: Date;
updatedAt: Date;
}
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
type UpdateUserInput = Partial<CreateUserInput>;
Full-stack TypeScript Projects
Shared types:
// shared/types.ts
export interface User {
id: number;
name: string;
email: string;
}
export type UserRole = 'admin' | 'user' | 'moderator';
export interface ApiResponse<T> {
data: T;
status: 'success' | 'error';
message?: string;
}
Conclusion and Best Practices Summary
Recap of Key Differences
Feature | Interface | Type |
---|---|---|
Object shapes | ✅ | ✅ |
Primitives | ❌ | ✅ |
Unions/Intersections | ❌ | ✅ |
Declaration merging | ✅ | ❌ |
Mapped types | ❌ | ✅ |
Conditional types | ❌ | ✅ |
Performance | ✅ | ⚠️ |
Decision-making Framework
Choose Interface when:
- Defining object shapes for classes
- Creating public APIs
- You might need declaration merging
- Working with object-oriented patterns
Choose Type when:
- You need unions or intersections
- Working with primitives
- Creating complex type compositions
- Using advanced TypeScript features
Hybrid approach:
- Use interfaces for public contracts
- Use types for internal implementations
- Use types for utility and helper types
Final Thoughts
Both interfaces and types are powerful tools in TypeScript. The key is understanding their strengths and using the right tool for the job. Start with interfaces for simple object shapes, and reach for types when you need more flexibility.