TypeScript interface Extends vs Intersection: When the Difference Actually Breaks Your Code
Most TypeScript type errors come from misunderstanding when extends catches conflicts that & silently permits. Learn the declaration-time versus use-time distinction that determines whether your code compiles or fails at runtime.
Most TypeScript type errors stem from treating interface extends and intersection types (&) as interchangeable syntax. The critical distinction is that extends validates property conflicts at declaration time, while & defers validation until use, permitting silent incompatibilities that manifest as runtime failures. Teams discover this the hard way when API responses typed with intersections accept malformed data that crashes application logic.
The problem compounds in large codebases. A developer extends an interface with a conflicting property, the compiler immediately reports the error at the declaration site. Another developer uses an intersection type for the same scenario, sees no error, commits the code, and the type system permits values that violate application invariants. The distinction between "compiler catches this now" and "compiler permits this until someone actually uses it wrong" determines whether bugs surface during development or in production.
%% alt: Interface extends detects property conflicts at declaration time
flowchart LR
A("Developer declares interface extending base") --> B("Compiler checks property compatibility")
B --> C("Conflicting property types")
C --> D("Build fails immediately")
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
Understanding when to use each mechanism eliminates an entire class of type safety failures. This post covers the declaration-time versus use-time checking model, the performance implications of interface caching, and the specific scenarios where choosing the wrong tool produces silent bugs.
%% alt: Interface extends enforces type safety early in the development cycle
flowchart LR
A("Developer declares interface extending base") --> E("Compiler checks property compatibility")
E --> F("Properties align correctly")
F --> G("Type safety enforced at declaration")
style F stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style G stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
interface extendsvalidates property conflicts at declaration time, while&defers validation until use, permitting incompatible types to compile.- The TypeScript compiler caches interface hierarchies, making
extendsfaster than intersection re-evaluation in large codebases. - Intersection types create union types for conflicting properties, not compile errors, allowing runtime type mismatches.
- API response typing with intersections permits malformed data that crashes application logic when property types conflict.
- Interface extension enforces a single source of truth for property types, preventing silent incompatibilities in shared models.
The Core Difference: Declaration-Time vs Use-Time Type Checking
The semantic difference between interface extends and intersection types determines when TypeScript validates type compatibility. Interface extension performs structural validation at the point of declaration. When an interface extends another interface, the compiler immediately checks that all properties in the child interface are compatible with the parent. If property types conflict, the build fails before any code uses the types.
%% alt: Declaration-time checking validates types when interfaces are defined
flowchart TD
A("Interface declaration with extends") --> B("Compiler validates property types")
B --> C("Property type conflicts?")
C -->|Yes| D("Error at declaration site")
C -->|No| E("Interface ready for use")
style A stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style D stroke:#ef4444,fill:#450a0a,color:#fca5a5
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Intersection types defer this validation. When developers combine types with &, the compiler creates a new type that represents values satisfying both constraints. Critically, the compiler does not validate property compatibility at the intersection definition. Conflicting property types produce a union of those types, not a compile error. The validation occurs only when code attempts to assign a value to the intersection type.
This distinction matters for property conflicts. Consider an interface Base with a property id: string and a derived type that attempts to narrow id to a more specific type. With extends, the compiler rejects incompatible narrowing immediately:
interface Base {
id: string;
name: string;
}
// Compiler error: Property 'id' in type 'Derived' is not assignable to the same property in base type 'Base'
interface Derived extends Base {
id: number; // Conflict detected at declaration
role: string;
}The error surfaces at the declaration site. Developers cannot use Derived anywhere in the codebase until they resolve the conflict. This enforcement prevents the interface from propagating through the type system with an unresolved incompatibility.
With intersection types, the same scenario compiles without error:
type DerivedIntersection = Base & {
id: number;
role: string;
};
// No compiler error here
const user: DerivedIntersection = {
id: 123, // Type error appears only at assignment
name: "Alice",
role: "admin"
};The intersection creates a type where id is string & number, which simplifies to never because no value can simultaneously be both types. The compiler permits the type definition but rejects assignments. The validation occurs at use time, not declaration time.
The implication here is that intersection types allow incompatible structures to exist in the codebase until code attempts to use them. In large applications with hundreds of types, this delayed validation obscures which types are actually usable. Developers discover incompatibilities only when they attempt to construct values, often far from the type definition itself.
Property Conflicts: How extends Reports Errors That & Silently Hides
The handling of property conflicts reveals why interface extension and intersection types are not interchangeable. When a child interface attempts to override a parent property with an incompatible type, extends produces a clear error at the declaration. The compiler message identifies the conflicting property and the incompatible types, directing developers to the exact location requiring attention.
Intersection types handle conflicts differently. For primitive type conflicts, the compiler creates a union type that resolves to never. For object type conflicts, the compiler merges properties recursively. This behavior permits structural inconsistencies that only surface when code attempts to satisfy the intersection constraints.
Consider a common API response scenario. A base response interface defines common fields, and specific endpoints add domain-specific properties:
interface ApiResponse {
status: number;
data: unknown;
timestamp: string;
}
interface UserResponse extends ApiResponse {
data: {
id: string;
email: string;
};
}With extends, the narrowing of data from unknown to a specific object type is valid because the specific type satisfies the unknown constraint. The compiler permits this refinement. Attempts to widen the type or introduce an incompatible type produce errors:
interface InvalidResponse extends ApiResponse {
status: string; // Error: Type 'string' is not assignable to type 'number'
}The error is immediate and specific. Developers see which property conflicts and what types are incompatible.
Using intersections for the same pattern produces different behavior:
type UserResponseIntersection = ApiResponse & {
data: {
id: string;
email: string;
};
};
type InvalidResponseIntersection = ApiResponse & {
status: string;
};The InvalidResponseIntersection type compiles without error. The status property becomes number & string, which simplifies to never. Code that attempts to create values of this type fails, but the type definition itself is valid. This separation between definition validity and usage validity complicates type system reasoning.
The silent permission of conflicting types creates maintenance burden. When a team refactors a base interface, adding or changing property types, intersection-based derived types do not report errors at the refactor site. Developers must search for all usages of the intersection types to discover where conflicts now exist. With extends, the compiler immediately reports every interface affected by the base change.
Performance Implications: Interface Caching vs Intersection Re-evaluation
The TypeScript compiler treats interfaces and intersection types differently in its type checking algorithm. Interfaces are named entities with stable identity. When the compiler encounters an interface, it caches the structural information and reuses it for subsequent type checks. This caching is particularly effective with interface hierarchies because the compiler validates the hierarchy once at declaration time and then treats the interface as a single resolved structure.
%% alt: Interface caching provides stable performance while intersections require repeated evaluation
flowchart LR
subgraph InterfaceExtends["Interface Extends Path"]
A("Interface declaration") --> B("Compiler validates once")
B --> C("Cached structure")
C --> D("Fast subsequent checks")
end
subgraph IntersectionTypes["Intersection Types Path"]
E("Intersection definition") --> F("Deferred validation")
F --> G("Re-evaluate at each use")
G --> H("Slower type checking")
end
style C stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style H stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
Intersection types require the compiler to re-evaluate the structural combination at each usage site. When code references an intersection type, the compiler must merge the component types, check for property conflicts, and compute the resulting structure. This re-evaluation occurs every time the intersection appears in a type expression, function signature, or variable declaration.
In small codebases, the performance difference is negligible. In applications with thousands of types and deep inheritance hierarchies, the cumulative cost of intersection re-evaluation affects IDE responsiveness and build times. Teams working in large monorepos report measurable improvements in type checking speed when migrating from intersection-heavy type systems to interface hierarchies.
The caching benefit compounds with interface merging. TypeScript permits multiple interface declarations with the same name in the same scope, automatically merging them into a single interface. This merging is cached like any other interface structure. Intersection types do not support declaration merging, requiring developers to manually combine types with additional & operators, each adding re-evaluation overhead.
The performance distinction matters most in generic constraints and mapped types. When a generic type parameter extends an interface, the compiler validates the constraint once. When a generic type parameter is constrained by an intersection, the compiler must evaluate the intersection structure for each instantiation of the generic. In utility types that operate over large unions or deeply nested structures, this difference becomes visible in editor lag and CI build duration.
Real-World Breaking Cases: API Responses, Database Models, and Config Objects
The difference between extends and & produces concrete failures in three common patterns: API response typing, database model hierarchies, and configuration object composition. Each pattern exposes scenarios where intersection types permit invalid states that crash application logic.
%% alt: API response pattern flows from definition through validation to runtime handling
flowchart LR
A("Define base API response") --> B("Add endpoint-specific fields")
B --> C("Validate response structure")
C --> D("Pass response to handlers")
D --> E("Access typed properties")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
API response typing commonly uses a base interface for shared fields (status codes, pagination, metadata) and derived types for endpoint-specific data. Teams using intersection types encounter a specific failure mode when the API returns error responses with different structures. Consider:
interface BaseResponse {
status: number;
data: Record<string, unknown>;
}
type SuccessResponse = BaseResponse & {
data: {
users: Array<{ id: string; name: string }>;
};
};
type ErrorResponse = BaseResponse & {
data: {
error: string;
code: string;
};
};Both intersection types compile. The data property in each becomes a merged structure attempting to satisfy both the base Record<string, unknown> and the specific shape. At runtime, when the API returns an error, the application code typed as SuccessResponse attempts to access data.users, which does not exist. The type system permits the access because the intersection type theoretically includes both structures, but the actual value satisfies only one.
Using extends makes this conflict explicit:
interface SuccessResponseInterface extends BaseResponse {
data: {
users: Array<{ id: string; name: string }>;
};
}The narrowing from Record<string, unknown> to a specific object type is valid. More importantly, the interface clearly expresses that data has a single, specific structure. The type system enforces this constraint at every usage site.
Database model hierarchies expose a second failure mode. Object-relational mappers often use a base entity interface with common fields (id, created timestamp, updated timestamp) and derived interfaces for specific tables. When developers use intersections and add conflicting property types, the compiler permits the definition but the ORM fails at runtime:
interface BaseEntity {
id: string;
created: Date;
}
type UserEntity = BaseEntity & {
id: number; // Conflict: ORM expects string
email: string;
};The UserEntity type compiles. The id property becomes string & number, which is never. When the ORM attempts to construct a UserEntity instance, it cannot satisfy the constraint. The failure occurs in ORM internals, often producing cryptic error messages far from the type definition.
Configuration object composition represents the third common failure. Applications often compose configuration from multiple sources (environment variables, default values, user overrides). Using intersections for this composition permits conflicting property types across configuration sources:
type DefaultConfig = {
port: number;
host: string;
};
type EnvConfig = {
port: string; // String from process.env
timeout: number;
};
type AppConfig = DefaultConfig & EnvConfig;The AppConfig type compiles with port: number & string. Runtime code that attempts to use config.port as a number fails when the environment variable source provides a string. The type system does not prevent this error.
Migration Strategies: When to Refactor Intersections to Interface Extension
Identifying when to refactor intersections to interface extension requires recognizing patterns where declaration-time validation prevents runtime failures. The migration is most valuable in four scenarios: shared type hierarchies with multiple consumers, types that serve as function parameter constraints, types used in generic constraints, and types that represent external data structures (API responses, database schemas).
%% alt: Migration decision flow guides developers through refactoring assessment
flowchart LR
A("Identify intersection type") --> B("Check for property conflicts")
B --> C("Conflicts present?")
C -->|Yes| D("Refactor to extends")
C -->|No| E("Check performance impact")
E --> F("Used in hot paths?")
F -->|Yes| D
F -->|No| G("Keep intersection")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style G stroke:#7c9cf0,fill:#142544,color:#eaf2ff
The refactor process begins with identifying types that have been extended or intersected multiple times. When a base type is combined with three or more additional types, the resulting intersection is likely hiding conflicts. Converting to an interface hierarchy surfaces these conflicts immediately. The compiler errors guide the refactor by showing which properties require alignment.
For types used as function parameter constraints, the migration eliminates a subtle bug class. When a function parameter is typed as an intersection, the compiler permits arguments that satisfy either component type, not necessarily both. This creates false positives where the function accepts invalid arguments:
type RequiredFields = { id: string; name: string };
type OptionalFields = { email?: string; phone?: string };
type UserInput = RequiredFields & OptionalFields;
function createUser(input: UserInput) {
// Function expects both required and optional fields
// but intersection permits arguments missing required fields
}Converting to interface extension enforces that arguments satisfy all constraints:
interface BaseUser {
id: string;
name: string;
}
interface UserInput extends BaseUser {
email?: string;
phone?: string;
}Now the type system correctly rejects arguments missing required fields.
Generic constraints benefit from migration because interface extension provides clearer constraint semantics. When a generic type parameter must satisfy multiple constraints, using extends with an interface hierarchy makes the constraints explicit:
interface Identifiable {
id: string;
}
interface Timestamped {
created: Date;
updated: Date;
}
interface Entity extends Identifiable, Timestamped {}
function processEntity<T extends Entity>(entity: T) {
// T must satisfy Identifiable AND Timestamped
}This is clearer than intersection-based constraints and performs better because the compiler caches the Entity interface structure.
The migration is less valuable for types that are never extended and only used in a single module. If an intersection type has no property conflicts and appears in only one file, the refactor cost exceeds the benefit. The decision framework: if the type is shared across modules or serves as a constraint for other types, migrate to interface extension. If the type is local and stable, the intersection remains acceptable.
Choosing the Right Tool: Decision Framework for extends vs &
The choice between interface extends and intersection types depends on three factors: whether the type represents a hierarchical relationship, whether property compatibility validation should occur at declaration time, and whether the type serves as a constraint for other types.
Use interface extends when the derived type represents a specialization of the base type. If the relationship is "X is a kind of Y" or "X extends Y with additional properties," interface extension correctly models the relationship. This applies to entity hierarchies, API response structures, and configuration schemas where derived types add fields without modifying inherited properties.
Use intersection types when combining unrelated types that happen to share some structure. If the relationship is "X needs the properties of Y and Z but is not conceptually derived from either," intersections express this composition. This applies to utility types that add metadata (timestamps, audit fields) to unrelated domain types or to function parameters that must satisfy multiple independent constraints.
The declaration-time validation factor determines which tool prevents more bugs. If property conflicts are possible (narrowing property types, adding properties with names that might conflict), use extends to catch conflicts immediately. If the types are guaranteed compatible (combining non-overlapping property sets), intersections work correctly.
For types that serve as generic constraints or are themselves extended by other types, interface extension provides better performance and clearer semantics. The compiler caches interface hierarchies, making constraint validation faster. Interface extension also permits declaration merging, enabling incremental type composition that intersections cannot support.
A practical rule: default to interface extends for domain types (entities, models, API contracts). Use intersection types for utility compositions (adding metadata, combining independent constraints). When in doubt, start with extends because the compiler will immediately report if the approach is wrong. With intersections, invalid patterns compile and fail at runtime.
That covers the essential patterns for choosing between interface extends and intersection types. The decision is not stylistic. Each mechanism provides different validation guarantees and performance characteristics. Apply the declaration-time versus use-time distinction in production type hierarchies and the difference in bug detection will be immediate. Teams that align their type composition strategy with these semantics eliminate entire categories of runtime type errors.
Frequently Asked Questions
When should I use intersection types instead of interface extends?
Use intersection types when combining unrelated types that do not have a hierarchical relationship. If you need to add timestamp fields to multiple unrelated domain types, an intersection expresses composition without implying inheritance. Reserve extends for true specialization relationships where the derived type is conceptually a kind of the base type.
Can I use both extends and intersection types on the same interface?
Yes. An interface can extend one or more interfaces and also be combined with types using intersections. However, this pattern often indicates overly complex type relationships. If you find yourself combining both mechanisms on a single interface, consider whether the type hierarchy can be simplified to use only extension.
Why does TypeScript permit intersection types that resolve to never?
The TypeScript compiler permits intersections that resolve to never because the type system is structural, not nominal. At the type definition level, string & number is a valid type expression representing values that are both strings and numbers. The fact that no such values exist becomes relevant only at usage sites, where the compiler rejects assignments to never types.
How do I migrate existing intersection types to interface extension without breaking code?
Start by creating an interface that matches the intersection structure exactly. Replace the intersection type alias with the interface, keeping the same name. Run the compiler to identify conflicts that were previously hidden. Address conflicts by aligning property types in the interface hierarchy. For external code consuming the type, the migration is transparent because structural compatibility is preserved.
Does interface extension work with type aliases?
An interface can extend a type alias if the type alias represents an object structure. However, interfaces cannot extend union types, primitive types, or type aliases that use advanced type operators (mapped types, conditional types). When extension fails, the compiler produces a clear error indicating why the base type is incompatible with interface extension.