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 of okNumbers. 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 whether okNumbers includes some specified number, okNumbers.includes(myNumberOrString) should report an error because myNumberOrString 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) when array is of a literal type and is known before runtime. This corresponds to the semantics of determining whether ele equals one of the elements in array.

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)}`);