How to Extend Interfaces in TypeScript
Every time you update your User interface, you're hunting through AdminUser, GuestUser, and VIPUser to make the same changes. The base properties—id, name, email—are duplicated across all four interfaces. Keeping them synchronized is tedious and error-prone.
Interface extension eliminates this duplication. Define your base properties once in User, extend them into specialized types, and updates flow automatically to all variants. You can add new properties, combine multiple interfaces, or create type hierarchies while maintaining full type safety. This guide covers practical extension patterns from basic inheritance to advanced techniques like declaration merging and extending classes—patterns you'll use every day in real applications.
Adding New Properties by Extending an Interface in TypeScript
To add new properties to an interface in TypeScript, use the extends keyword:
interface User {
name: string;
email: string;
}
interface Admin extends User {
role: string;
permissions: string[];
}
const admin: Admin = {
name: 'Sarah Chen',
email: 'sarah@company.com',
role: 'admin',
permissions: ['read', 'write', 'delete'],
};
The Admin interface inherits all properties from User and adds its own properties. TypeScript verifies that any Admin object includes both the inherited properties (name and email) and the new ones (role and permissions).
You don't retype the same properties across multiple interfaces. When you update User, those changes flow automatically to Admin and any other interfaces that extend it. This keeps your type hierarchies maintainable and mirrors your application's data relationships.
Interface extension works with TypeScript interfaces to create modular, reusable definitions throughout your codebase.
Combining Multiple Interfaces Through Extension
You can extend multiple interfaces at once to compose complex types from simpler ones:
interface Address {
street: string;
city: string;
state: string;
zip: string;
}
interface ContactInfo {
phone: string;
email: string;
}
interface Customer extends Address, ContactInfo {
customerId: string;
memberSince: Date;
}
const customer: Customer = {
customerId: 'CUST-12345',
memberSince: new Date('2023-01-15'),
street: '123 Main St',
city: 'San Francisco',
state: 'CA',
zip: '94102',
phone: '555-0100',
email: 'customer@example.com',
};
The Customer interface combines properties from both Address and ContactInfo, plus its own specific properties. This modular approach lets you separate concerns. Address information in one interface, contact details in another. Customer brings them together without repeating definitions.
You build precise types from smaller, focused interfaces rather than creating monolithic structures. That's composition over inheritance in TypeScript.
When to Use Interface Extension vs Type Intersection
TypeScript gives you two ways to combine types: interface extension with extends and type intersection with &. Here's when to use each:
// Interface extension - use when building hierarchies
interface User {
name: string;
email: string;
}
interface Admin extends User {
role: string;
}
// Type intersection - use for one-off combinations
type AdminUser = User & { role: string };
// Interface extension allows reopening (declaration merging)
interface User {
lastLogin?: Date; // This merges with the original User
}
// Type aliases cannot be reopened
type BasicUser = { name: string };
// type BasicUser = { email: string }; // Error: Duplicate identifier
Use interface extension when:
- You're building a clear inheritance hierarchy
- You might need declaration merging later
- You want better error messages (they reference the interface name)
- You're defining object shapes
Use type intersection when:
- You're combining types you don't control
- You need to intersect with union types or other complex types
- You're creating a one-off combination
- You're working with TypeScript utility types
The official TypeScript documentation recommends preferring interfaces when possible, as they generally provide better performance and clearer error messages. For more context on this decision, check out TypeScript interface vs type.
Declaration Merging: Extending Interfaces Across Files
TypeScript lets you declare the same interface multiple times, and it'll merge them automatically. This is incredibly useful when extending third-party library types or organizing large interfaces across files:
// types/user.ts
interface User {
id: string;
name: string;
}
// types/auth.ts
interface User {
token: string;
expiresAt: Date;
}
// types/profile.ts
interface User {
avatar?: string;
bio?: string;
}
// Now User has all properties from all declarations
const user: User = {
id: '123',
name: 'Alex Morgan',
token: 'abc-def-ghi',
expiresAt: new Date('2024-12-31'),
avatar: 'https://example.com/avatar.jpg',
bio: 'Developer and TypeScript enthusiast',
};
This is especially useful with libraries like Express or React. You can augment their types without modifying the original definitions:
// Extending Express Request type
import { Request } from 'express';
declare global {
namespace Express {
interface Request {
user?: {
id: string;
role: string;
};
}
}
}
// Now req.user is properly typed in your route handlers
app.get('/profile', (req, res) => {
if (req.user) {
console.log(req.user.id); // TypeScript knows about this
}
});
Important rules for declaration merging:
- Non-function properties must have identical types if declared multiple times
- Function members are treated as overloads
- Later declarations have higher precedence for function overloads
For more on augmenting types, see TypeScript declare.
Interfaces Extending Classes in TypeScript
TypeScript has a unique feature: interfaces can extend classes. When this happens, the interface inherits the class's structure (properties and methods) but not the implementation:
class DatabaseEntity {
id: string;
createdAt: Date;
constructor(id: string) {
this.id = id;
this.createdAt = new Date();
}
}
interface Product extends DatabaseEntity {
name: string;
price: number;
inStock: boolean;
}
// Now Product has id and createdAt from DatabaseEntity
const product: Product = {
id: 'PROD-001',
createdAt: new Date(),
name: 'Wireless Keyboard',
price: 79.99,
inStock: true,
};
This gets interesting with private and protected members. When an interface extends a class with private or protected properties, only that class or its subclasses can implement the interface:
class BaseEntity {
private internalId: number;
constructor(id: number) {
this.internalId = id;
}
}
interface ValidEntity extends BaseEntity {
validate(): boolean;
}
class UserEntity extends BaseEntity implements ValidEntity {
validate() {
return true; // Can implement because it extends BaseEntity
}
}
// This would error - can't implement ValidEntity without extending BaseEntity
// class StandaloneEntity implements ValidEntity {
// validate() { return true; }
// }
This pattern is useful when you want to ensure type safety across a class hierarchy while defining contracts that only work within that hierarchy. For more on TypeScript classes, see TypeScript class.
Property Override: Narrowing Types in Extended Interfaces
When extending an interface, you can override properties with more specific types. The key is that the new type must be assignable to the original:
interface ApiResponse {
status: number;
data: unknown;
}
interface UserResponse extends ApiResponse {
data: {
id: string;
name: string;
email: string;
}; // Narrows 'unknown' to a specific shape
}
interface ErrorResponse extends ApiResponse {
status: 400 | 500; // Narrows 'number' to specific values
data: {
message: string;
code: string;
};
}
You can also use union types to extend rather than replace:
interface Base {
id: string;
type: 'base';
}
// Error: Type '"extended"' is not assignable to type '"base"'
// interface Extended extends Base {
// type: 'extended';
// }
// Solution 1: Use a union to allow both values
interface Extended extends Base {
type: 'base' | 'extended';
features: string[];
}
// Solution 2: Use a different property name
interface ExtendedAlt extends Base {
extendedType: 'extended';
features: string[];
}
The type system prevents incompatible overrides. If you're getting errors, check whether the new type is truly assignable to the original. When working with TypeScript generics, you can use constraints to ensure compatible overrides.
Building Reusable Type Hierarchies
Create interface hierarchies to eliminate repeated property definitions across related types:
interface Shape {
x: number;
y: number;
area: number;
}
interface Circle extends Shape {
radius: number;
}
interface Rectangle extends Shape {
width: number;
height: number;
}
interface Triangle extends Shape {
base: number;
height: number;
}
const circle: Circle = {
x: 10,
y: 20,
area: 314.16,
radius: 10,
};
const rectangle: Rectangle = {
x: 0,
y: 0,
area: 200,
width: 10,
height: 20,
};
Each specialized shape extends the base Shape interface, inheriting common position and area properties. This scales well when you work with collections of different shapes:
function drawShape(shape: Shape) {
// Works with any shape type
console.log(`Drawing shape at (${shape.x}, ${shape.y}) with area ${shape.area}`);
}
drawShape(circle);
drawShape(rectangle);
This hierarchy works well with TypeScript keyof operations and utility types. Use them when you need to manipulate or transform these interfaces.
Managing Complex Types with Utility Types
Handle complex scenarios by combining interface extension with TypeScript's utility types:
interface User {
id: string;
name: string;
email: string;
password: string;
role: 'admin' | 'user';
createdAt: Date;
updatedAt: Date;
}
// Public-facing user data (no password)
type PublicUser = Omit<User, 'password'>;
// User creation data (no id or timestamps)
type CreateUserInput = Omit<User, 'id' | 'createdAt' | 'updatedAt'>;
// Partial update data
type UpdateUserInput = Partial<Pick<User, 'name' | 'email'>>;
const publicUser: PublicUser = {
id: '123',
name: 'Jordan Kim',
email: 'jordan@example.com',
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
};
const createInput: CreateUserInput = {
name: 'New User',
email: 'new@example.com',
password: 'hashed_password',
role: 'user',
};
const updateInput: UpdateUserInput = {
name: 'Updated Name',
// email is optional
};
The PublicUser type excludes sensitive data using TypeScript's Omit<T, K> utility type. TypeScript prevents you from accidentally accessing the omitted property on PublicUser objects.
When building APIs with Convex's server modules, these utility types help control which properties are exposed to clients. Combining utility types with interface extension gives you fine-grained control over your data shapes throughout your stack.
Real-World Pattern: API Response Types
Here's how interface extension works in a typical application with API responses:
interface BaseApiResponse {
success: boolean;
timestamp: Date;
requestId: string;
}
interface SuccessResponse<T> extends BaseApiResponse {
success: true;
data: T;
}
interface ErrorResponse extends BaseApiResponse {
success: false;
error: {
code: string;
message: string;
details?: Record<string, unknown>;
};
}
// Specific response types
interface UserData {
id: string;
name: string;
email: string;
}
type UserResponse = SuccessResponse<UserData> | ErrorResponse;
// Usage in API handler
async function fetchUser(id: string): Promise<UserResponse> {
try {
const user = await db.getUser(id);
return {
success: true,
timestamp: new Date(),
requestId: crypto.randomUUID(),
data: user,
};
} catch (error) {
return {
success: false,
timestamp: new Date(),
requestId: crypto.randomUUID(),
error: {
code: 'USER_NOT_FOUND',
message: 'User not found',
},
};
}
}
// Type-safe handling
const response = await fetchUser('123');
if (response.success) {
console.log(response.data.name); // TypeScript knows this is UserData
} else {
console.error(response.error.message); // TypeScript knows this is error
}
This pattern ensures consistent response structures across your API while maintaining type safety. The TypeScript discriminated union on the success property lets TypeScript narrow the type correctly.
Real-World Pattern: Component Props
Interface extension is essential when building component libraries:
interface BaseButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
className?: string;
}
interface IconButtonProps extends BaseButtonProps {
icon: string;
iconPosition?: 'left' | 'right';
}
interface LoadingButtonProps extends BaseButtonProps {
isLoading: boolean;
loadingText?: string;
}
const iconButton: IconButtonProps = {
label: 'Submit',
onClick: () => console.log('Clicked'),
icon: 'arrow-right',
iconPosition: 'right',
};
const loadingButton: LoadingButtonProps = {
label: 'Save Changes',
onClick: () => console.log('Saving...'),
isLoading: true,
loadingText: 'Saving...',
};
You start with a base interface defining shared behavior, then extend it for specialized components. TypeScript ensures each component receives the correct props, catching errors before runtime.
This integrates seamlessly with TypeScript's interface system and works well in modern frameworks. In Convex applications, you can apply similar patterns to define mutations, queries, and actions with type-safe parameters.
Common Mistakes to Avoid
Incompatible Property Types
The most common error is trying to override a property with an incompatible type:
interface Base {
id: string;
count: number;
}
// Error: Type 'boolean' is not assignable to type 'number'
// interface Extended extends Base {
// count: boolean;
// }
// Solution: Use a compatible type or different property name
interface Extended extends Base {
count: number; // Keep the same type
isActive: boolean; // Or use a new property
}
Circular Interface Dependencies
Avoid circular references between interfaces:
// This creates circular dependency issues
// interface A extends B { a: string }
// interface B extends A { b: string }
// Solution: Use a base interface both extend
interface Base {
shared: string;
}
interface A extends Base {
a: string;
}
interface B extends Base {
b: string;
}
Forgetting Required Properties
When implementing an extended interface, you must provide all inherited properties:
interface User {
name: string;
email: string;
}
interface Admin extends User {
role: string;
}
// Error: Property 'email' is missing
// const admin: Admin = {
// name: 'Admin',
// role: 'admin',
// };
// Correct
const admin: Admin = {
name: 'Admin',
email: 'admin@example.com',
role: 'admin',
};
Extending Interfaces with TypeScript Generics
Combine interface extension with generics to create flexible, reusable types:
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
save(item: T): Promise<T>;
}
interface AuditableRepository<T> extends Repository<T> {
findByDateRange(start: Date, end: Date): Promise<T[]>;
getAuditLog(id: string): Promise<AuditEntry[]>;
}
interface AuditEntry {
timestamp: Date;
action: 'create' | 'update' | 'delete';
userId: string;
}
class UserRepository implements AuditableRepository<User> {
async findById(id: string): Promise<User | null> {
// Implementation
return null;
}
async findAll(): Promise<User[]> {
// Implementation
return [];
}
async save(user: User): Promise<User> {
// Implementation
return user;
}
async findByDateRange(start: Date, end: Date): Promise<User[]> {
// Implementation
return [];
}
async getAuditLog(id: string): Promise<AuditEntry[]> {
// Implementation
return [];
}
}
This works well for data access layers and API clients. The generic parameter flows through the inheritance chain, maintaining type safety at every level. For more on this, check out TypeScript generics.
Wrapping Up
Interface extension gives you maintainable type hierarchies in TypeScript. Use extends to add properties, combine multiple interfaces, or create specialized variants. Use declaration merging to augment third-party types across files. Choose between interface extension and type intersection based on your needs. Interfaces for clear hierarchies and potential merging, intersections for one-off combinations.
These patterns work across your entire stack. Whether you're defining API responses, component props, or database entities, interface extension keeps your code DRY and your types precise. In production applications built with Convex, these patterns ensure type safety from your database schemas through your API endpoints to your frontend components.
Interface extension happens at compile time. The TypeScript compiler merges structures to create a single set of expectations. No runtime overhead, just better type safety and clearer code organization.