Search code examples
typescriptswitch-statementtypescript-genericsstring-literals

Typescipt string literals as generics: Exhausiveness check in switch


I have the following oversimplified code snippet:

type a = 'a' | 'b';
const fn = <T extends a>(param: T): T => {
  switch(param) {
    case 'a':
      return 'a' as T;
    case 'b':
      return 'b' as T;
  }
};

I cannot figure out, why does the compiler complain about the lack of return, and is there a way to fix it that is future proof (e.g. not adding a default case, I wanna make sure all cases are explicitly handled, so if in the future the type is extended, I do want it to fail)

it works when I remove the generic, but in my scenario, the return type is generic on T


Solution

  • This is currently a missing feature of TypeScript, reported at microsoft/TypeScript#13215. Generics and narrowing don't work together very well; if you want to get exhaustiveness checking from switch/case statements, then you'll need the relevant value to be a union type directly and not a generic type constrained to a union. Maybe someday TypeScript will automatically apply exhaustiveness checks for generics, but for now it's not part of the language.


    For the example code as given, the simplest approach is to widen param from T to "a" | "b" when doing the check:

    type AB = 'a' | 'b';
    const fn = <T extends AB>(param: T): T => { // okay
      const p: AB = param; // <-- widen
      switch (p) {
        case 'a':
          return 'a' as T;
        case 'b':
          return 'b' as T;
      }
    };
    

    You could also use the satisfies operator with a type assertion to safely widen a value without copying it to a new variable. That is, x satisfies Y as Y will only compile if x is assignable to Y (the satisfies check) and the whole expression will be treated as type Y (the as assertion):

    to provide context that param should be

    const fn = <T extends AB>(param: T): T => { // okay
      switch (param satisfies AB as AB) {
        case 'a':
          return 'a' as T;
        case 'b':
          return 'b' as T;
      }
    };
    

    These approaches, of course, lose the generic behavior, so neither "a" nor "b" will be seen as assignable to the generic type parameter T. That means you still need to use the type assertions in return "a" as T and return "b" as T. There are often ways to refactor code to maintain the generic behavior, but they will necessarily not use a switch/case statement and are therefore out of scope for the question as asked.

    Playground link to code