Indeed, there exist differences that may or may not be applicable to your specific situation.
One of the most notable disparities lies in how members with identical property keys are managed when present in both types.
Take for example:
interface NumberToStringConverter {
convert: (value: number) => string;
}
interface BidirectionalStringNumberConverter extends NumberToStringConverter {
convert: (value: string) => number;
}
The extends
keyword here triggers an error due to the fact that the inheriting interface declares a property with the same key as one in the parent interface but with incompatible signatures.
error TS2430: Interface 'BidirectionalStringNumberConverter' incorrectly extends interface 'NumberToStringConverter'.
Types of property 'convert' are incompatible.
Type '(value: string) => number' is not assignable to type '(value: number) => string'.
Types of parameters 'value' and 'value' are incompatible.
Type 'number' is not assignable to type 'string'.
However, by utilizing intersection types:
type NumberToStringConverter = {
convert: (value: number) => string;
}
type BidirectionalStringNumberConverter = NumberToStringConverter & {
convert: (value: string) => number;
}
No errors occur in this scenario. Furthermore, this approach is advantageous as it allows for easy conceptualization of a value adhering to this particular intersection type.
const converter: BidirectionalStringNumberConverter = {
convert: (value: string | number) => {
return (
typeof value === 'string'
? Number(value)
: String(value)
) as string & number; // type assertion is an unfortunately necessary hack.
}
}
It's important to note that while the implementation of the intersection involves some cumbersome types and assertions, these are simply implementation details that do not impact the type of the converter object determined solely by the
BidirectionalStringNumberConverter
type annotation used on the
converter
object literal.
const s: string = converter.convert(0); // `convert`'s call signature comes from `NumberToStringConverter`
const n: number = converter.convert('a'); // `convert`'s call signature comes from `BidirectionalStringNumberConverter`
Playground Link
Another significant distinction is that interface
declarations are open-ended. Additional members can be added anywhere because multiple interface
declarations with the same name in the same declaration space are merged. This differs from the type expression created by &
, which generates an anonymous expression that can be assigned to an alias for reuse but cannot be extended through merging.
Here's a common example demonstrating the merging behavior:
lib.d.ts
interface Array<T> {
// map, filter, etc.
}
array-flat-map-polyfill.ts
interface Array<T> {
flatMap<R>(f: (x: T) => R[]): R[];
}
if (typeof Array.prototype.flatMap !== 'function') {
Array.prototype.flatMap = function (f) {
// Implementation simplified for exposition.
return this.map(f).reduce((xs, ys) => [...xs, ...ys], []);
}
}
Notice the absence of an extends
clause. Despite being specified in separate files, the interfaces reside in the global scope and are merged by name into a unified logical interface declaration containing both sets of members. (Similar merging can be achieved for module-scoped declarations with slight syntax adjustments)
In contrast, intersection types stored within a type
declaration are closed and not subject to merging.
There are numerous distinctions between the two approaches. For further information, you can refer to the TypeScript Handbook. The sections on Object Types and Creating Types from Types offer valuable insights.