TypeScript Interface vs Type
You're assigning a plain object to a function that expects Record<string, unknown>, and TypeScript throws a cryptic error: "Index signature for type 'string' is missing in type 'MyInterface'." You switch interface to type, and the error vanishes. No other changes needed.
That moment captures why the interface-vs-type choice matters more than most tutorials let on. The two are similar enough to feel interchangeable for simple cases, but they diverge in ways that cause real bugs and real compiler frustration. This guide covers the practical differences so you can make the right call every time.
1. Choosing Between Interfaces and Types
The short modern answer: reach for type by default. Use interface when you specifically need declaration merging or a clean contract for a class.
Here's why type is the safer default:
- Types can express things interfaces simply can't: union types, template literal types, conditional types, and mapped types are all
type-only territory. - Types don't have surprises: interfaces support declaration merging, which can silently modify a type anywhere in your codebase. Types don't.
- Types have implicit index signatures, making them assignable to
Record<string, unknown>without extra boilerplate (more on this below).
// Only `type` can express these constructs
// Union type
type UserId = string | number;
// Template literal type — interfaces can't do this
type ApiRoute = `/api/${string}`;
// Conditional type
type IsString<T> = T extends string ? true : false;
// Mapped type
type ReadonlyUser<T> = { readonly [K in keyof T]: T[K] };
That said, interfaces still have a clear home. Use them when:
- You're defining a contract that a class will
implement - You need declaration merging to extend a third-party library's types
- You're building a public library API where interface error messages tend to be cleaner
// Interface — the right call here because Employee implements it as a class contract
interface User {
name: string;
age: number;
getFullName(): string;
}
// Type — excellent for unions, primitives, and type operations
type UserId = string | number;
type UserRecord = Record<UserId, User>;
class Employee implements User {
constructor(public name: string, public age: number) {}
getFullName(): string {
return this.name;
}
}
The interface-vs-type decision also comes up with generics<T>: if a generic type needs to be extended by a class, reach for an interface; if it needs to compose with other types, a type is usually cleaner. See TypeScript best practices for more on this.
2. Defining Detailed Object Shapes
Both interfaces and types handle nested object shapes well, but they shine in different situations. Interfaces are cleaner for structured objects with optional and required properties you'll share across your codebase. Types are better when you need to mix in primitives or create flexible value unions alongside the object shape.
// Interface for a complex configuration object
interface AppConfig {
database: {
host: string;
port: number;
username: string;
password: string;
};
api: {
endpoint: string;
headers: {
'Content-Type': string;
Authorization?: string; // Optional property
};
};
features?: {
enableLogging: boolean;
maxRetries: number;
};
}
// Type handles mixed value scenarios better
type ConfigValue = string | number | boolean | null;
type ConfigRecord = Record<string, ConfigValue>;
const appConfig: AppConfig = {
database: {
host: 'localhost',
port: 5432,
username: 'admin',
password: 'securepassword',
},
api: {
endpoint: 'https://api.example.com',
headers: {
'Content-Type': 'application/json',
},
},
};
const featureFlags: ConfigRecord = {
enableDarkMode: true,
maxConnections: 10,
theme: 'ocean',
debugMode: null,
};
When working with complex data structures in Convex, you can use validation helpers to ensure your objects match their expected shapes. This keeps your data reliable as the project grows.
For large projects, consider using utility types to manipulate object shapes and create variations without duplicating code.
3. Extending Interfaces and Types
Interfaces extend with the extends keyword. Types combine with &. Both work, but there's a meaningful difference in how the compiler handles them (covered in the performance section below).
// Interface extension using 'extends'
interface Notification {
title: string;
message: string;
createdAt: Date;
}
interface PushNotification extends Notification {
deviceToken: string;
priority: 'high' | 'normal';
}
// Type intersection using '&'
type RequestContext = {
userId: string;
sessionId: string;
};
type AuthenticatedContext = RequestContext & {
permissions: string[];
expiresAt: Date;
};
// Declaration merging — interfaces only
interface Notification {
readAt?: Date; // Adds an optional property to the existing interface
}
const push: PushNotification = {
title: 'New message',
message: 'You have a reply from Sarah',
createdAt: new Date(),
deviceToken: 'fcm_abc123',
priority: 'high',
readAt: undefined, // Available from the merged interface
};
const ctx: AuthenticatedContext = {
userId: 'usr_42',
sessionId: 'sess_9f1a',
permissions: ['read', 'write'],
expiresAt: new Date(Date.now() + 3600_000),
};
Building schema validation in Convex is a good example of where these extension techniques pay off. You define a base validator once and build on it, rather than repeating the same field definitions across every schema.
For complex type hierarchies, extends provides a clean way to build relationships between different data types.
4. Managing Intersection and Union Types
Intersection types (&) combine multiple types into one. Union types (|) let a value be one of several types. Both are type-only territory when the result isn't a plain object shape.
// Intersection type — the result must satisfy both types
type Employee = {
id: number;
name: string;
};
type Manager = {
department: string;
reports: Employee[];
};
type ManagerWithDetails = Employee & Manager;
// Union type — the value can be either
type UserId = string | number;
type RequestStatus = 'pending' | 'active' | 'deleted';
// Type guard narrows union types at runtime
function formatUserId(id: UserId): string {
if (typeof id === 'string') {
return id.toUpperCase();
}
return id.toString().padStart(5, '0');
}
const seniorManager: ManagerWithDetails = {
id: 101,
name: 'Alice Smith',
department: 'Engineering',
reports: [
{ id: 201, name: 'Bob Johnson' },
{ id: 202, name: 'Carol Williams' },
],
};
const userStatus: RequestStatus = 'active';
const userId: UserId = 'U12345';
Union types are especially useful for query parameters. Complex filters in Convex are a good real-world example: a filter can be a string ID, a date range, or a status code, and a union type captures that without any any hacks.
For state management patterns, consider using discriminated unions to model different states with associated data.
5. Compiler Performance: interface extends vs type &
This one surprises developers who assume type & and interface extends are completely equivalent. They're not, at least from the compiler's perspective.
When you use interface extends, TypeScript caches the resulting type by name internally. Future checks against it are fast because the compiler doesn't need to recompute the shape. With type intersections (&), TypeScript recomputes the combined shape each time it appears. For most small codebases, this difference is imperceptible. In large projects with deeply nested intersections, it adds up.
// Prefer extends for types you'll compose many times in a large hierarchy
interface LogEntry {
timestamp: Date;
level: 'info' | 'warn' | 'error';
message: string;
}
interface ApiLog extends LogEntry {
endpoint: string;
statusCode: number;
responseTimeMs: number;
}
interface UserActionLog extends LogEntry {
userId: string;
action: string;
metadata: Record<string, unknown>;
}
// This style creates recomputed intersections — fine for occasional use,
// but can slow compilation in large, deeply nested type hierarchies
type ApiLogAlt = LogEntry & {
endpoint: string;
statusCode: number;
responseTimeMs: number;
};
The practical rule: if you're building a deep type hierarchy with many levels of composition, prefer interface extends. For one-off combinations or anything involving unions, type is fine.
When building type-safe backends with Convex, large schemas with many shared fields benefit from the interface extends pattern to keep compile times manageable.
6. The Implicit Index Signature Difference
This is a subtle difference that bites developers regularly, and most articles on this topic skip it entirely.
Type aliases have an implicit index signature. Interfaces don't. In practice, this means you can assign a type-defined object to Record<string, unknown>, but doing the same with an interface throws a compiler error.
type UserType = {
id: number;
name: string;
};
interface UserInterface {
id: number;
name: string;
}
function processData(data: Record<string, unknown>) {
console.log(data);
}
const userFromType: UserType = { id: 1, name: 'Alice' };
const userFromInterface: UserInterface = { id: 1, name: 'Alice' };
processData(userFromType); // Works fine
processData(userFromInterface);
// Error: Index signature for type 'string' is missing in type 'UserInterface'
The fix if you're stuck with an interface is to add an explicit index signature or cast with as Record<string, unknown>. But the cleaner solution for utility-style objects that get passed around generically is to use type from the start.
The Record<K, V> type comes up often in generic utility functions precisely because it accepts type aliases but not bare interfaces. This asymmetry is one of the stronger practical arguments for using type as your default.
7. Ensuring Compatibility
TypeScript uses structural typing, which means compatibility is based on shape rather than name. An object with all the required properties of an interface is compatible with it, even if it's defined as a type.
interface SearchResult {
query: string;
hits: number;
durationMs: number;
}
// Compatible with SearchResult due to structural typing — even as a type alias
type CachedSearchResult = {
query: string;
hits: number;
durationMs: number;
cacheKey: string; // Extra property is fine with structural typing
};
// Type guard for runtime validation of unknown data
function isSearchResult(obj: unknown): obj is SearchResult {
return (
typeof obj === 'object' &&
obj !== null &&
'query' in obj &&
'hits' in obj &&
'durationMs' in obj &&
typeof (obj as SearchResult).query === 'string' &&
typeof (obj as SearchResult).hits === 'number'
);
}
function renderResult(result: SearchResult) {
console.log(`"${result.query}" returned ${result.hits} hits in ${result.durationMs}ms`);
}
const cached: CachedSearchResult = {
query: 'typescript interface',
hits: 142,
durationMs: 38,
cacheKey: 'search:typescript-interface:v2',
};
// Works — structural typing means CachedSearchResult satisfies SearchResult
renderResult(cached);
// Safe runtime handling when data shape is unknown (e.g., parsed JSON)
function handleApiResponse(data: unknown) {
if (isSearchResult(data)) {
renderResult(data);
} else {
console.error('Unexpected response shape from search API');
}
}
Structural typing also shows up when creating custom functions in Convex: you define a shape once and pass it through multiple layers without worrying that every function uses the exact same named type. The helpers in Convex's functional relationships take the same approach, accepting compatible shapes rather than requiring exact type matches.
The typeof operator is useful here too. It lets you narrow union types inside conditionals so TypeScript knows exactly which shape it's dealing with.
Final Thoughts on TypeScript Interface vs Type
The interface-vs-type debate has a clearer answer now than it did a few years ago. Here's a quick reference:
Use type when:
- Working with unions, intersections, or literal types
- Creating template literal, conditional, or mapped types
- Defining utility types or aliases for primitives
- You want implicit index signature compatibility with
Record<string, unknown> - You don't need declaration merging and want to avoid its surprises
Use interface when:
- Defining a contract that a class will
implement - You need declaration merging to augment third-party types
- Building deep type hierarchies where compiler caching gives a performance edge
- Working on a public library API
When in doubt, type is the safer default. It's more flexible, less surprising, and the implicit index signature behavior alone makes it the better choice for utility objects. Reach for interface when you have a specific reason, not out of habit.
The implicit index signature difference and compiler caching behavior are the two things most developers don't learn until they've been burned by them. Now you know both. Start with type, reach for interface when you have a reason, and you'll be fine. For a full end-to-end example of TypeScript done right, Convex's TypeScript guide is worth a read.