Array.includes
is a commonly used function in JavaScript. However, in TypeScript, its
typing is quite strict: The element being searched for must have the same type as that of the array
element.
This causes type errors if the array is of a literal type. For example, with the following code
snippet:
const okNumbers = [1, 3, 5, 7] as const;
console.log(`2 is OK? ${okNumbers.includes(2)}`);
The TypeScript compiler complains:
Argument of type ‘2’ is not assignable to parameter of type ‘1 | 3 | 5 | 7’.
A similar error occurs with a parameter of type number
:
const okNumbers = [1, 3, 5, 7] as const;
const myNumber = Math.random();
const myNumberOk = okNumbers.includes(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
The TypeScript compiler complains:
Argument of type ’number’ is not assignable to parameter of type ‘1 | 3 | 5 | 7’.
There are many solutions to circumvent this error. Let’s go over some of them before introducing the solution that I consider the most elegant and safe.
For the impatient, feel free to jump to the elegant and safe solution.
Solution 1: Use Type Assertion
The most straightforward solution is to use type assertion:
const okNumbers = [1, 3, 5, 7] as const;
const myNumber = Math.random();
const myNumberOk = (okNumbers as readonly number[]).includes(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
However, this is not type-safe because the assertion ignores errors if okNumbers
is not an array
of numbers.
Solution 2: Broaden the Type of the Array
const okNumbers: readonly number[] = [1, 3, 5, 7] as const;
const myNumber = Math.random();
const myNumberOk = okNumbers.includes(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
Although this solution does not use type assertion, it broadens the type of okNumbers
unnecessarily. As a result, the TypeScript compiler may be unable to deduce the narrowest types
resulting from other uses of okNumbers
.
A Less Intrusive Alternative
A less intrusive alternative is to broaden the type before calling includes
:
const okNumbers = [1, 3, 5, 7] as const;
const myNumber = Math.random();
const broadenedOkNumber: readonly number[] = okNumbers;
const myNumberOk = broadenedOkNumber.includes(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
While this preserves the narrowest typing of okNumbers
, create a new variable just to call
includes
is verbose, confusing, and error-prone.
Solution 3: Modify the Typing of Array.includes
One can also add an overload of Array.includes
:
interface ReadonlyArray<T> {
includes<U>(x: U & (T & U extends never ? never : unknown)): boolean;
}
const okNumbers = [1, 3, 5, 7] as const;
const myNumber = Math.random();
const myNumberOk = okNumbers.includes(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
The essence of this solution is to modify the typing of Array.includes
so that type checking
passes as long as the types of okNumbers
and myNumber
overlap. While this solution does seem to
address the typing of Array.includes
in one shot, there are multiple issues associated with it:
The TypeScript compiler still yields an error if
myNumber
is a literal that is not among the members ofokNumbers
. For example:const myNumber = 2 as const;
This solution broadens the typing of
Array.includes
, which may cause some typing errors in other situations to be uncaught. For example, the following code snippet passes type checking while logically it is likely considered erroneous:interface ReadonlyArray<T> { includes<U>(x: U & (T & U extends never ? never : unknown)): boolean; } const okNumbers = [1, 3, 5, 7] as const; const myNumberOrString: number | string = Math.random() > 0.5 ? "a string" : 2; const myNumberOk = okNumbers.includes(myNumberOrString); console.log(`myNumber is OK? ${myNumberOk}`);
Here,
myNumerOfString
is either a number or a string. If we would like to express the semantics of whetherokNumbers
includes some specified number,okNumbers.includes(myNumberOrString)
should report an error becausemyNumberOrString
may also be a string.
An Elegant and Safe Solution
In my opinion, the most elegant solution is to divide the semantics underlying the functionality of
Array.includes
into two categories, and then use different functions for each semantics.
We first define the following function:
function isInArray<T, Element extends T>(
element: T,
array: readonly Element[],
): element is Element {
const arrayT: readonly T[] = array;
return arrayT.includes(element);
}
Then:
- Use
Array.includes
when the array is not of a literal type and is unknown before runtime. This corresponds to the semantics of determining whether an array contains a certain element. - Use
isInArray(ele, array)
whenarray
is of a literal type and is known before runtime. This corresponds to the semantics of determining whetherele
equals one of the elements inarray
.
Examples
In the following example, Array.includes
is more appropriate, because the array is unknown before
runtime and we are interested to know whether the array contains certain elements:
function createArray() {
return Array.from({ length: 10 }, Math.random);
}
const okNumbers = createArray();
const myNumber = 2;
const myNumberOk = okNumbers.includes(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
const myRandomNumber = okNumbers.includes(Math.random());
console.log(`myRandomNumber is OK? ${myRandomNumber}`);
In the following example, isInArray
is more appropriate, because the array is known before runtime
and we are interested to know whether a variable equals one of the elements in array
:
const okNumbers = [1, 3, 5, 7] as const;
const myNumber = Math.random();
if (isInArray(myNumber, okNumbers)) {
// Here, myNumber is of type 1 | 3 | 5 | 7
console.log(`myNumber is OK.`);
} else {
console.log(`myNumber is not OK.`);
}
console.log(`2 is OK? ${isInArray(2, okNumbers)}`);
Limitations
If the types of the array and the element are both literal, the TypeScript compiler will complain:
const okNumbers = [1, 3, 5, 7] as const;
console.log(`2 is OK? ${isInArray(2 as const, okNumbers)}`);
// Argument of type 'readonly [1, 3, 5, 7]' is not assignable to parameter of type 'readonly 2[]'.
// Type '1 | 3 | 5 | 7' is not assignable to type '2'.
// Type '1' is not assignable to type '2'.
This case doesn’t fit well into either semantics, and the result of isInArray(2 as const, okNumbers)
is known before runtime. Therefore, we can either insert the result into the code, or
use one of the solutions above to work around.
Bonus: Set.has
A similar issue and solution applies to Set.has
. With the following code snippet:
const okNumbers = new Set([1, 3, 5, 7] as const);
const myNumber = Math.random();
const myNumberOk = okNumbers.has(myNumber);
console.log(`myNumber is OK? ${myNumberOk}`);
The TypeScript compiler complains:
Argument of type ’number’ is not assignable to parameter of type ‘1 | 3 | 5 | 7’.
Similar to the case of Array.include
, we can define a function isInSet
for these scenarios:
function isInSet<T, Element extends T>(element: T, s: Readonly<Set<Element>>): element is Element {
const setT: Readonly<Set<T>> = s;
return setT.has(element);
}
const okNumbers = new Set([1, 3, 5, 7] as const);
const myNumber = Math.random();
if (isInSet(myNumber, okNumbers)) {
// Here, myNumber is of type 1 | 3 | 5 | 7
console.log(`myNumber is OK.`);
} else {
console.log(`myNumber is not OK.`);
}
console.log(`2 is OK? ${isInSet(2, okNumbers)}`);