Home > Mobile >  Return a discriminating union from object key
Return a discriminating union from object key

Time:05-25

I'm trying to return a discriminated union given an objects key:

type Foo = {
    key: 'foo';
    value: number;
};

type Bar = {
    key: 'bar';
    value: string;
};

type Obj = {
    foo: number;
    bar: string;
};

function getField(obj: Obj, key: keyof Obj): Foo | Bar {
    // Error here:
    // Type '{ key: keyof Obj; value: string | number; }' is not assignable to type 'Foo | Bar'.
    return { key, value: obj[key] };
}

When I try the above code I get the error: Type '{ key: keyof Obj; value: string | number; }' is not assignable to type 'Foo | Bar'.

I think I need to link the type of obj[key] to the type of key somehow but I'm not sure how to accomplish this without casting.

CodePudding user response:

You could do it like this:

type Foo = {
    key: 'foo';
    value: number;
};

type Bar = {
    key: 'bar';
    value: string;
};

type Obj = {
    foo: number;
    bar: string;
};

function getField(obj: Record<'foo' | 'bar', any>, key: 'foo' | 'bar'): Foo | Bar {
    return { key, value: obj[key] };
}

CodePudding user response:

Even though it is true that the return value of getField() will definitely be either a Foo or a Bar, the compiler cannot see it. The key parameter is of a union type "foo" | "bar", and therefore obj[key] is also of a union type, number | string. These types are the correct types. But the types of key and obj[key] do not contain sufficient information to recognize that { key, value: obj[key] } will be assignable to type Foo | Bar.

If I hand you a value k of type "foo" | "bar" and a value v of type number | string, you'd have no reason to believe that { key: k, value: v } would be a valid Foo or a Bar. It's quite possible that k is "foo" while v is some string.

Now, we happen to know that key and obj[key] are correlated in a way that's not captured by looking at their types separately. If key is "foo" then obj[key] is number, and if key is "bar", then obj[key] is string. But unfortunately the compiler only sees the separate types, the same as the k and v example from before.

It would be nice if you could tell the compiler to consider the "foo" situation separately from the "bar" situation, but that just can't happen in a single line of code with expressions of union types.

This issue with correlated union types is the subject of microsoft/TypeScript#30581.


TypeScript 4.6 introduced some improvements in microsoft/TypeScript#47109 to address this issue.

The general approach is refactor to use a generic function, so that the single line of code can be evaluated for just "foo" or just "bar". That is, we make it generic in K extends keyof Obj. And we must also refactor the types so that the compiler can see Foo and Bar being some generic function of K.

Here is the refactoring necessary:

type FooBar<K extends keyof Obj> = { [P in K]: { key: P, value: Obj[P] } }[K];
type Foo = FooBar<"foo">;
type Bar = FooBar<"bar">;

The FooBar<K> type is a distributive object type as coined in microsoft/TypeScript#47109. The type FooBar<"foo"> is the same as your original Foo, and FooBar<"bar"> is the same as Bar. And FooBar<keyof Obj> is the same as Foo | Bar.

Now you can write getField() like this:

function getField<K extends keyof Obj>(obj: Obj, key: K): FooBar<K> {
  return { key, value: obj[key] }; // okay
}

And verify that it works as desired:

const obj: Obj = { foo: 1, bar: "x" };
const f: Foo = getField(obj, "foo");
const b: Bar = getField(obj, "bar");

Hooray!


Note well: it is important that FooBar is written in terms of a generic {key: P, value: Obj[P]} and that getField() is implemented with the analogous {key: key, value: obj[key]}. If you rewrite FooBar to be unrelated to the Obj type, such as, for example,

type FooBar<K extends keyof Obj> = Extract<Foo | Bar, { key: K }>;

then you would get the same error again:

function getField<K extends keyof Obj>(obj: Obj, key: K): FooBar<K> {
  return { key, value: obj[key] }; // error!
}

Playground link to code

  • Related