Home > Software engineering >  Create and return value assignable to particular key of object
Create and return value assignable to particular key of object

Time:09-27

I have the following code, that is supposed (in this simplified application), to return a value assignable to a particular key of Obj.

interface Obj {
  foo: number;
  bar: string;
}

const foo = createValueOfObjKey("foo");
const bar = createValueOfObjKey("bar");

function createValueOfObjKey<Key extends keyof Obj>(key: Key): Obj[Key] {
  switch (key) {
    case "foo":
      return 0;
    case "bar":
      return "";
  } 
}

However, on both the return statements, the compiler complains (exemplified by the "foo" return) about not being able to assign to never:

Type 'number' is not assignable to type 'Obj[Key]'.
  Type 'number' is not assignable to type 'never'.(2322)

(Funnily enough, the constant's type, holding the return value, is inferred correctly, e.g. const foo will be string, and const bar number)

It seems like typescript isn't able to narrow down Obj[Key] to Obj["foo"]. So I thought maybe introducing a local variable would help:

    case "foo":
      // I have also tried other things, like Obj[Key].
      const val: Obj[typeof key] = 0;
      return val;

But that sadly still results in:

Type 'number' is not assignable to type 'Obj[Key]'.
  Type 'number' is not assignable to type 'never'.(2322)

I have found the following issues, but I'm uncertain if they are related:

Here the mentioned code as a playground.

CodePudding user response:

TypeScript as of TS4.8 cannot use control flow analysis to narrow or re-constrain generic type parameters. So in createValueOfObjKey(key) where key is of generic type K extends keyof Obj, you can check key via switch, and that will narrow the type of key... but K itself will stubbornly stay the same. And therefore the compiler has no idea if a string or a number will be assignable to Obj[K] and it complains.

This is currently a missing feature of TypeScript. There's an open issue at microsoft/TypeScript#33014 asking to add support for code like you've written. Until and unless that or something like it is implemented, there are only workarounds.


The simplest workaround is just to assert that what you are doing is correct. It's the least type safe, but it doesn't require you change your implementation much:

function createValueOfObj<K extends keyof Obj>(key: K): Obj[K] {
  switch (key) {
    case "foo":
      return 0 as Obj[K]; // assert
    case "bar":
      return "" as Obj[K]; // assert
  }
  throw new Error("UNREACHABLE"); // compiler can't see exhaustive so you need this 
}

If you want more type safety, then you need to write something the compiler can verify as accurate. For an indexed access type like T[K], the compiler can only easily verify that you've produced a valid value of that type if you take an object of type t and a key of type k and return the value t[k]. That is, you can perform an indexed access operation to get a verifiable indexed access type.

So you could write

function createValueOfObj<K extends keyof Obj>(key: K): Obj[K] {
  return {
    foo: 0,
    bar: ""
  }[key]; // okay
}

That compiles with no error, hooray!

If you're worried that this forces you to come up with all possible output values in advance, you could implement this lookup object lazily via getters instead of regular properties:

function createValueOfObjKey<K extends keyof Obj>(key: K): Obj[K] {
  return {
    get foo() { return 0 },
    get bar() { return "" }
  }[key]; // okay
}

That also compiles with no error, and now only the code corresponding to the actual key will be executed.


Let's verify that it works:

const foo = createValueOfObjKey("foo");
console.log(foo.toFixed(2)) // "0.00"
const bar = createValueOfObjKey("bar");
console.log(bar.toUpperCase()) // ""

Looks good.

Playground link to code

  • Related