ZYinMD
ZYinMD

Reputation: 5069

How to solve TypeScript being too restrictive on Array.includes()

I should be allowed to pass anything into Array.includes to check if it's in the array, but TypeScript doesn't want me to pass in something that isn't the right type. For instance:

type Fruit = "apple" | "orange";
type Food = Fruit | "potato";

const fruits: Fruit[] = ["apple", "orange"];

function isFruit(thing: Food) {
  return fruits.includes(thing); // ts error: "potato" is not assignable to type 'Fruit'.
}

Playground

What is a clean way to fix this code with minimal impact on readability?

Upvotes: 17

Views: 5663

Answers (4)

dev.andre.lat
dev.andre.lat

Reputation: 19

Today I came across that case.

const validNumbers: TValidNumber[] = ['one', 'two', 'three'];

You will likely face the problem that the "includes" method only accepts the "TValidNumber" type as a parameter, and you are likely passing a "string" to it. I couldn't find an answer in the 20 minutes I spent researching this, so after trying, I found that you can tell or assign the type to "validNumbers" to be both an Array of TValidNumber and an Array of "string" in the following way:

const validNumbers: TValidNumber[] & string[] = ['one', 'two', 'three'];

From hating TypeScript to loving it with great force in an instant hehehe, just as it has helped me, I hope it helps a lot of people.

Upvotes: 0

Dai
Dai

Reputation: 155155

First, please read this QA from TypeScript 3.x days where someone was asking essentially the same question as yourself: TypeScript const assertions: how to use Array.prototype.includes?

Now, in your case, as an alternative to @CertainPerformance's suggestion of unknown[] (which loses type information), you can legally widen fruits to readonly string[] (without using as), which is compatible with Fruit and Food:

type Fruit = "apple" | "orange";
type Food = Fruit | "potato" | "egg";

const fruits: readonly Fruit[] = ["apple", "orange"];

function isFruit(food: Food): food is Fruit {

  const fruitsAsStrings: readonly string[] = fruits;
  return fruitsAsStrings.includes(food);
}

An alternative, (theoretically more "correct") approach is to add a variant includes member to the ReadonlyArray<T> interface (as suggested in the linked QA) which allows U to be a supertype of T instead of the other way around.

interface ReadonlyArray<T> {
  includes<U>(x: U & ((T & U) extends never ? never : unknown)): boolean;
}

type Fruit = "apple" | "orange";
type Food = Fruit | "potato" | "egg";

const fruits: readonly Fruit[] = ["apple", "orange"];

function isFruit(food: Food): food is Fruit {

  return fruits.includes(food);
}


Having said all of that... if you intend to use a collection-type as a value/type set-membership test, you should use a JavaScript Set<T> (or use object keys) instead of an Array: not only because of performance reasons (as both Set<T>.has() and object key lookup are O(1) but Array.includes() is O(n) - and also because TypeScript works better with keyof types.

...implementing that is an exercise for the reader.

Upvotes: 14

vitoke
vitoke

Reputation: 748

The solution you wound up with (type-cast) seems fine to me.

The issue you ran into is not unique to TypeScript. If we look at Java's List.contains method we see that even though you may have a List of strings, the contains method accepts any argument type (Object). TypeScript has chosen a more restrictive approach, limiting includes to only those types that are in the array.

In my opinion, Java's choice is too liberal, and TypeScript's choice is too restrictive. But these are the easiest solutions to the problem. After all, if you choose something in between, you will have to somehow be able to show that the type you pass to includes is related to the type in the array.

I chose to take on that challenge and have sane defaults for these kinds of methods in the Rimbu immutable collections library:

import { HashSet } from "@rimbu/core";

type Fruit = "apple" | "orange";
type Food = Fruit | "potato";
type NonFruit = "car" | "house";

const fruits = HashSet.of<Fruit>("apple", "orange");

function isFruit(thing: Food) {
  // return fruits.has(thing);           // Error: "potato" not assignable to "Fruit"
  // return fruits.has(3);               // Error: 3 not assignable to "Fruit"
  // return fruits.has<NonFruit>("car"); // Error: "car" not assignable to Fruit
  return fruits.has<Food>(thing);        // Correct way
}

In the example you see that simply saying fruits.has(thing) results in a compiler error. After all, the type Food is not a subset of Fruit, so it may be a mistake. However, if you tell the method that this is intentional by supplying the Food type parameter, everything is fine.

Now, why doesn't fruit.has<NonFruit>("car") work? Well, the has method has a restriction that the collection type and the argument should be in some way "related" (to be specific, either one has to extend the other). This is not the case for types Fruit and NonFruit. So even supplying the type will still result in a compiler error. If you are really certain that you want this, you can of course always supply any as parameter.

While this approach is still not perfect, I think it covers 95% of the use cases of such methods. Therefore you will find this approach all over the place in Rimbu collections.

Here is a CodeSandbox where you can play with this example.

Upvotes: 0

ZYinMD
ZYinMD

Reputation: 5069

I ended up doing this (seems no drawback):

function isFruit(thing: Food) {
  return (fruits as Food[]).includes(thing);
}

Upvotes: -1

Related Questions