4

I have the following class factory pickSomething that creates a type based on a passed in key from a ClassMap:

class A {
  keya = "a" as const;
}
class B {
  keyb = "b" as const;
}

type ClassMap = {
  a: A
  b: B
}


const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
  switch (key) {
    case 'a':
      return new A(); // Error: A is not assignable to A & B
    case 'b':
      return new B(); // Error: B is not assignable to A & B
  }
  throw new Error();
}

// It works fine externally
const a = pickSomething('a').keya;
const b = pickSomething('b').keyb;

It works fine externally (as you can see from const a = pickSomething('a').keya;). This means that externallly ClassMap[K] is mapping to the correct instance (A or B depending on the passed in key). However internally I get an error on each return statement. TypeScript expects ClassMap[K] to mean A & B. Is there a way to solve it with a better type annotation (without resorting to type assertions)?

basarat
  • 261,912
  • 58
  • 460
  • 511
  • If *you* have to ask, there's probably none. But we can probably find a few reportson TypeScript's issue tracker… – Bergi Jan 29 '21 at 01:18
  • Isn't this yet another case of [this problem](https://stackoverflow.com/q/56505560/1048572)? TypeScript doesn't know that `K` is not instantiated as `'a' | 'b'`. – Bergi Jan 29 '21 at 01:24
  • This is a case where `K` could be broader than a single key. You could theoretically call `pickSomething<'a' | 'b'>('b')` and your return type wouldn't be valid. In practice, you're not gonna do that. So just use the assertions. – Linda Paiste Jan 29 '21 at 01:58
  • I suppose overloading could help, but that wouldn't use the `ClassMap` – Bergi Jan 29 '21 at 03:00

1 Answers1

2

I think the general issue is that TypeScript does not narrow a type parameter extending a union via control flow analysis, the way it normally does for values of specific union types. See microsoft/TypeScript#24085 for discussion. You've checked whether key is "a" or "b", and key is of type K, but this has no implications on K itself. And since the compiler doesn't know that K is anything narrower than "a" | "b", it doesn't know that ClassMap[K] can be anything wider than A & B. (Since TypeScript 3.5, writing to a lookup property on a union of keys requires an intersection of properties; see microsoft/TypeScript#30769.)

Technically speaking it is correct for the compiler to refuse to do this narrowing, since nothing stops the type parameter K from being specified as the full union type "a" | "b", even if you check it:

pickSomething(Math.random() < 0.5 ? "a" : "b"); // K is "a" | "b"

Currently there's no way to tell the compiler that you don't really mean K extends "a" | "b", but instead something like K extends "a" or K extends "b"; that is, not a constraint to a union, but a union of constraints. If you could express that, it would perhaps be possible for checking key to narrow K itself and then understand that, for example, ClassMap[K] is just A when key is "a". See microsoft/TypeScript#27808 and microsoft/TypeScript#33014 for the relevant feature requests there.

Since those aren't implemented, the easiest way to get your code to compile with the minimum changes is to use type assertions. It's not fully type safe, of course:

const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
    switch (key) {
        case 'a':
            return new A() as A & B
        case 'b':
            return new B() as A & B
    }
    throw new Error();
}

but the JavaScript produced is idiomatic, at least.


Other possibilities: the compiler does allow you to return a lookup property type T[K] by actually looking up a property of key type K on an object of type T. You could refactor your code to do this:

const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
    return {
        a: new A(),
        b: new B()
    }[key];
}

If you don't want to actually create new A() and new B() every time you call pickSomething, you could use getters instead, so that only the desired code path is actually followed:

const pickSomething = <K extends keyof ClassMap>(key: K): ClassMap[K] => {
    return {
        get a() { return new A() },
        get b() { return new B() }
    }[key];
}

This compiles without error and is type safe. But it's weird code, so I don't know if it's worth it. I think a type assertion is, for now, the right way to go. And hopefully at some point, there will be a better solution to microsoft/TypeScript#24085 that makes your original code work without need for the assertion.


Playground link to code

jcalz
  • 264,269
  • 27
  • 359
  • 360