Skip to main content

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:

FeatureInterfaceType 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 allowedtype 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 extends gives 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 type by default, reach for interface when you need declaration merging or want the performance benefit of extends in 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 of UserResponse, 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.