Understanding Interfaces in TypeScript
You're fetching user data from an API, and your code expects a userId property, but the API returns id instead. Your app crashes at runtime, even though TypeScript didn't warn you. This is where interfaces save you—they define exact object shapes upfront, catching mismatches before your code runs.
TypeScript interfaces act as contracts for object structures. They tell TypeScript exactly what properties and methods an object should have:
interface UserProfile {
id: string;
email: string;
displayName: string;
createdAt: Date;
}
When you type a variable with this interface, TypeScript enforces the contract:
const currentUser: UserProfile = {
id: "usr_12345",
email: "sarah@example.com",
displayName: "Sarah Chen",
createdAt: new Date()
};
// TypeScript error: Property 'id' is missing
const invalidUser: UserProfile = {
userId: "usr_67890", // Wrong property name
email: "john@example.com",
displayName: "John Doe",
createdAt: new Date()
};
Extending Interfaces in TypeScript
extends in TypeScript lets you build new interfaces from existing ones. This pattern promotes code reuse and clear hierarchies:
interface BaseUser {
id: string;
email: string;
createdAt: Date;
}
interface AdminUser extends BaseUser {
permissions: string[];
lastLoginAt: Date;
}
The AdminUser interface inherits id, email, and createdAt from BaseUser, then adds admin-specific properties. This keeps your type definitions DRY and maintainable.
You can extend multiple interfaces to compose complex types:
interface Timestamped {
createdAt: Date;
updatedAt: Date;
}
interface Identifiable {
id: string;
}
interface BlogPost extends Identifiable, Timestamped {
title: string;
content: string;
authorId: string;
}
Now BlogPost has all five properties without repeating the common fields you use across different data models.
Using Interfaces with Classes in TypeScript
Classes use the implements keyword to guarantee they provide all properties and methods from an interface. This pattern is especially useful when working with Convex server functions:
interface CacheProvider {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttl: number): Promise<void>;
delete(key: string): Promise<void>;
}
class RedisCache implements CacheProvider {
async get(key: string): Promise<string | null> {
// Redis implementation
return await redis.get(key);
}
async set(key: string, value: string, ttl: number): Promise<void> {
await redis.set(key, value, 'EX', ttl);
}
async delete(key: string): Promise<void> {
await redis.del(key);
}
}
The interface ensures any cache implementation you swap in later (like MemoryCache or FileCache) will have the same methods.
You can implement multiple interfaces to compose behavior. Here's a practical example combining function type declarations:
interface Authenticatable {
authenticate(token: string): Promise<boolean>;
getUserId(): string | null;
}
interface RateLimited {
checkRateLimit(userId: string): Promise<boolean>;
recordRequest(userId: string): Promise<void>;
}
class ApiClient implements Authenticatable, RateLimited {
private currentUserId: string | null = null;
private requestCounts = new Map<string, number>();
async authenticate(token: string): Promise<boolean> {
// Validate token and set user ID
const user = await validateToken(token);
if (user) {
this.currentUserId = user.id;
return true;
}
return false;
}
getUserId(): string | null {
return this.currentUserId;
}
async checkRateLimit(userId: string): Promise<boolean> {
const count = this.requestCounts.get(userId) || 0;
return count < 100; // Allow 100 requests
}
async recordRequest(userId: string): Promise<void> {
const count = this.requestCounts.get(userId) || 0;
this.requestCounts.set(userId, count + 1);
}
}
Interface vs Type: When to Use Each
You'll often wonder whether to use interface or type for your object definitions. Here's the practical breakdown:
| Feature | Interface | Type Alias |
|---|---|---|
| Object shapes | ✅ Great choice | ✅ Works fine |
| Extending/Inheriting | ✅ Use extends (faster) | ✅ Use & (slower) |
| Declaration merging | ✅ Automatically merges | ❌ Error on duplicate |
| Union types | ❌ Not supported | ✅ Full support |
| Primitive aliases | ❌ Not allowed | ✅ type ID = string |
| Computed properties | ❌ Limited | ✅ Full support |
Default recommendation: Use type for most cases. It's more flexible and can handle unions, intersections, and mapped types. Use interface specifically when:
- You're defining object inheritance hierarchies where
extendsgives you better performance - You need declaration merging (extending third-party library types)
- You're building a public API where consumers might extend your types
Here's why performance matters with extends:
// Faster: TypeScript caches interface checks
interface BaseConfig {
apiUrl: string;
timeout: number;
}
interface AppConfig extends BaseConfig {
retries: number;
}
// Slower: Intersection creates a new type each check
type BaseConfig = {
apiUrl: string;
timeout: number;
}
type AppConfig = BaseConfig & {
retries: number;
}
For union types, you must use type:
type Status = 'pending' | 'approved' | 'rejected';
type ApiResponse = SuccessResponse | ErrorResponse;
Optional Properties in TypeScript Interfaces
Mark interface properties as optional with the ? operator. This is crucial when working with API responses or configuration objects where certain fields might not always exist:
interface ApiConfig {
endpoint: string;
apiKey: string;
timeout?: number;
retryAttempts?: number;
}
You can create valid objects with or without the optional fields:
const prodConfig: ApiConfig = {
endpoint: 'https://api.production.com',
apiKey: 'sk_live_abc123',
timeout: 10000
};
const devConfig: ApiConfig = {
endpoint: 'http://localhost:3000',
apiKey: 'sk_test_xyz789'
// timeout and retryAttempts omitted
};
When accessing optional properties, TypeScript forces you to handle the undefined case. Combine with optional chaining and type assertion for safer code. This pattern is particularly useful when building complex filters in Convex:
interface SearchFilters {
query: string;
category?: string;
priceRange?: {
min: number;
max: number;
};
tags?: string[];
}
function buildSearchQuery(filters: SearchFilters): string {
let query = `q=${filters.query}`;
// Safe access with optional chaining
if (filters.category) {
query += `&category=${filters.category}`;
}
if (filters.priceRange?.min !== undefined) {
query += `&minPrice=${filters.priceRange.min}`;
}
if (filters.tags && filters.tags.length > 0) {
query += `&tags=${filters.tags.join(',')}`;
}
return query;
}
Readonly Properties in TypeScript Interfaces
Use the readonly modifier to prevent accidental mutations. This catches bugs at compile time:
interface DatabaseRecord {
readonly id: string;
readonly createdAt: Date;
name: string;
status: string;
}
TypeScript throws an error if you try to modify readonly properties:
const record: DatabaseRecord = {
id: 'rec_123',
createdAt: new Date(),
name: 'Sample Record',
status: 'active'
};
record.name = 'Updated Name'; // OK
record.id = 'rec_456'; // Error: Cannot assign to 'id' because it is a read-only property
When building applications with Convex, readonly properties help enforce immutability patterns in your data models:
interface ConvexDocument {
readonly _id: string;
readonly _creationTime: number;
title: string;
content: string;
}
function updateDocument(doc: ConvexDocument, newTitle: string): ConvexDocument {
// Can't modify the original due to readonly fields
// Return a new object instead
return {
...doc,
title: newTitle
};
}
Function Types in TypeScript Interfaces
Interfaces can define method signatures for objects that contain functions:
interface EventHandler {
onSuccess(data: unknown): void;
onError(error: Error): void;
onComplete?(): void; // Optional method
}
const apiHandler: EventHandler = {
onSuccess(data: unknown): void {
console.log('API call succeeded:', data);
},
onError(error: Error): void {
console.error('API call failed:', error.message);
}
// onComplete is optional, can be omitted
};
You can also define interfaces for function types themselves:
interface ValidationFunction {
(value: string): boolean;
}
const isEmail: ValidationFunction = (value: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
};
const isPhoneNumber: ValidationFunction = (value: string) => {
return /^\d{3}-\d{3}-\d{4}$/.test(value);
};
Index Signatures in TypeScript Interfaces
Use index signatures when you don't know all property names ahead of time, but you know the value types. This is common with API responses or dynamic configuration objects:
interface ApiErrorMessages {
[errorCode: string]: string;
}
const errors: ApiErrorMessages = {
'AUTH_FAILED': 'Authentication failed. Please log in again.',
'RATE_LIMIT': 'Too many requests. Try again later.',
'NOT_FOUND': 'Resource not found.'
};
// TypeScript knows the value will be a string
console.log(errors['AUTH_FAILED']);
You can mix index signatures with known properties:
interface CacheStore {
version: string; // Known property
[key: string]: string | number; // Dynamic properties
}
const cache: CacheStore = {
version: '1.0',
userId: 'user_123',
sessionId: 'sess_456',
timestamp: 1638360000
};
Combine index signatures with utility types like Partial<T> or Record<K, V> for more flexibility:
type ErrorCodes = 'AUTH_FAILED' | 'RATE_LIMIT' | 'NOT_FOUND';
type ErrorMessageMap = Record<ErrorCodes, string>;
// This ensures all error codes have messages
const messages: ErrorMessageMap = {
'AUTH_FAILED': 'Authentication failed',
'RATE_LIMIT': 'Too many requests',
'NOT_FOUND': 'Resource not found'
};
Declaration Merging with Interfaces
One unique feature of interfaces is declaration merging. When you declare the same interface twice, TypeScript automatically combines them into one definition:
interface User {
id: string;
email: string;
}
interface User {
displayName: string;
avatarUrl: string;
}
// TypeScript merges both declarations
const user: User = {
id: 'usr_123',
email: 'sarah@example.com',
displayName: 'Sarah Chen',
avatarUrl: '/avatars/sarah.jpg'
};
This feature is especially valuable when extending third-party library types. Say you're using a library with incomplete type definitions:
// In node_modules/@types/some-library/index.d.ts
interface LibraryConfig {
apiKey: string;
}
// In your code
interface LibraryConfig {
timeout?: number;
retries?: number;
}
// Now LibraryConfig has all five properties
const config: LibraryConfig = {
apiKey: 'key_123',
timeout: 5000,
retries: 3
};
Declaration merging also works across module boundaries, which TypeScript uses internally to manage built-in types. When you add es2015 to your lib setting in tsconfig.json, TypeScript merges additional type definitions with the base library.
Watch out: Declaration merging can cause confusing bugs if you accidentally declare the same interface twice. If you prefer to avoid this behavior, use type instead of interface, or enable ESLint's no-redeclare rule.
Generic Interfaces
Generics let you write reusable interfaces that work with multiple types. Instead of creating separate interfaces for different data types, you can use a type parameter:
interface ApiResponse<T> {
data: T;
status: number;
message: string;
timestamp: Date;
}
interface User {
id: string;
email: string;
name: string;
}
interface Product {
id: string;
title: string;
price: number;
}
// Same interface, different data types
const userResponse: ApiResponse<User> = {
data: { id: 'usr_123', email: 'sarah@example.com', name: 'Sarah' },
status: 200,
message: 'Success',
timestamp: new Date()
};
const productResponse: ApiResponse<Product> = {
data: { id: 'prod_456', title: 'Laptop', price: 999 },
status: 200,
message: 'Success',
timestamp: new Date()
};
Generic interfaces shine when building data structures or API clients:
interface Repository<T> {
findById(id: string): Promise<T | null>;
findAll(): Promise<T[]>;
create(item: T): Promise<T>;
update(id: string, item: Partial<T>): Promise<T>;
delete(id: string): Promise<void>;
}
class UserRepository implements Repository<User> {
async findById(id: string): Promise<User | null> {
// Implementation
}
async findAll(): Promise<User[]> {
// Implementation
}
async create(user: User): Promise<User> {
// Implementation
}
async update(id: string, updates: Partial<User>): Promise<User> {
// Implementation
}
async delete(id: string): Promise<void> {
// Implementation
}
}
You can constrain generic types to ensure they have certain properties:
interface Identifiable {
id: string;
}
interface CacheManager<T extends Identifiable> {
set(item: T): void;
get(id: string): T | undefined;
remove(id: string): void;
}
// This works because User has an 'id' property
const userCache: CacheManager<User> = {
set(user: User): void {
// Store in cache using user.id as key
},
get(id: string): User | undefined {
// Retrieve from cache
},
remove(id: string): void {
// Remove from cache
}
};
Where Developers Get Stuck
Handling API Responses with Unknown Properties
You fetch data from an API, but the response includes extra fields your interface doesn't define. TypeScript complains about excess properties:
interface UserData {
id: string;
email: string;
}
// Error: Object literal may only specify known properties
const userData: UserData = {
id: 'usr_123',
email: 'sarah@example.com',
createdAt: '2024-01-15', // Extra property causes error
isVerified: true // Extra property causes error
};
Solution: Use an index signature or intersection type to allow additional properties:
interface UserData {
id: string;
email: string;
[key: string]: unknown; // Allow additional properties
}
// Or use intersection with a more specific structure
interface CoreUserData {
id: string;
email: string;
}
type FlexibleUserData = CoreUserData & Record<string, unknown>;
Conflicting Properties When Extending Multiple Interfaces
You're combining interfaces and hit a type conflict:
interface ApiResponse {
data: string;
status: number;
}
interface DatabaseRecord {
data: Buffer; // Conflict!
id: string;
}
// Error: Interface 'Combined' cannot simultaneously extend types 'ApiResponse' and 'DatabaseRecord'
interface Combined extends ApiResponse, DatabaseRecord {}
Solution: Rename conflicting properties or use type intersection with careful property management:
interface ApiResponse {
responseData: string; // Renamed to avoid conflict
status: number;
}
interface DatabaseRecord {
recordData: Buffer; // Renamed to avoid conflict
id: string;
}
interface Combined extends ApiResponse, DatabaseRecord {
// Now both properties coexist without conflict
}
Working with Readonly Properties in State Updates
You're trying to update an object with readonly properties, and TypeScript blocks you:
interface AppState {
readonly userId: string;
readonly sessionStart: Date;
preferences: Record<string, unknown>;
}
function updatePreferences(state: AppState, newPrefs: Record<string, unknown>): void {
state.preferences = newPrefs; // OK
state.userId = 'new_id'; // Error: Cannot assign to 'userId' because it is read-only
}
Solution: Return a new object instead of mutating. This aligns with immutable update patterns:
function updatePreferences(
state: AppState,
newPrefs: Record<string, unknown>
): AppState {
return {
...state,
preferences: {
...state.preferences,
...newPrefs
}
};
}
Optional Properties in Class Constructors
You're implementing an interface with optional properties in a class, and you're not sure how to handle them in the constructor:
interface ServerConfig {
host: string;
port: number;
ssl?: boolean;
retries?: number;
}
class Server implements ServerConfig {
host: string;
port: number;
ssl?: boolean;
retries?: number;
constructor(config: ServerConfig) {
this.host = config.host;
this.port = config.port;
// Should you always set these, even if undefined?
this.ssl = config.ssl;
this.retries = config.retries;
}
connect(): void {
const useSSL = this.ssl ?? false; // Provide defaults when accessing
const maxRetries = this.retries ?? 3;
console.log(`Connecting to ${this.host}:${this.port} (SSL: ${useSSL}, Retries: ${maxRetries})`);
}
}
Solution: Assign optional properties in the constructor and provide default values when you actually use them. This keeps your interface flexible while making your runtime behavior predictable.
Key Takeaways
Here's what you should remember when working with TypeScript interfaces:
- Use
typeby default, reach forinterfacewhen you need declaration merging or want the performance benefit ofextendsin deep inheritance hierarchies - Mark properties readonly when they shouldn't change after creation—this catches mutation bugs at compile time instead of runtime
- Use optional properties (
?) judiciously—too many optional properties can hide missing data issues that should be caught early - Generic interfaces eliminate code duplication—write
ApiResponse<T>once instead ofUserResponse,ProductResponse, etc. - Index signatures are your friend for dynamic data—perfect for API responses where you know the value type but not all the keys upfront
- Declaration merging is powerful but dangerous—great for extending third-party types, but can cause confusing bugs if you accidentally declare the same interface twice
With these patterns, you'll write TypeScript that catches bugs before they reach production while keeping your codebase maintainable as it grows.