简体   繁体   中英

Typescript cannot infer correct type from generic interface property

I have some scenarios where I have an object that wields strings, numbers and booleans, and a getter that returns a value with correct type, eg something like this:

interface AllowedMapTypings {
    'str': string;
    'lon': number;
    'str2': string;
}

const obj: AllowedMapTypings = {
    'str': 'foo',
    'lon': 123,
    'str2': 'foo'
};

function foo<T extends keyof AllowedMapTypings>(key: T): AllowedMapTypings[T] {
    return obj[key];
}

let str = foo('str'); // correctly inferred type 'string'

However, if I use the inferred interface type as an argument, it doesn't work:

function fn<T extends keyof AllowedMapTypings>(key: string, kind: T, value: AllowedMapTypings[T]) {
    if (kind === 'str') {
        console.log(value.length); // Property 'length' does not exist on type 'AllowedMapTypings[T]'.
    }
}

It looks like the condition kind === 'str' doesn't do it's job as a type guard correctly. Am I missing something, or is this a missing feature/bug in TS?

This is a known limitation, see microsoft/TypeScript#13995 and microsoft/TypeScript#24085 . The kind of control-flow narrowing that happens when you do if (kind === 'str') {} does not act on generic type parameters or values of such types. I think one could argue that if (kind === 'str') {} should narrow the type of kind from T to T & 'str' , but even if the compiler did that for you it would not narrow the type of value . Even though you know the type of kind is correlated with that of value , the compiler doesn't.

You can always get around this with liberal uses of type assertions . If you want a bit more type safety you can use a workaround that widens the values of generic types to concrete unions, and packs correlated values into a single variable which can be narrowed the way you expect. For example:

type KindValuePair<K extends keyof AllowedMapTypings = keyof AllowedMapTypings> =
  K extends any ? [K, AllowedMapTypings[K]] : never;

The type KindValuePair expands to ["str", string] | ["lon", number] | ["str2", string] ["str", string] | ["lon", number] | ["str2", string] ["str", string] | ["lon", number] | ["str2", string] , which is the union of things you actually want to allow as kind and value . (I could have just manually set KindValuePair to that union, but instead I'm using a distributive conditional type to have the compiler figure it out for me.)

Then you can do this:

function fn<T extends keyof AllowedMapTypings>(key: string, kind: T, value: AllowedMapTypings[T]) {
  const kindValuePair = [kind, value] as KindValuePair; // assertion here
  if (kindValuePair[0] === 'str') {
    console.log(kindValuePair[1].length); // okay
  }
}

You assert that [kind, value] is of type KindValuePair , and then you can use control flow narrowing on kindValuePair to maintain the relationship you expect to see between its elements after you check against 'str' . If this works for you, you can even make the function concrete and not generic using rest parameters :

function fn(key: string, ...kindValuePair: KindValuePair) {
  if (kindValuePair[0] === 'str') {
    console.log(kindValuePair[1].length); // okay
  }
}

This completely avoids assertions and is as type-safe as I can imagine making. It also has the side effect of outlawing calls like this:

fn("", Math.random() < 0.5 ? 'str' : 'lon', 1); // error

which are allowed in the generic version (it specifies T as 'str' | 'lon' ).

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM