Search code examples
typescripttype-safety

Avoid implicit `any` when using Object.values with union of Records


Why does the code below silently assumes fooValues is any. And is it possible to avoid this implicit any. I tried the "noImplicitAny" tsconfig option, but that doesn't seem to help.

type Record1 = Record<string,  { a: 1 }>
type Record2 = Record<string,  { b: 1 }>
const foo: Record1 | Record2 = {}
const bar: Record1 = {}
// Why is `fooValues` any[]???
const fooValues = Object.values(foo)
// This one is OK.
const barValues = Object.values(bar)

Playground link


Solution

  • This isn't an issue about implicit any. The any you're seeing here is actually quite explicit, and has to do with the standard library's call signatures for Object.values():

    interface ObjectConstructor {
      values<T>(o: { [s: string]: T } | ArrayLike<T>): T[];
      values(o: {}): any[];
    }
    

    That's an overloaded function with two call signatures. The first is a generic function that requires a parameter with an index-signature and returns a strongly-typed array; the second is a non-generic function that takes any non-nullish parameter and returns any[]. When you call Object.values(), the compiler has to choose which call signature to apply by trying each one in turn. Apparently when you call Object.values(bar) it chooses the first signature, but when you call Object.values(foo) it chooses the second.


    Let's investigate what's happening by breaking each call signature into its own separate function:

    const objectValues1: <T>(o: { [s: string]: T; } | ArrayLike<T>) => T[] = Object.values;
    const objectValues2: (o: {}) => any[] = Object.values;
    

    Here, objectValues1 only has the generic signature and objectValues2 has only the non-generic one. Let's look at bar:

    const barVals1 = objectValues1(bar); // okay {a: 1}[]
    // T is inferred as {a: 1}
    

    Great, objectValues1() works; T is inferred as {a: 1} and you get a strongly-typed output. Now let's try foo:

    const fooVals1 = objectValues1(foo); // error!
    // T inferred as {a: 1}, but
    /* Argument of type 'Record<string, { a: 1; }> | Record<string, { b: 1; }>' 
    is not assignable to parameter of type 'ArrayLike<{ a: 1; }> | { [s: string]: { a: 1; }; }'. */
    
    const fooVals2 = objectValues2(foo); // any[]
    

    Oops, that didn't work and we had to try objectValues2() which produces any. But why didn't it work?


    If you look, the compiler just infers T as {a: 1} instead of as the union {a: 1} | {b: 1}. Generic type parameter inference is an art, not a science. Well, for humans, anyway. For the compiler, type parameter inference operates by some heuristics that the language designers came up with. These heuristics will often reject a call that requires synthesizing a union type not present in any of the inference sites. Why? Because it turns out that this ends up accepting things which weren't intended. See this question for more info. The type of foo presents both {a: 1} and {b: 1} as possible candidates for T, but since neither choice works, the inference fails, and the compiler proceeds to the second call signature.


    So, what can you do? Well, the easiest thing is to just manually specify the generic type parameter as the union. The compiler won't infer it for you, but it will accept it if you specify it:

    const fooValues = Object.values<{ a: 1 } | { b: 1 }>(foo);
    // Array<{a: 1} | {b: 1}>
    

    Playground link to code