Let's consider this code
interface First {
a: number
}
interface Second {
b: number
}
type GeneralInterface = First | Second;
type Kind = 'First' | 'Second';
function getValue(kind: 'First', object: First): number
function getValue(kind: 'Second', object: Second): number
function getValue(kind: Kind, object: GeneralInterface): number {
switch (kind) {
case "First": {
return object.a
}
case "Second": {
return object.b
}
}
}
I see error
TS2339: Property 'a' does not exist on type 'GeneralInterface'. Property 'a' does not exist on type 'Second'.
To make it working i have to write this way
function getValue(kind: 'First', object: First): number;
function getValue(kind: 'Second', object: Second): number;
function getValue(kind: Kind, object: GeneralInterface): number {
if (kind === 'First' && 'a' in object) {
return object.a;
} else if (kind === 'Second' && 'b' in object) {
return object.b;
}
throw new Error(`Invalid argument: ${kind}`);
}
but these additional checks makes code less readable. For more complicated interfaces it require a lot of work. Is there any workaround?
I am sure that there will be no other combinations of arguments that these:
function getValue(kind: 'First', object: First): number
function getValue(kind: 'Second', object: Second): number
Unfortunately TypeScript can't analyze the implementation of overloaded functions the way you want. The compiler only "sees" the implementation signature, which you've got annotated as
function getValue(kind: Kind, object: GeneralInterface): number {...}
so there's no correlation between kind
and object
that the compiler can use to narrow object
when you check kind
.
There is a longstanding open issue at microsoft/TypeScript#22609 asking for the compiler to use the call signatures for control flow purposes inside the implementation. Until and unless this is implemented, you'll have to look for another solution.
Because both of your call signatures return number
, there's an alternative approach which looks very much like an overloaded function from the caller's side, but which the implementation can actually verify the way you'd like:
type KindObject =
[kind: "First", object: First] |
[kind: "Second", object: Second]
function getValue(...[kind, object]: KindObject): number {
switch (kind) {
case "First": {
return object.a // okay
}
case "Second": {
return object.b // okay
}
}
}
This is a single call signature with a destructured rest parameter ...[kind, object]
, whose type is KindObject
, a discriminated union of tuple types. TypeScript 4.6 and above is able to perform control flow analysis narrowing in such cases.
And let's just test it from the call side:
// 1/2 getValue(kind: "First", object: First): number
// 2/2 getValue(kind: "Second", object: Second): number
getValue("First", { a: 1 }); // okay
getValue("First", { b: 2 }); // error
getValue("Second", { b: 2 }); // okay
getValue("Second", { a: 1 }); // error
Looks good! The IntelliSense shows the same set of call signatures you'd see for an overloaded function, and the compiler only accepts calls corresponding to the two intended call signatures and rejects ones with mismatching arguments.