Home > Software engineering >  returning T inside a type resolves to never when a string enum is passed as generic type
returning T inside a type resolves to never when a string enum is passed as generic type

Time:05-24

Consider the following enum A:

enum A {
  ONE,
  TWO = "TWO"
}

I want to write a generic type which checks if a string or number is part of A. This is what I have come up with:

type IsAValue<T extends string | number> = T extends string
  ? T extends `${A}`  
    ? "true1"
    : "false1"
  : `${T}` extends `${A}` 
    ? "true2"
    : "false2"

This seems to work great.

type Test1 = IsAValue<0>
//   Test1 = "true2"

type Test2 = IsAValue<A.ONE>
//   Test2 = "true2"

type Test3 = IsAValue<"TWO">
//   Test3 = "true1"

type Test4 = IsAValue<A.TWO>
//   Test4 = "true1"

type Test5 = IsAValue<9999>
//   Test5 = "false2"

type Test6 = IsAValue<"NOT IN ENUM">
//   Test6 = "false1"

But now I don't want to return "true1" or "true2" but instead just return T.

type IsAValue2<T extends string | number> = T extends string
  ? T extends `${A}`  
    ? T
    : "false1"
  : `${T}` extends `${A}` 
    ? T
    : "false2"

Yet unexpectedly this breaks for A.TWO.

type Test7 = IsAValue2<A.TWO>
//   Test7 = never

This is strange since we just saw that it evaluates to "true1" in IsAValue. When I return T I would simply expect A.TWO to be the output here.

Why does this resolve to never if never is not even a type in any branch of IsAValue2. And why does this still work for the other types?

Playground

CodePudding user response:

This is a known bug in TypeScript, see microsoft/TypeScript#41778. Even though A.TWO extends "TWO" is seen as true, the compiler does not manipulate enum types as one would expect. Specifically, A.TWO & "TWO" evaluates to never, even though (in my opinion) it should evaluate to A.TWO. See microsoft/TypeScript#21998 for an issue about the intersection. And in the true branch of the conditional, the compiler changes T to T & "TWO" in order for it to recognize that T is indeed assignable to "TWO". And that's never. It's kind of a mess.

Maybe that bug will be fixed at some point, but who knows when that will be. In the mean time there are workarounds. One would be to "copy" the type before checking it. In the case of a string enum, the easiest thing here would be to transform it via template literal as well before checking it:

type IsAValue2<T extends string | number> = T extends string
  ? `${T}` extends `${A}` // <-- this change here
  ? T
  : "false1"
  : `${T}` extends `${A}`
  ? T
  : "false2"

type Test7 = IsAValue2<A.TWO>
//   Test7 =  A.TWO             

This evaluates as desired for Test7 and the other ones don't change. That implies you should collapse the whole thing to

type IsAValue<T extends string | number> =
  `${T}` extends `${A}` ? T : "false"

In the general case, you could copy a parameter by using conditional type inference, like T extends infer U ? ... : never:

type IsAValue<T extends string | number> = T extends string
  ? (T extends infer U ? U extends `${A}` ? T : "false1" : never)
  : `${T}` extends `${A}` ? T : "false2"

This also produces the desired results.

Playground link to code

  • Related