typescript

Narrowing for TypeScript "in" operator with arbitrary key?


function dostuff(tag: any) {
  if (tag in primitives) return primitives[tag]
  // ...

If the condition is true, tag still has the type any.

Is there a way to make this work according to in operator narrowing?


Solution

  • Currently, in operator narrowing only narrows the type of the second operand. So if you check key in obj, the type of obj might be narrowed, but key will be unaffected. People generally use in operator narrowing to filter a union-typed object.


    There is an open feature request at microsoft/TypeScript#43284 to allow in to also narrow the first operand, so that key in obj will cause key to be assignable to keyof typeof obj. It's unlikely that this will ever be implemented, seeing as a virtually identical feature request at microsoft/TypeScript#48149 was declined.

    The main problem with narrowing key to be keyof typeof obj is that TypeScript object types are not sealed or "exact" (to use terminology from Flow). If obj is of type {a: string}, that means it has a string-valued a property, but it does not mean that it has only that property. It might well have a lot of other properties that TypeScript doesn't know about. TypeScript object types are open, extendible, or "inexact". That allows you to extend interfaces and add properties while maintaining compatibility with the extended type. There's a longstanding open feature request at microsoft/TypeScript#12936 to support exact types. If that were ever implemented, then if obj were of an exact type, key in obj really would be enough to narrow key. But as it stands now, if obj has more properties than TS knows about, then key in obj doesn't really give you any reason to believe that key happens to be in the set of keys TypeScript does know about. Here's an example of the kind of problem you run into:

    interface Foo {
        x: string,
        y: string,
    }
    
    function process(obj: Foo, key: string) {
        if (key in obj) { 
            // imagine that key is narrowed to 
            console.log(obj[key].toUpperCase())
        }
    }
    

    That looks like it might be fine, right? The key in obj check narrows key from string to keyof Foo, so that obj[key] is known to be string, and thus has a toUpperCase() method. But then:

    interface Bar extends Foo {
        a: boolean,
        b: number,
        c: Date
    }
    
    const bar: Bar = { x: "abc", y: "def", a: true, b: 123, c: new Date() };
    process(bar, "b"); // <-- RUNTIME ERROR!
    // obj[key].toUpperCase is not a function
    

    TypeScript will allow you to call process(bar, "b"), because bar is a Bar, which is a Foo, because TypeScript types are open and not sealed. And then we see the problem inside process. If TypeScript assumes that "b" in bar implies that "b" is actually "x" or "y", that's a problem, and you get a runtime error, because obj.b is not a string, so it has no toUpperCase() method. It's therefore not sound or type safe to allow such narrowing.


    So that's the stated reason why they're unlikely to implement it. Of course the current in-operator narrowing is also unsafe in a similar way, for the same reason:

    function processTwo(obj: { a: string } | { b: string }) {
        if ("a" in obj) {
            console.log(obj.a.toUpperCase())
        } else {
            console.log(obj.b.toUpperCase())
        }
    }
    
    const obj = { a: 123, b: "abc" };
    processTwo(obj); // <-- RUNTIME ERROR! 
    // obj.a.toUpperCase is not a function
    

    So the soundness issue is already present, and they allow it because this kind of thing doesn't happen very often. When people use in operator narrowing, they assume types are exact.

    In my mind the only problem with supporting key narrowing is determining how it should interact with object narrowing. If you check key in obj it certainly shouldn't narrow both key and obj, right? See this comment on microsoft/TypeScript#42384. Perhaps there's some heuristic, like... if key is wide like string, or a union, then we narrow key, but if key is some known string literal like "a" then we narrow obj. It's not obvious how to do this that wouldn't break existing code, though.


    Anyway, it's unlikely to happen. So if you need this functionality, you'll have to implement it yourself. You can do so by wrapping the in check in a user-defined type guard function like:

    function inOperator<T extends object>(key: any, obj: T): key is keyof T {
        return key in obj;
    }
    

    which enables the process() function above to be written like

    function process(obj: Foo, key: string) {
        if (inOperator(key, obj)) {
            console.log(obj[key].toUpperCase())
        }
    }
    

    That compiles, and key is narrowed to keyof Foo, so obj[key] is allowed and seen to be of type string. Of course that still has the problem of unsoundness, so just... don't call process with an obj which has more properties than Foo does, okay?

    Playground link to code