I'm trying to augment an interface from an external dependency.
The reason is that the external d.ts
file does not specify the full return types.
In the example below, doStuff()
actually exists on objects returned by Bar.giveList()
and Baz.giveList()
, but the original declaration does not contain this information.
The following is a reconstruction of the problem using declare global
(I'm actually trying to augment named modules, but the problem seems to be similar when using two declare global
blocks in a single file).
export type MyList<T> = T[] & {
doStuff(): void
}
declare global { // Original declaration from external dependency
export interface Bar {
giveList(): string[]
}
export interface Baz {
giveList(): string[]
}
}
declare global { // My attempt of refining some return types
export interface Bar {
giveList(): MyList<string>
}
export interface Baz {
giveList(): MyList<string>
}
}
let bar = {} as Bar
let baz = {} as Baz
let barbaz = {} as (Bar | Baz)
let barList = bar.giveList() // infered type: MyList<string> --- good
let bazList = baz.giveList() // infered type: MyList<string> --- good
let barbazList = barbaz.giveList() // infered type: string[] --- why not MyList<string>?
barList.doStuff() // good
barList[0] // good
bazList.doStuff() // good
bazList[0] // good
barbazList.doStuff() // ERROR: Property 'doStuff' does not exist on type 'string[]'.(2339)
barbazList[0] // good
Since Bar.giveList()
and Baz.giveList()
return MyList<string>
, why doesn't (Bar|Baz).giveList()
return MyList<string>
as well?
Edit: I simplified the problem and created a ticket: ms/TS#57319
This is a known design limitation of TypeScript; see microsoft/TypeScript#50488 for an authoritative answer.
From the documentation for declaration merging:
For function members, each function member of the same name is treated as describing an overload of the same function. Of note, too, is that in the case of interface
A
merging with later interfaceA
, the second interface will have a higher precedence than the first.
So Bar
and Baz
end up being equivalent to this:
interface Bar {
giveList(): MyList<string>
giveList(): string[]
}
interface Baz {
giveList(): MyList<string>
giveList(): string[]
}
where giveList()
is an overloaded method.
And, unfortunately, overloads are fragile in TypeScript.
A direct call to a declared overloaded method will resolve to the most appropriate call signature. That's what happens in bar.giveList()
and baz.giveList()
; in each case, the compiler walks through the list of call signatures and selects the most appropriate one... which is the higher priority giveList()
, the one that returns MyList<string>
.
But any other manipulation or analysis of overloaded methods causes TypeScript to take a shortcut. It essentially chooses one call signature without figuring out which one is the most appropriate. Usually this is the one with the least priority, assuming that it's the most general (this assumption is incorrect in your example, which is why things go wrong). So when determining the type of giveList()
of the union Bar | Baz
, the compiler just chooses the lowest priority signature, the one that returns string[]
. And then you have a union of things that return string[]
, and you get string[]
.
So that's what's going on. Really you don't actually want to merge in the giveList()
method, but actually modify the existing Bar
and Baz
interfaces to return MyList<string>
and not string[]
. But TypeScript has no facility to modify existing interfaces, apart from you manually forking the library and modifying the types locally.
There is a feature request at microsoft/TypeScript#19064 asking to allow people to remove overloads and then presumably overwrite them with other ones. This is really what you're trying to do. But it's not supported by TypeScript and given the lack of overwhelming community support for it (it has only a handful of upvotes) I wouldn't expect to see it implemented anytime soon (even very highly requested features do not usually get implemented quickly).