Clear rules instead of gut feeling across the team
Interface and type alias look interchangeable at first glance, but declaration merging, union types and the capabilities of the TypeScript compiler create real practical differences. This article walks through concrete examples showing when each option is technically required, which team convention holds up in large codebases, and where performance and maintainability genuinely diverge.
Table of Contents
- 1. The Recurring Team Debate: Interface or Type?
- 2. Syntax Differences and Shared Capabilities
- 3. Declaration Merging: Why Interfaces Stay Open
- 4. Type Aliases for Unions, Intersections, Primitives, and Tuples
- 5. extends vs. Intersection (&): Two Paths to Composition
- 6. Edge Cases: Where Only One Option Actually Works
- 7. Compiler Performance: The Caching Advantage of Interfaces
- 8. Team Conventions for Real-World Codebases
- 9. Interface vs. Type Compared Side by Side
- 10. Summary
- 11. FAQ
1. The Recurring Team Debate: Interface or Type?
Almost every TypeScript project runs into the same debate sooner or later: do you describe object shapes with interface or with type? Both constructs overlap heavily in what they can do, and that overlap is exactly what causes friction: code reviews stall over style questions, style guides contradict each other across projects, and new team members just copy whatever example is nearest in the codebase without knowing the actual reasoning behind it.
Yet the answer is rarely "purely a matter of taste." There are real technical differences that, in certain situations, only allow one of the two options to work at all. This article clears exactly that up: where both tools behave identically, where only one of them actually works, and which convention holds up in practice for a team working on the same codebase for months or years. Once you understand the differences, the decision stops being a habit and becomes a concrete technical choice.
2. Syntax Differences and Shared Capabilities
Purely syntactically, interface and type barely differ for simple object shapes: both describe fields with their types, both support optional properties with ?, both understand readonly modifiers, and both can be parameterized with generics. If you only ever model object-like structures, you won't notice a difference in practice for a long time. The real difference lies in the declaration itself: interface is its own language construct category with its own merge semantics, while type simply binds a name to an arbitrary type, whether that's an object, a union, a tuple, or a primitive.
Function signatures can also be expressed with both tools, either as a call signature inside an interface block or as a function type behind a type alias. Index signatures work in both variants as well. The practical difference only shows up once you go beyond plain object shapes: as soon as union types, tuples, mapped types, or template literal types enter the picture, only type remains an option, as the following sections show.
// Both interface and type can describe the same object shape
interface UserInterface {
id: number;
name: string;
email: string;
}
type UserType = {
id: number;
name: string;
email: string;
};
// Both support optional and readonly properties
interface ProductInterface {
readonly sku: string;
price: number;
discount?: number;
}
// Both can describe function signatures
interface Logger {
(message: string): void;
}
type LoggerType = (message: string) => void;
// Both support generics
interface Box<T> {
value: T;
}
type BoxType<T> = {
value: T;
};
3. Declaration Merging: Why Interfaces Stay Open
Arguably the most important structural difference is declaration merging: if you declare an interface with the same name multiple times in the same scope, TypeScript automatically merges all members into a single effective declaration. A type alias behaves completely differently: a second type with an identical name in the same scope is a compile error due to a duplicate identifier. Interfaces are inherently "open," type aliases are "closed."
This becomes practically relevant when augmenting third-party types. Anyone who wants to add a custom field like tenantId to Express Request objects, or extend a global Window object with a field like dataLayer injected by an analytics script, cannot avoid declaration merging. Many npm packages use this same pattern themselves to keep their public types extensible. That's exactly why well-maintained libraries tend to define their public APIs as interface rather than type, since it lets consumers extend the types when needed without touching the original source.
// Declaration merging: augmenting a third-party interface
// This works because interfaces are open, type aliases are not
declare namespace Express {
interface Request {
// Add a custom property to Express Request without touching its source
tenantId?: string;
}
}
// Merging is also used to extend global objects
interface Window {
// Third-party analytics script attaches itself to window at runtime
dataLayer: Record<string, unknown>[];
}
// Two separate interface declarations with the same name merge automatically
interface Config {
apiUrl: string;
}
interface Config {
timeout: number;
}
// Config now has both apiUrl and timeout
const config: Config = {
apiUrl: 'https://api.mironsoft.de',
timeout: 5000,
};
// This would be a compile error: type aliases cannot be re-opened
// type Config = { apiUrl: string };
// type Config = { timeout: number }; // Error: Duplicate identifier 'Config'
4. Type Aliases for Unions, Intersections, Primitives, and Tuples
As soon as you need to describe more than a single plain object shape, type is the only option. Union types like 'pending' | 'paid' | 'shipped' cannot be expressed with interface at all, because an interface always describes exactly one object shape, not an alternative between values. The same goes for primitive aliases like type ProductId = string, for tuples like type Coordinates = [number, number], and for pure function types, which can technically be modeled via a call signature inside an interface but where type is noticeably more compact.
Particularly valuable are discriminated unions: a union of several object variants sharing a common literal field, which TypeScript uses for narrowing inside switch or if blocks. This pattern is extremely common in frontend projects, for instance when modeling different payment methods or API response shapes with a success and an error case. Without type aliases for unions, this pattern simply wouldn't be feasible in its current form, because interfaces are structurally not built for "either-or" relationships.
// Only a type alias can name a union of primitives or literals
type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';
// Interfaces cannot express this directly:
// interface OrderStatus = 'pending' | 'paid'; // not valid syntax
// Type aliases can name primitives, tuples and function types directly
type ProductId = string;
type Coordinates = [latitude: number, longitude: number];
type Comparator<T> = (a: T, b: T) => number;
// Discriminated unions are a common real-world case
type PaymentMethod =
| { type: 'creditCard'; cardNumber: string }
| { type: 'paypal'; email: string }
| { type: 'invoice'; iban: string };
function processPayment(method: PaymentMethod): void {
switch (method.type) {
case 'creditCard':
// TypeScript narrows to the creditCard variant here
console.log(method.cardNumber);
break;
case 'paypal':
console.log(method.email);
break;
case 'invoice':
console.log(method.iban);
break;
}
}
5. extends vs. Intersection (&): Two Paths to Composition
Both tools offer composition, but with different mechanics. interface uses extends and checks the compatibility of the extended types right at declaration time: if a property in the subtype has a type incompatible with the base, TypeScript reports the error immediately at the extends clause. Type aliases instead use the intersection operator &, which merges two types structurally. With conflicting properties, an intersection doesn't automatically produce an error, but in the worst case resolves the affected property to the type never, which often only becomes visible much later in the code.
An interface can also extend several other interfaces at once, which noticeably improves readability in deeply layered domain models. For classes, implements is the bridge between both worlds: a class can implement either an interface or an object-shaped type alias, as long as it describes a plain object shape. For unions or primitive type aliases, implements is not possible, because a class instance always has a fixed object shape structurally.
// Interface composition via extends
interface BaseEntity {
id: number;
createdAt: Date;
}
interface Product extends BaseEntity {
sku: string;
price: number;
}
// Type alias composition via intersection
type BaseEntityType = {
id: number;
createdAt: Date;
};
type ProductType = BaseEntityType & {
sku: string;
price: number;
};
// Interfaces can extend multiple interfaces at once
interface Timestamped {
updatedAt: Date;
}
interface AuditableProduct extends Product, Timestamped {
editedBy: string;
}
// A class can only "implements" object-shaped contracts,
// both interface and object type aliases work here
class DatabaseProduct implements Product {
constructor(
public id: number,
public createdAt: Date,
public sku: string,
public price: number,
) {}
}
6. Edge Cases: Where Only One Option Actually Works
Some TypeScript features are simply only implementable with type. Mapped types like { [K in keyof T]: ... } programmatically derive a new type from an existing one and have no interface equivalent. Conditional types using extends and infer also work exclusively through type, as do template literal types, which assemble new string types from string literals at compile time. Anyone writing their own generic utility types, such as custom variants of Partial or Pick, ends up using type almost inevitably.
Conversely, there's hardly a feature reserved exclusively for interface, aside from declaration merging itself. One important practical note: the built-in utility types like Pick<T, K> or Partial<T> technically work on both input kinds and always return a type as the result, regardless of whether the source was an interface or a type. This asymmetry, combined with the union and mapped-type restrictions, explains why many TypeScript codebases tend to accumulate more type aliases than interfaces as complexity grows.
// Mapped types require "type", there is no interface equivalent
type ReadonlyProduct<T> = {
readonly [K in keyof T]: T[K];
};
// Conditional types also require "type"
type ExtractId<T> = T extends { id: infer U } ? U : never;
type ProductIdOnly = ExtractId<{ id: number; name: string }>; // number
// Template literal types are only possible with "type"
type EventName = `on${Capitalize<'save' | 'delete' | 'publish'>}`;
// resolves to: 'onSave' | 'onDelete' | 'onPublish'
// Utility types like Partial, Pick and Omit return type aliases,
// but they work perfectly fine on interfaces as input
interface DraftProduct {
sku: string;
price: number;
description: string;
}
type ProductPreview = Pick<DraftProduct, 'sku' | 'price'>;
type OptionalProduct = Partial<DraftProduct>;
7. Compiler Performance: The Caching Advantage of Interfaces
The TypeScript team itself has pointed out that interface can be processed faster by the compiler in certain scenarios than a structurally equivalent intersection of type aliases. The reason lies in the internal representation: an interface is cached as a named, flat type with a stable identity, while an intersection potentially needs to be re-resolved and structurally reconciled on every use, especially when several intersections are nested. In small codebases this difference isn't measurable, but in large monorepos with thousands of type checks per build it can noticeably affect tsc runtime.
Context matters here: this performance advantage primarily concerns deeply nested intersections of many object types, not every arbitrary type alias. A simple type for a flat object shape or a union causes no relevant overhead. As a rule of thumb: if you're building very large, frequently reused object interfaces that get extended in many places, you benefit from interface and extends instead of a long chain of intersections. For everything else, the performance difference is practically negligible compared to readability and team convention.
8. Team Conventions for Real-World Codebases
The "interface or type" question is worth settling once per project, rather than re-litigating it on every pull request. A proven convention, widespread across many TypeScript style guides, reads as follows: interface for public, exported object shapes that other modules might implement or extend, especially for API contracts and domain models. type for everything interfaces structurally cannot express, meaning unions, intersections, tuples, primitive aliases, and derived utility types.
An ESLint rule such as @typescript-eslint/consistent-type-definitions enforces this convention automatically and prevents different team members from picking different tools for the same kind of type. What matters more than the specific rule is consistency itself: a team that follows the same pattern throughout reduces cognitive load during code review and makes diffs more predictable. In projects with TypeScript build tooling or headless frontends, it's also worth using interfaces for all GraphQL or REST response shapes, since these are frequently extended with additional fields as new API versions appear.
9. Interface vs. Type Compared Side by Side
The table below summarizes which capabilities interface and type actually support. It works well as a quick reference for the next team discussion or the next code review comment.
| Capability | interface | type |
|---|---|---|
| Declaration merging | Supported | Not supported |
| Union types | Not supported | Supported |
| Primitive aliasing | Not supported | Supported |
| extends multiple types | Yes (extends A, B) | Yes (A & B & C) |
| implements in classes | Yes | Yes, object shapes only |
| Mapped / conditional types | Not supported | Supported |
In practice, the table shows mainly one thing: the two tools overlap heavily but not completely, and the gaps are almost never symmetric. Once you've established a clear convention and know the edge cases shown here, you no longer have to make the decision line by line, only once per project.
Mironsoft
TypeScript code reviews and architecture consulting for Magento and Hyvä frontends
Want clear TypeScript conventions across your team?
We review your existing codebase, identify inconsistent interface and type usage, and work with your team to establish a convention that actually sticks, from ESLint rules to the architecture of type-safe API clients.
TypeScript Code Review
Analyzing existing type definitions, surfacing inconsistencies and edge cases
Establishing Codebase Conventions
ESLint rules, style guides, and team onboarding for consistent type code
Type-Safe Frontend Architecture
Cleanly modeling API clients, GraphQL types, and headless integrations
10. Summary
Interface and type alias are not an either-or choice, but two tools with different strengths. interface excels at declaration merging, at augmenting third-party types, and potentially at compiler performance for large, nested object models. type is the only option for unions, intersections, tuples, primitive aliases, mapped types, and conditional types. Where both work, such as for simple object shapes, the decision ultimately comes down to team convention, not the language itself.
Once you know the edge cases described here, where only one of the two options is technically possible, you no longer have to guess. For everything else, a documented convention enforced by a linter is worth adopting, one new team members can follow without discussion. That reduces friction in code review and keeps the codebase consistently readable over the years.
Interface vs. Type Alias - The Essentials at a Glance
Declaration merging
interface allows repeated declarations, type does not, the basis for third-party extensions.
Unions & primitives
Only type can express unions, tuples, and primitive aliases.
Performance
interface plus extends can compile faster than long intersection chains for large, nested models.
Team convention
Enforce an ESLint rule like consistent-type-definitions, consistency over personal preference.