Reputation: 5069
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'.
}
What is a clean way to fix this code with minimal impact on readability?
Upvotes: 17
Views: 5663
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
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
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
Reputation: 5069
I ended up doing this (seems no drawback):
function isFruit(thing: Food) {
return (fruits as Food[]).includes(thing);
}
Upvotes: -1