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?
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.