zeroliu
zeroliu

Reputation: 1040

Type guard expands generic to all possible values?

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?

http://www.typescriptlang.org/play/#code/C4TwDgpgBAygrmMB7ATsCATKBeKByAMySTwB88AjAQxTwG4AoUSWYK9HKAbwaigG0A0lACWAO1gJkaTAF0A-AC4og-rIYBfBgThiAxsBFIJ6AM7AAPABUoEAB7oxGU1ADWEEEgKt2EAHwAFOa+yjBs6AA0UMwQylYAlNy8UHrG5lA0KJzB6Pwxsox8It4BAISZiTx8fCgQwHAoYoVQWjV1DRKZ-AAMBVAA9P0pacAZKCjKAERESJNqUKRQk9Qoc+paOvqGxtEQ5gBM1rYOEE4u7p7eYb6BObE+kdHg9wlJfKli6ZnZ4RB5z7IMi4rGpSLoMBACOJMM1ilAyhU3tVavVGs0NFBkiiOmMUD0+oNhp9Rpk4mpNAwgA

Upvotes: 0

Views: 319

Answers (2)

jcalz
jcalz

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!

Link to code

Upvotes: 1

ford04
ford04

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 function

State[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.

Comparison with 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")[]

Playground

Hope, it helps. Cheers

Upvotes: 1

Related Questions