I have a type with some different config objects, like this:
type Types =
| { kind: 'typeA', arg1: string }
| { kind: 'typeB', arg1: string, arg2: string }
I also have type which pulls out just the kind
subtype from the union above:
type InnerType = Types['kind'];
Here innerType
is a union of 'typeA'|'typeb'
as expected.
Lastly I have conditional type which extracts only the non-kind subtypes also from the Types
union:
type ExtractOtherParams<K, T> = K extends { kind: T }
? Omit<K, 'kind'>
: never
So far, this works as expected - if I create a new type called Test
and use the conditional type passing in typeA
then the type is an object containing only arg
type Test = ExtractOtherParams<Types, 'typeA'> // test = { arg1: string }
And if I pass typeb
to the conditional, then it gives object type with arg1
and arg2
properties, e.g:
type Test = ExtractOtherParams<Types, 'typeB'> // test = { arg1: string, arg2: string }
So far, so good. But now when I try to define function which uses this conditional then it does not work as expected. For example:
function test<T extends InnerType>(
kind: T,
others: ExtractOtherParams<Types, T>,
): void {
switch (kind) {
case 'typeA':
console.log(others.arg1);
break;
case 'typeB':
console.log(others.arg1, others.arg2);
}
Here it is mostly correct - I can only switch on the values 'typeA'
or 'typeB'
, any other value not in the original Types union gives error - this is good. But inside second case, it is giving error when I try to access others.arg2
(it says arg2 does not exist on type 'ExtractOtherParams<{ kind: "typeA"; arg1: string; }, T>')
But, when I use this function, it works as expected from a consumer POV, for example, if I call the function with typeA
then it will only let me pass object with arg1:
test('typeA', { arg1: 'test' }); // correct, no errors
test('typeA', { arg1: 'test', arg2: 'oops' }); // correct, has error on arg2
test('typeB', { arg1: 'test', arg2: 'works' }); // correct, no errors
test('typeB', { arg1: 'test' }); // correct, has error for missing arg2
What am I missing from the definition of test
function to allow it to access the arg2 inside the second case expression?
I tried function definition without extending, just using generic type, e.g:
test<T>
But this did not make a difference. Unsure what else to try tbh
The problem is that currently TypeScript does not use control flow analysis to affect generic type parameters. So if you check kind
, you might be able to narrow the apparent type of kind
from T
to, say, T & "typeB"
. But T
itself doesn't change. And therefore ExtractOtherParams<Types, T>
cannot be equated with, say, ExtractOtherParams<Types, "typeB">
. And so you get that error.
There are various open issues about this; the one that seems to be canonical for your use case is microsoft/TypeScript#33014. It's been open for quite a while, but there's some hope it might get addressed sometime soon, as there's a pull request at microsoft/TypeScript#56941 for it. But until and unless it's actually merged, we have to work around your problem.
An easy workaround is to just a type assertion to tell TypeScript that others
in the second case is of the expected type:
function test<T extends InnerType>(
kind: T,
others: ExtractOtherParams<Types, T>,
): void {
switch (kind) {
case 'typeA':
console.log(others.arg1);
break;
case 'typeB':
console.log(
others.arg1,
(others as ExtractOtherParams<Types, "typeB">).arg2
);
}
}
More complicated is a refactoring away from using generics so that control flow analysis is helpful. The sort of narrowing you're trying to use in the function treats kind
and others
as if they were both properties of a discriminated union. You can actually write test()
so it uses a destructured discriminated union rest parameter:
type TestArgs = Types extends infer T ? T extends Types ?
[kind: T['kind'], others: Omit<T, "kind">] : never : never
/* type TestArgs =
[kind: "typeA", others: { arg1: string; }] |
[kind: "typeB", others: { arg1: string; arg2: string; }]
*/
function test(...[kind, others]: TestArgs): void {
switch (kind) {
case 'typeA':
console.log(others.arg1);
break;
case 'typeB':
console.log(others.arg1, others.arg2); // okay
}
}
test('typeA', { arg1: 'test' }); // correct, no errors
test('typeA', { arg1: 'test', arg2: 'oops' }); // correct, has error on arg2
test('typeB', { arg1: 'test', arg2: 'works' }); // correct, no errors
test('typeB', { arg1: 'test' }); // correct, has error for missing arg2
The TestArgs
type was created via distributive conditional type over Types
, to get the two different intended call signatures for test()
. Then the call signature looks like (...[kind, others]: TestArgs) => void
, meaning that either [kind, others]
is of type [kind: "typeA", others: { arg1: string; }]
, or it is of type [kind: "typeB", others: { arg1: string; arg2: string; }]
.
And now if you check kind
, the compiler understand the implication on others
. And your calls still see the expected type checking.