Edy Bourne
Edy Bourne

Reputation: 6207

How to get TypeScript to infer the correct type when value type can be any number of interfaces?

Perhaps the title is not clear, this is tough to summarize.

I have two interfaces, such as:

interface KeyA {
    a: ValueA;
}

interface KeyB {
    b: ValueB;
}

and a type which indicates a given value can be of KeyA, KeyB , or an empty object:

type KeyExpr = KeyA | KeyB | Record<string, never>;

Assuming I am given an instance of KeyExpr type, I would to access one of it's properties (either a or b in this example) and have the resulting type be ValueA or ValueB accordingly:

function func(keyExpr: KeyExpr) {
    const value = keyExpr['a']; 

    // how to make the type of "value" by ValueA here?
}

I am able to obtain the possible keys for KeyExpr like so:

export type KnownKeys<T> = {
    [K in keyof T]: string extends K ? never : number extends K ? never : K;
} extends infer R
    ? R extends { [_ in keyof T]: infer U }
        ? U
        : never
    : never;

const keys = KnownKeys<KeyExpr>;

...now the type of keys is "a" | "b", but still accessing a property on something that is of KeyExpr type yields the type never (meaning even though I used a type "a" | "b" to access it, it still resolved to that more generic Record type).

Any ideas how to improve this or just can't be done?

Thanks!

Upvotes: 1

Views: 145

Answers (1)

jcalz
jcalz

Reputation: 329278

If you have a value whose type is a union of object types, you can use the in operator to narrow the apparent type of that value. The type checker will automatically filter the union to just those members known to have or not known to have the key you're checking.

In the case of a value of type KeyExpr, it looks like this:

function func(keyExpr: KeyExpr) {
  if ("a" in keyExpr) {
    // keyExpr narrowed to KeyA
    const value = keyExpr.a // ValueA
  } else if ("b" in keyExpr) {
    // keyExpr narrowed to KeyB
    const value = keyExpr.b // Value B
  } else {
    // keyExpr narrowed to Record<string, never>
    keyExpr // Record<string, never>
  }

That works how you'd like it.


Note that it is technically not type safe for the type checker to do this, since object types in TypeScript are open and values may have properties not known to the compiler. That is, TypeScript types are not "exact types", as discussed in microsoft/TypeScript#12936. So something like this is possible:

declare const valueB: ValueB; 
const keyB = { a: "oops", b: valueB };
func(keyB); // no error

Here you've created a value named keyB which is assignable to KeyB but has an excess a property. If you explicitly annotated this like const keyB: KeyB = {a: "oops", b: valueB }, you'd get an excess property warning because the compiler would worry that you're throwing away the a property. But without that annotation it's fine.

And then func(keyB) compiles with no error because keyB is a KeyB which is a KeyExpr. And then, unfortunately, the implementation of func() goes down the wrong path because the compiler assumes that ("a" in keyExpr) means that keyExpr is a KeyA. If that implementation did any KeyA-specific things, you'd likely get a runtime error.

In practice this sort of thing is rare, so you might not need to worry about it.


But if you are worried about it, then you should really try to make your KeyExpr type more robust. For example, you could make it a discriminated union. For example:

interface KeyA {
  type: "A",
  a: ValueA;
}

interface KeyB {
  type: "B"
  b: ValueB;
}

type KeyExpr = KeyA | KeyB | Partial<Record<string, never>>;


function func(keyExpr: KeyExpr) {
  if (keyExpr.type === "A") {
    const value = keyExpr.a // ValueA
  } else if (keyExpr.type === "B") {
    const value = keyExpr.b // Value B
  } else {
    keyExpr // Partial<Record<string, never>>
  }
}

Now there is a strongly-typed type property which will either be "A", "B", or undefined, and can be used to unequivocally identify which member of the union a value is:

declare const valueB: ValueB;
const keyB = { type: "B" as const, a: "oops", b: valueB };
func(keyB); // okay now

Playground link to code

Upvotes: 1

Related Questions