TypeScript Tips: Safely Using includes With ReadonlyArrays

Picture of the author
Nicolas CharpentierMay 28, 2024
5 min read
TypeScript Tips: Safely Using includes With ReadonlyArrays

I recently tried to use includes on a ReadonlyArray, which was a subset of the value's type, and I was surprised to see that TypeScript wasn't letting me do it because it was expecting the full range of the superset:

Argument of type 'SUPERSET' is not assignable to parameter of type 'SUBSET'.

It seems smart to guard against that because in some cases it doesn't make sense if they don't overlap perfectly, but in some cases, like mine, it does!

I looked online for a solution as I didn't want to just cast it as a string, but I was a little bit deceived by the solutions I found as I wanted to preserve the existing type safety and even narrow down based on the condition, so I wanted to share a new perspective on it!

Issue

First of all, let's define a test scenario where we have a list of fruits and a subset of them defined as top picks:

const FRUITS = [
//    ^? const FRUITS: readonly ["Apple", "Banana", ...
  'Apple',
  'Banana',
  'Orange',
  'Grape',
  'Watermelon',
  'Pineapple',
  'Mango',
  'Strawberry',
  'Peach',
  'Pear',
] as const;

type AllFruits = typeof FRUITS[number];

// This is a subset of `FRUITS`
const TOP_PICKS = ['Apple', 'Peach'] as const satisfies ReadonlyArray<AllFruits>;
//    ^? const TOP_PICKS: readonly ["Apple", "Peach"...

Here, we would like to check whether the selection (FRUITS) is part of the TOP_PICKS (subset of FRUITS), which could be useful if we would like to display a distinctive element for "top pick" elements, e.g., a star.

If we want to check whether the selection is part of the subset TOP_PICKS, TypeScript won't let us do it. Because it knows that our two literal arrays don't match as selection is typed based on its superset: FRUITS. TypeScript knows that not all members of the superset are present in the subset (obviously, right? It's a subset of the domain). TypeScript believes it could be a mistake, unless that's exactly what you want to do.

function scenario(selection: AllFruits) {
  if (TOP_PICKS.includes(selection)) { // Argument of type '"Apple" | "Banana" | "Orange" | "Grape" | "Watermelon" | "Pineapple" | "Mango" | "Strawberry" | "Peach" | "Pear"' is not assignable to parameter of type '"Apple" | "Peach"'.
    // Do something.
  }
}

We need to widen the type of TOP_PICKS to be able to check whether a selection is included in it, and hopefully, retain type safety!

First we can try out widening TOP_PICKS by casting it as ReadonlyArray<string> and you can see that it works! (or kind of)

function scenario(selection: AllFruits) {
  if ((TOP_PICKS as ReadonlyArray<string>).includes(selection)) {
    const selectionType = selection;
    //    ^? const selectionType: "Apple" | "Banana" | "...
  } else {
    const selectionType = selection;
    //    ^? const selectionType: "Apple" | "Banana" | "...
  }
}

But... it feels like cheating a little bit when we know that it can't be a string, but only literals. It's kind of defeating the purpose of building arrays of literals, but at least, it doesn't turn selection type into string!

We could be a little bit more precise here, rather than casting it as string, we could widen the type of TOP_PICKS to expand it to the full domain of its supset, so it does match with selection type.

function scenario(selection: AllFruits) {
  if ((TOP_PICKS as ReadonlyArray<AllFruits>).includes(selection)) {
    const selectionType = selection;
    //    ^? const selectionType: "Apple" | "Banana" | "...
  } else {
    const selectionType = selection;
    //    ^? const selectionType: "Apple" | "Banana" | "...
  }
}

It works! But still, there's something weird, or at least, not satisfactory.

What's the point in building literals if it doesn't narrow the type of selection in conditions?

Meaning that, within the if/else condition, we know that if selection was included in TOP_PICKS, then it means selection could only be a value included in TOP_PICKS, not the full range!

Solution

Let's extract the exact same logic we used previously, but this time, we'll define it in its own function as a type guard combined with a type predicate!

function isTopPick(selection: AllFruits): selection is (typeof TOP_PICKS)[number] {
    return (TOP_PICKS as ReadonlyArray<AllFruits>).includes(selection);
}

We do expect to receive a selection, which could be any fruits.

Then, we upcast TOP_PICKS as its superset (FRUITS), which we call includes with two matching types.

Finally, we define a type predicate as the function of a type guard returning whether selection is a top pick or not.

Combined to a type predicate, we can tell TypeScript that if the type guard returns true, it means that the selection is in fact a TOP_PICKS!

The beauty of this can be observed here, we were able to check if a value (superset) was included in a (subset) while preserving its type safety, but not only that, we also narrowed down the selection type!

If isTopPick returns true, the selection type is narrowed down to "Apple" | "Peach".

function scenario(selection: AllFruits) {
if (isTopPick(selection)) {
    const selectionType = selection;
    //    ^? const selectionType: "Apple" | "Peach"
  } else {
    const selectionType = selection;
    //    ^? const selectionType: "Banana" | "Orange" | ...
  }
}

📚 TypeScript Playground