Currently the TypeScript typings for the includes()
array method don't allow it to act as type guard on its input. This was suggested at microsoft/TypeScript#36275, but declined as too complex to implement. The reason why it isn't allowed has to do with what it implies when includes()
returns false
. It's easy to reason about the true
result; if arr.includes(x)
is true
, then of course x
has to be the same type as the elements of arr
. But if arr.includes(x)
is false
, then it is quite often a mistake to say that the type of x
is not the type of the elements of arr
. For example, consider what would happen here:
function hmm(x: string | number) {
if (!([1, 2, 3].includes(x))) {
x.toUpperCase();
}
}
The value x
is of either a string
or a number
type. If [1, 2, 3].includes(x)
is true
, then we know x
is a number
. But if it is false
, we absolutely cannot conclude that x
is a string
. But that's what would happen if includes()
were a type guard function. There is no built-in support to say "oh just ignore the negative case". That would require something like "one-sided" or "fine-grained" type guard functions, as requested in microsoft/TypeScript#15048, one of the issues you mentioned.
This change would be immediately noticeable and unpleasant, as includes()
calls throughout the TypeScript-using world would suddenly see very strange effects where their searched values got narrowed incorrectly, possibly all the way to the never
type.
So the naive approach to making it a type guard would be a problem. We could try to be more sophisticated and only allow the function to act as a type guard in circumstances like your code, where the array element type is a union of literal types. But this starts to look more complicated and bizarre, involving conditional types and this
parameters, maybe like this:
interface ReadonlyArray<T> {
includes(
this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
searchElement: string
): searchElement is string & T;
}
That tries to detect if T
is a string literal type, but not string
itself, and then it makes the function act as a type guard. But even that is not safe, since nothing requires an array to contain every element of its type:
function yuck(x: "a" | "c") {
const arr: readonly ("a" | "b")[] = ["b"]; // <-- doesn't contain "a"
if (!(arr.includes(x))) {
x; // x has been erroneously narrowed to "c"
({ c: 123 })[x].toFixed(1); // runtime error
}
}
That might not be likely in practice, but it's a complication. Even if we didn't care about that, adding new weird call signatures to globally available functions can have noticeable effects on other people's code. (Inference does weird things with overloaded functions, see ms/TS#26591 among countless others). It's just not worth the effort and risk to all of TypeScript just to support this one use case.
If you're only doing this once, I'd recommend you just assert and move on:
const myAwesomeFunction = (field: keyof SomeType): void => {
if (array.includes(field)) {
doStuff(field as "a" | "b" | "c") // assert
} else {
field as "foo" | "bar" // assert
}
}
Otherwise you could wrap it in your own custom type guard function and use it when you need this behavior:
function myIncludes<T extends U, U>(
arr: readonly T[], searchElement: U
): searchElement is T {
return (arr as readonly any[]).includes(searchElement);
}
const myAwesomeFunction = (field: keyof SomeType): void => {
if (myIncludes(array, field)) {
doStuff(field)
} else {
field // (parameter) field: "foo" | "bar"
}
}
Or, if you really want to see this behavior everywhere in your code base, you could merge that call signature into the Array<T>
interface; this is what it would be like if microsoft/TypeScript#36275 had been accepted, but it doesn't affect anyone else.
// declare global { // uncomment this if you're in a module
interface ReadonlyArray<T> {
includes(
this: ReadonlyArray<T extends string ? string extends T ? never : T : never>,
searchElement: string
): searchElement is string & T;
}
// } // uncomment this if you're in a module
const myAwesomeFunction = (field: keyof SomeType): void => {
if (array.includes(field)) {
doStuff(field)
} else {
field // (parameter) field: "foo" | "bar"
}
}
Playground link to code