How to access an object value by a key and return its specific value type

What I want to achieve is getting a valid intellisense for an object value - in this case a function signature - if I try to access the value by the object's key. Below is an example that should illustrate what I'm up to. I came to this code more by trial and error, but what I really don't understand is why it is working outside the Bar constructor and fails within.

Playground link

const int = {
  read: () => { return 1 },
  write: (value: number) => { return }
}

const str = {
  read: () => { return "foo" },
  write: (value: string) => { return }
}

const types = {
  int,
  str
};

type Types = typeof types;
type TypeKeys = keyof Types;
type TypeValues = Types[TypeKeys];


function getType<T extends TypeKeys>(str: T): Types[T] {
  if (str in types) {
    return (types as any)[str];
  }

  throw new Error(`Unknown type '${str}'`);
}


const foo = getType("int");
foo.write(1);  // write(value: number): void --> good, intellisense shows the right function signature

const bar = getType("str");
bar.write("bar"); // write(value: string): void --> good, intellisense shows the right function signature


class Baz {
  constructor(type: TypeKeys = "int") {
    const t = getType(type);
    if (type === "int") {
      t.write(2);  // write(value: never): void --> bad, would expect to work as above
    } else {
      t.write("foo");  // write(value: never): void --> bad, would expect
    } 
  }
}

Inside the Baz constructor, the type parameter is of the union type TypeKeys. And the t variable is therefore of the union type TypeValues. Now, your expectation is that you can check the type of type and it will have an implication for the type of t, because the type of type and the type of t are correlated to each other. But the compiler really does not tend to track such a correlation across different union-typed variables; this is the subject of microsoft/TypeScript#30581. There's no way to get IntelliSense if the compiler doesn't know what's going on.


Up to and including TypeScript 4.5, the only ways to deal with this were either redundant but type safe, or terse but unsafe. Here's redundant but safe:

  if (type === "int") {
    const t = getType(type);
    t.write(2);
  } else {
    const t = getType(type);
    t.write("foo");
  }

Here we've duplicated the const t = getType(type); line and pushed it down into the scope after checking type === "int". Since type is already narrowed to either "int" or "str", then the two getType(type) lines are seen to return narrower types, and everything just works. Personally I think this is probably a reasonable approach, especially if you're planning to already have if/else blocks where you write code for individual cases. And this is probably the only way that gives you the IntelliSense you want.


Here's terse but unsafe:

const t = getType(type) as typeof int & typeof str; // assert
if (type === "int") {
  t.write(2);
} else {
  t.write("foo");
}

Here we've used a type assertion to lie to the compiler and say that t is the intersection of the two types instead of the union. That would mean you could write() a number to it or a string to it as you see fit. In actuality only one of those will work, and luckily the rest of the code does the right thing. That assertion doesn't duplicate things, but it's unsafe (e.g., you could switch around if (type === "int") to if (type !== "int") and the compiler wouldn't notice. As I said, I'd probably prefer the redundant version to this. Here the compiler will be happy with either a number or a string, so the IntelliSense will be too loose.


Starting in TypeScript 4.6, a fix at microsoft/TypeScript#47109 will be released that gives a way to get safe correlated union behavior. But you do have to refactor some of your types and type annotations to get it to work, so that the compiler is aware that you're looking for this behavior. You need to write a basic mapping type like this:

type TypeMap = { int: number, str: string };

And then you can define your other types in terms of it:

type Types = { [K in keyof TypeMap]: {
  read: () => TypeMap[K],
  write: (value: TypeMap[K]) => void
} };
type TypeKeys = keyof Types;

Then your types and getType() stay the same (but it's important that you annotate the return type of getType() in terms of Types:

const types = {
  int,
  str
};

function getType<K extends TypeKeys>(str: K): Types[K] {
  return types[str];
}

And then you can write a generic writeType() function like this:

function writeType<K extends TypeKeys>(type: K) {
  const t = getType(type);
  const vals = { int: 2, str: "foo" };
  t.write(vals[type]) // okay in TS4.6+
}

Notice how I've refactored the if/else clause to be a lookup in an object. The correlation between t and vals[type] is now tracked by the compiler and there's no error. This really sidesteps the whole IntelliSense thing, because you're calling the function exactly once, on something that needs to be correlated to t.

And you can invoke this function inside your Baz constructor (you can't make the Baz constructor do it inline unless you make all of Baz generic):

class Baz {
  constructor(type: TypeKeys = "int") {
    writeType(type);
  }
}

So that's neat. Personally with the example code as written I'd probably still go with the "redundant" version on the top, since the extra code is minimal. But it's good to see that it's at least possible for the compiler to follow the sort of correlated union manipulation that you're doing here.

Playground link to code


If you give the union a discriminant, then that can be used in the if statement to narrow the type of t as you want to. The as const assertions are needed so that the type properties are not widened to string.

const int = {
  type: 'int',
  read: () => { return 1 },
  write: (value: number) => { return },
} as const

const str = {
  type: 'str',
  read: () => { return "foo" },
  write: (value: string) => { return },
} as const

// ...

class Baz {
  constructor(type: TypeKeys = "int") {
    const t = getType(type);
    if (t.type === "int") {
      t.write(2);
    } else {
      t.write("foo");
    } 
  }
}

Playground Link