Reputation: 1040
Here's a simplified code snippet to reproduce my issue:
type Supported = 'foo'|'bar';
type State = {
[K in Supported]?: K[]
}
function test<T extends keyof State>(state: State, type: T) {
const arr = state[type];
if (!arr) {
return;
}
return arr[0];
}
function test2<T extends keyof State>(state: State, type: T) {
const arr = state[type] as T[]|undefined;
if (!arr) {
return;
}
return arr[0];
}
In the first function, arr's type is State[T] before the if block. It becomes "foo"[] | "bar"[] afterwards. In test2, I cast arr to the actual value type of the state manually and the return value type is correct.
It looks like the generic type T is lost after the type guard filtering out undefined. Is this behavior expected?
Upvotes: 0
Views: 319
Reputation: 328758
I think what's going on here is similar to this reported issue and this Stack Overflow question... when you read a property from a constrained generic (e.g., State[K]
where K extends keyof State
), the generic type gets widened to its constraint (so K
becomes keyof State
, and State[K]
is evaluated as State[keyof State]
which is "foo"[] | "bar"[] | undefined
. This isn't exactly wrong in your case, it is true that state[type]
is of type "foo"[] | "bar"[] | undefined
... it's just not as specific as you'd like.
You want to see state[type]
as something like K[] | undefined
. But the compiler just doesn't do this for you... it would require some higher-order type analysis that the compiler doesn't know how to perform. It would have to be able to calculate that Exclude<State[K], undefined>[0]
is equivalent to K
, and it can't.
The type assertion you're using in test2
seems like a reasonable workaround to me.
Another possibility is to widen the type of the state
parameter from State
to something that it is forced to treat as K[] | undefined
when you index into it. For example, something like Partial<Record<K, K[]>>
. The only problem with using that directly is that the compiler will use both the type
and state
parameters to infer K
, and that could widen K
up to the full keyof State
. We only want to use type
to infer K
, so it would be good to tell the compiler to use the type parameters in state
in a "non-inferential" way. One way to do that, according to a language maintainer, is to replace K
with K & {}
in the places where we don't want inference to happen. That leads us to this:
function test3<K extends keyof State>(
state: Partial<Record<K & {}, K[]>>,
type: K
) {
const arr = state[type];
// const arr: { [P in K]?: P[] | undefined; }[K]
if (!arr) {
return;
}
// const arr: K[]
return arr[0];
}
Now the compiler understands that after undefined
is eliminated, arr
will be of type K[]
. And thus the return type of test3
is inferred as K
. Hooray! Yes, that's a lot of hoops to jump through compared to your type assertion, so in practice I'd probably just assert and move on.
Hope that helps; good luck!
Upvotes: 1
Reputation: 74560
Actually your first example is inferred properly, the typecast and array type in second example are a bit inaccurate. In your concrete case it doesn't matter, as you only return the first element. First things first, let's look at test
:
test
functionState[T]
is the same as "foo"[] | "bar"[] | undefined
. You can also write it like this:
State[T] -> State["foo" | "bar"] -> State["foo"] | State["bar"] -> "foo"[] | "bar"[]
=> "foo"[] | "bar"[] | undefined (optional properties possible)
So it is correct that arr
has the type "foo"[] | "bar"[]
at the end of your function, arr[0]
type "foo" | "bar"
, because your if-block excludes the undefined value. IntelliSense displayed type representations can be a bit confusing, as sometimes they end up more verbose/granular, sometimes more compact/unresolved. The canonical type for the compiler is the same though.
test2
In the beginning, I said that your casted array type is slightly inaccurate. Lets assume, that we return the whole array in test1
and test2
(not only the first element), to illustrate the issue.
New function signatures of test
and test2
// test signature
<T extends Supported>(state: State, type: T): State[T]
// test2 signature
<T extends Supported>(state: State, type: T): T[] | undefined
Test case:
// define some variables
declare const state: State;
declare const stateType: "foo" | "bar";
// invoke functions
test(state, stateType); // return type: "foo"[] | "bar"[] | undefined
test2(state, stateType); // return type: Supported[] | undefined
Results:
const test_sample1: "foo"[] | "bar"[] | undefined = ["foo", "foo"] // works
const test_sample2: "foo"[] | "bar"[] | undefined = ["foo", "bar"] // <-- error!
const test2_sample1: Supported[] | undefined = ["foo", "bar"] // works
const test2_sample2: Supported[] | undefined = ["foo", "foo"] // works
So with your manual cast in test2
you could return ["foo", "bar"]
, which would not be possible with test
. Part of the reason is, that following is not the same:
"foo"[] | "bar"[] !== ("foo"|"bar")[]
Hope, it helps. Cheers
Upvotes: 1