Typescript: type narrowing not working for `in` when key is stored in a variable
Asked Answered
V

3

10

Consider this simple snippet. I'm also pasting it here:

type A =
  | {
      b: number;
    }
  | {
      c: number;
    };

function f1(a: A) {
  if ('b' in a) {
    return a['b']; // No problem!
  }
  return 42;
}

function f2(a: A) {
  const key = 'b';
  if (key in a) {
    return a[key];  // Property 'b' does not exist on type 'A'
  }
  return 42;
}

Why doesn't the type of a get narrowed to {b: number} in f2? (as it is for f1)

Vowel answered 30/10, 2020 at 23:34 Comment(3)
It's just as your title says - it doesn't work when you use a variable. Apparently, it adds a lot of complexity to the analysis that the compiler has to to, hence why the TS guys didn't implement this.Eudo
It works if you are explicit about the key type. For example, const key = 'b' as keyof A;.Pecten
@JakeHolzinger keyof A is never because there are no keys common to all the constituents of the A union. Writing const key = 'b' as keyof A is equivalent to writing const key = 'b' as never which is explicit but unsound and usually not advisable. The type inferred by const key = 'b' is just 'b'. If you are explicit about that, as in const key: 'b' = 'b' or maybe `const key = 'b' as 'b', you have the same as shown above. The problem is not explicitness.Nestornestorian
N
11

This is essentially the same issue as microsoft/TypeScript#10530; type narrowing from control flow analysis only happens for properties that are directly literals like "b" and not for arbitrary expressions whose types are literal types. Issue #10530 talks about narrowing via property access... like a.b or a["b"], which does cause a to be narrowed, vs a[key], which does not.

As you've noticed, this also happens with the in operator type guard (as implemented in microsoft/TypeScript#15256), where "b" in a narrows the type of a, but key in a does not. This is not explicitly mentioned in #10530 (which pre-dates the in type guard) but I don't think there's another existing issue specifically about this.

According to microsoft/TypeScript#10565, an initial attempt to address the issue in #10530, adding type guard functionality for arbitrary expressions of literal types significantly worsens the compiler performance. Maybe performing extra analysis for all x in y checks would be less expensive than performing extra analysis for all y[x] property accesses, but at least so far nobody has cared much.

You could always open your own issue about it in GitHub (many issues end up being duplicates, and I'm not 100% sure this wouldn't just be considered a duplicate of #10530, or that there isn't some other issue this duplicates), but practically speaking it's probably not going to change anytime soon.


If you want a workaround for the case where you can't just replace key with a string literal, you could write your own user-defined type guard function called hasProp(obj, prop). The implementation would just return prop in obj, but its type signature explicitly says that a true result should cause obj to be narrowed to just those union members with a key of type prop:

function hasProp<T extends object, K extends PropertyKey>(
    obj: T, prop: K
): obj is Extract<T, { [P in K]?: any }> {
    return prop in obj;
}

and then in your function, replace key in a with hasProp(a, key):

function f3(a: A) {
    const key = 'b';
    if (hasProp(a, key)) {
        return a[key];  // okay
    }
    return 42;
}

Playground link to code

Nestornestorian answered 31/10, 2020 at 3:56 Comment(9)
Out of curiosity, in what way does the workaround improve over as keyof A? It certainly looks more complex. For instance, const key = 'asdf'; has the same behavior when the more complex type is used.Pecten
After working with the example more closely I see that there are benefits to using the more complex function/type, but only when key is const. If the variable is not constant like var, let, or a function parameter, then the complex type doesn't seem to make a difference, but I'd be happy to hear otherwise.Pecten
keyof A is never, so unless you want the compiler to interpret a[key] as never (see here), just about anything is an improvement. I'd really recommend forgetting about as keyof A as a possible way to deal with this.Nestornestorian
When you write var key = "b" or let key = "b" the compiler widens the type of key to string. When you have a key of type string, the compiler cannot use any obvious method to narrow the type of the object you're indexing into. For example, if k and k1 are both of type string, the type system sees no difference between if (k in obj) return obj[k] and if (k in obj) return obj[k1] (see microsoft/TypeScript#34867). If you annotate like let key: "b" = "b"; then the user-defined type guard works again.Nestornestorian
Understood. Coercing the type into never is not a good idea. I was mostly trying to understand what benefits the more complex solution provides, in terms of compile time errors. The additional info about a[key] = never makes things more clear. I was expecting the more complex type to prevent me from making mistakes in this context. Like only allow me to use a property value that exists in at least one of the types (e.g. const key = 'asdf' gets interpreted as an error since asdf isn't a valid key for A.)Pecten
@Nestornestorian could you explain the question mark in obj is Extract<T, { [P in K]?: any }> ? Removing it doesn't seem to make any difference. What is it doing there?Vowel
@Vowel the question mark only makes a difference if the union type you're discriminating has an optional property. If A were, for example, {b?: number} | {c: number} then you'd see that Extract<A, {[ P in "b" ]: any}> evaluates to never, while Extract<A, {[ P in "b"]?: any}> evaluates to {b?: number}. Whether that difference is important to you. and which behavior is preferable if so, is up to you of course.Nestornestorian
@Nestornestorian to confirm, obj is Extract<T, { [P in K]?: any }> is an equality statement right? And Extract<T, { [P in K]?: any }> is saying... take this key out of the object... the value can be anything. If you cannot take the key from the object, then it'll become obj is false then it all becomes false?Trinatte
Not at all. obj is … is a type predicate, and the Extract utility type filters unions. So it means "if this function returns true, then obj can be narrowed from the full union T to just those members with a key of type K, otherwise it can be narrowed from the full union T to just those members without a key of type K". You might want to read the TS docs or make your own post if you run into trouble. Good luck!Nestornestorian
C
3

You need custom type guard functions like this:

function isin<T>(key: PropertyKey, obj: T): key is keyof T {
  return key in obj;
}

TypeScript doesn't narrow the type of a key stored in a variable, apparently for a performance reason.

For an alternative approach and the detailed reason for why TypeScript doesn't do it, see this answer: https://mcmap.net/q/1071174/-typescript-type-narrowing-not-working-for-in-when-key-is-stored-in-a-variable

Creature answered 28/6, 2022 at 3:0 Comment(1)
Unlike the other version this works is the type of key is not narrowed down at compile tmie. e.g. key : 'a' | 'b'Juliannajulianne
W
1

In older typescript version 3.6.3 both existing answers above did not work for me. I am stuck in an older version because of my target environment. Please see below my working version:

/**
 * Type guard function to check if a property exists in an object.
 * 
 * @template T - The type of the object. Must extend object.
 * 
 * @param {PropertyKey} key - The property key to check.
 * @param {T} obj - The object in which to check for the property.
 * 
 * @returns {boolean} - True if the property exists in the object, false otherwise.
 * 
 * If true, the TypeScript type of `key` is narrowed to be a keyof `T`, 
 * indicating it exists as a property within the object `T`.
 */
export function is_in<T extends Object>(
    key: PropertyKey, obj: T
): key is keyof T {
    return key in obj;
}
Wyant answered 22/6, 2023 at 4:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.