かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

TypeScript 定数を値に持つ配列で array.includes(value) しようとしたら Type error になる件

以前 Union 型に含まれるか判定するのに 配列を array.some で回して調べる方法を描いていました

array.includes() だと Type error になるという情報を見かけたので調べてみたのメモ

環境

TypeScript v4.9.5 (TypeScript Playground)

array.includes() だと Type error になる!

const arr = ['Ichigo', 'Aoi', 'Ran'] as const;
// const arr: readonly ["Ichigo", "Aoi", "Ran"]

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
  return arr.includes(val);
  // Argument of type 'string' is not assignable to parameter of type '"Ichigo" | "Aoi" | "Ran"'.
};

const str = 'Yurika';
isSoleil(str);

arr.include(val) の箇所で Argument of type 'string' is not assignable to parameter of type '"Ichigo" | "Aoi" | "Ran"'. という型エラーが発生しました

配列の中身が定数なら同様にエラーになる

const ICHIGO = 'Ichigo' as const;
const AOI = 'Aoi' as const;
const RAN = 'Ran' as const;
const arr = [ICHIGO, AOI, RAN];
// const arr: ("Ichigo" | "Aoi" | "Ran")[]

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
  return arr.includes(val);
  // Argument of type 'string' is not assignable to parameter of type '"Ichigo" | "Aoi" | "Ran"'.
};

解消方法 1. array.some() を使う

array.inclides を使わなくてもいいなら array.some() に置き換えてしまえば良い

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
- return arr.includes(val);
+ return arr.some((v) => v === val);
};

const str = 'Yurika';
isSoleil(str); // => false

解消方法 2. array.includes() に渡す引数を any 型にキャストしてしまう

array.prototype.include は次のように定義されている

declare global {
  interface Array<T> {
    includes<U extends (T extends U ? unknown : never)>(
      searchElement: U, fromIndex?: number): boolean;
  }
}

Yes, technically it should be safe to allow the searchElement parameter in Array<T>.includes() to be a supertype of T, but the standard TypeScript library declaration assumes that it is just T. For most purposes, this is a good assumption, since you don't usually want to compare completely unrelated types as @GustavoLopes mentions. But your type isn't completely unrelated, is it?
cf. javascript - Why does the argument for Array.prototype.includes(searchElement) need the same type as array elements? - Stack Overflow

string[] のような配列に number の値が含まれるかなんて調べないよね?って想定で array.includes() の引数は配列に含まれる型 (Array<T>T) であることとされているらしい
なので as const された配列や含まれる要素が定数の場合は、配列に含まれる型 = 定数 になるので string 型などを渡そうとするとエラーになってしまうみたい

なのだけど、array.includes() に渡す引数を as any で any 型にしてしまうと配列に含まれる型として担保されないのだけどコンパイラが通る

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
- return arr.includes(val);
+ return arr.includes(val as any);
};

const str = 'Yurika';
isSoleil(str); // => false

any 型は全ての型の親みたいなものなので、下記のような流れでコンパイラが通るのではないかと思う

includes<U extends (T extends U ? unknown : never)>
↓
includes<any extends (T extends any ? unknown : never)>
↓
includes<any extends unknown>

個人的な憶測だが any の場合は配列に含まれる型である可能性もあるので 100% 異なると言い切れないので受け付ける様になっているのではと思いました

追記: 2023-06-19 コメントで引数を as any にキャストするのは良くない。配列の側を as string[] でキャストしたほうが良いご指摘頂きました。

確かに、val: string として引数を受け取っている関数内で val as any にしてしまう気持ち悪さがありましたし、今回の例は定数とはいえ arrstring[] であることは自明なので、配列側の型をコンパイラに教えてあげる方が筋が良さそうだと感じました。

const ICHIGO = 'Ichigo' as const;
const AOI = 'Aoi' as const;
const RAN = 'Ran' as const;
const arr = [ICHIGO, AOI, RAN];
// const arr: ("Ichigo" | "Aoi" | "Ran")[]

type SoleilType = typeof arr[number];
// type SoleilType = "Ichigo" | "Aoi" | "Ran"

const isSoleil = (val: string): val is SoleilType => {
- return arr.includes(val);
+ return (arr as readonly string[]).includes(val);
};
個人的に理解しきれていない点

arrstring[] なのは自明なのですが arr の定義が離れたところにあり、この isSoleil() 関数のコードの部分だけ見た時に arr as string[] とキャストしていることで返り値は val is SoleilType の筈だが万が一 arrSoleilType に含まれない文字列が入ってしまっていて、その文字列とマッチする引数が渡されれると val is SoleilType が変えるように見えてしまう可能性がるのではないか? (そう読み取れてしまう可能性を否定できないのではないか?) という疑問が湧きました

関数だけのファイルがあったとして

export const isSoleil = (val: string): val is SoleilType => {
  return (arr as readonly string[]).includes(val);
  // このコードでは arr がコピーではなく参照として使われているので、
  // arr にもし SoleilType が含まれてしまっていたら… と読み取れてしまわないか?
};

TypeScript 歴がめちゃめちゃある訳ではないので、ちょっと肌感がわからないのですが引数を最初から any にしてしまえば引数は何でも取れるが、必ず SoleilType にマッチるものしか返されないとしか読み取れないとならないかな?と感じました。

const isSoleil = (val: any): val is SoleilType => {
  return arr.includes(val);
};

引数の型が初めから any であれば、個人的にはコードの見た目上 SoleilType の配列に val が含まれるかが返されるように読み取りやすそうに感じました。
いずれにせよ arr が参照地なので必ずしも SoleilType である確証がないので良いコードではないのですが…

この辺り肌感覚がないので教えていただけると嬉しいです!

まとめ

配列に含まれている値かチェックしたい時は

1 array.some() を使う
2 array.includes() を使う場合、array が定数の配列なら引数を as any にキャストして渡す
2-1 array.include() を使う場合、array が定数の配列の型が自明であれば、配列を as some[] でキャストする
2-2 array.includes() を使う場合、array が定数の配列なら関数の引数を any として受け取ってしまう

とおぼえておけば良さそうです
個人的には array.some() で良いんじゃないかな〜という印象

おわり ₍ ᐢ. ̫ .ᐢ ₎


[参考]