Description
Proposal for Merged Declarations between Classes & Interfaces
This is a proposal for a new feature, merging class/interface declarations, to allow existing libraries to remain type-compatible with dependencies that migrate from interface to class declarations as part of the process of updating to ES6.
Introduction
Consider lib1.ts and lib2.ts, where lib2.ts depends on and extends an interface in lib1.ts. Eg:
// lib1.ts
interface Foo {
bar : number;
}
// lib2.ts
interface Foo {
baz(x : number) : boolean;
}
Today, through merged interface-interface declarations, lib2.ts can write the above code to extend the interface Foo
. The prototypical example of this is in polyfill libraries, in particular those modifying standard built-in objects, such as Array<T>
. As part of the tsc compiler, we provide interface declarations for Array<T>
in src/lib/core.d.ts (ES3, ES5) and src/lib/es6.d.ts (ES6).
To extend Array<T>
, say with a proposed ES7 member function like includes()
(description available here), a library/polyfill creator might write
// fixes the type of Array<T>
interface Array<T> {
includes(searchElement : T, fromIndex : number) : boolean;
}
// provides the implementation
Array.prototype.includes = function(searchElement : T, fromIndex : number) : boolean {
// implementation ...
}
and then use the polyfilled Array<T>
normally.
With the advent of ES6, we would like to modify the API so that Array<T>
is now declared a class
instead of an interface
. In ES6, built-in's can be subclassed, so the current API (with interface declarations) is difficult if not impossible to maintain while remaining consistent with ES6. Additionally, class-based declarations are cleaner. Since classes do not merge with interface declarations presently, this would present a breaking change to the API -- the above code would trigger an error. To allow for a more straightforward transition to ES6 declarations while retaining backwards compatibility, we propose merged declarations of ambient classes and interfaces.
Details of Proposal
If an object is declared twice, once as an ambient class and then as an interface, then the resulting object is a class whose fields are both those of the class declaration and the interface declaration. The interface fields are marked public in the resulting type. On other words, the type of the class object is merged with the type of the interface. The class constructor object is unmodified.
Conflicts among the members/properties of the resulting type are resolved as if the object were declared contiguously within the class declaration.
The order of the declarations is irrelevant (ie: the behavior is the same regardless of whether the interface or class is declared first). Moreover A class can be merged with an arbitrary number of interfaces with the same name. For example,
declare class Foo {
private x : number;
}
interface Foo {
public y : string;
}
function bar(foo : Foo) {
foo.x = 1;
foo.y = "1"; // okay, declared above.
foo.z = true; // okay, declared below.
}
interface Foo {
z : boolean;
}
resolves the type of Foo as
{ x : number, y : string, z : boolean }
This proposal does not extend to allow merged declarations of classes-classes, classes-namespaces, or classes-functions. Moreover, since this applies only for the ambient portion class declarations, this proposal has no runtime behavior on the resulting type.
Pros
- Library writers can update to ES6 idioms without breaking backwards compatibility.
- Users of ES6 syntax (ie: class declarations) can interoperate with interface-syntax in TypeScript when declaring types. A user of a vanilla ES6 library with a class declaration on some object
Foo
may want to polyfill a class. interface-class merged declarations allow for this facility (where the type is obtained from the library's corresponding .d.ts file). - Generally, this allows the programmer to re-open and modify a class' declared type.
Cons
- Before, an unintentional name-conflict between an interface and a class would be a caught as an error. Now, this triggers perhaps surprising behavior in the type system. Perhaps it would be useful to limit the contexts in which class-interface merging can be performed, either with an additional keyword
mergeable
or a compiler flag? - If a type
Foo
is declared an interface, it is no longer a priori clear ifFoo
is actually an interface or a class or both. This may make some language service completion counter-intuitive.
To Be Discussed
- For the key example, it isn't necessary that class-interface merging be symmetric. For our examples, the first declaration is always the class. Should the order of the merged declarations matter?
- Should this feature be extended to non-ambient class declarations? It is unclear how the language would need to be modified to allow the programmer to open up the constructor to initialize new values.
- How should type deductions perform across files/the points between the declarations? For example,
// file1.ts
declare class C {
public x : number;
}
function funcI(c : C) {
console.log(c.x);
console.log(c.y); // okay?
console.log(c.z); // okay?
}
interface C {
y : number;
}
// file2.ts
interface C {
z : number;
}
For the analogous code sample with merged declarations for interfaces, the type is resolved across both files, so both lines are okay. Do we want to emulate this behavior (ie: that the type is inferred globally for each usage)?