0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReadonlyArray.includesで型ガード

Posted at

TypeScriptで以下のようなコードを書いてイラッときたことはないですか?私はあります。

const KEYS = ['aaa', 'bbb', 'ccc'] as const;
function f1(key: string) {
  if (KEYS.includes(key)) {
    //              ~~~
    // Argument of type 'string' is not assignable to parameter of type '"aaa" | "bbb" | "ccc"'.
  }
}

指定された文字列がKEYSの中にあるかどうかを確認したいのにincludesに指定できるのは"aaa" | "bbb" | "ccc"、つまりKEYSに含まれる文字列だけ?何という理不尽...

そこでどうにかならないか考えてみました。

TL;DR

以下の内容をtypes.d.tsなどの名前でプロジェクトに置くだけで上記の問題が解決します!

declare global {
  interface ReadonlyArray<T> {
    includes<U>(
      s: U,
      ...args: IsLiteralArray<this> extends true
        ? T extends U
          ? [fromIndex?: number]
          : never
        : never
    ): s is IsLiteralArray<this> extends true
      ? T extends U
        ? T
        : never
      : never;
    includes<U>(
      s: U,
      ...args: T extends U ? [fromIndex?: number] : never
    ): boolean;
  }

  interface Array<T> {
    includes<U>(
      s: U,
      ...args: T extends U ? [fromIndex?: number] : never
    ): T extends U ? boolean : false;
  }
}

type IsUnion<T> = (T extends T ? (p: T) => 0 : never) extends (p: T) => 0
  ? false
  : true;
type IsLiteral<T> = IsUnion<T> extends true
  ? false
  : T extends null | undefined | boolean
  ? true
  : T extends PropertyKey
  ? {} extends Record<T, unknown>
    ? false
    : true
  : T extends bigint
  ? {} extends Record<`${T}`, unknown>
    ? false
    : true
  : false;
type IsLiteralArray<T extends readonly unknown[]> = IsUnion<T> extends true
  ? false
  : IsLiteral<T['length']> extends true
  ? T extends readonly [infer F, ...infer R]
    ? IsLiteral<F> extends true
      ? IsLiteralArray<R>
      : false
    : true
  : false;

export {};

解決までの道のり

解決案1: unknown型にする

まずはReadonlyArrayでのincludesの定義を見てみます。

interface ReadonlyArray<T> {
    /**
     * Determines whether an array includes a certain element, returning true or false as appropriate.
     * @param searchElement The element to search for.
     * @param fromIndex The position in this array at which to begin searching for searchElement.
     */
    includes(searchElement: T, fromIndex?: number): boolean;
}

これを

const KEYS = ['aaa', 'bbb', 'ccc'] as const;

に適用するとKEYSはReadonlyArrayとしてみるとReadonlyArray<"aaa" | "bbb" | "ccc">になるのでincludesはincludes(searchElement: "aaa" | "bbb" | "ccc", fromIndex?: number): boolean;となるので、"aaa" | "bbb" | "ccc"でないと第1引数に指定できないのです。

ならばこれを何でも受け付けるunknownにしてしまえばいい、ということでこんなふうにしてみました。

declare global {
  interface ReadonlyArray<T> {
    includes(searchElement: unknown, fromIndex?: number): searchElement is T;
  }
}

export {};

こうすると

const KEYS = ['aaa', 'bbb', 'ccc'] as const;
function f1(key: string) {
  if (KEYS.includes(key)) {
    key; // 'aaa' | 'bbb' | 'ccc'
  }
}

とこのコードだけ見ると思ったとおりになってくれます。

解決案1の問題点

この解決方法では2つ問題があります。

  1. 型チェックが効かなくなる
  2. 型ガードが誤判定を起こす

型チェックが効かなくなる

たとえば先程の例のKEYSに対して数値型の変数を間違って渡してしまったとしましょう。

function f2(key: number) {
  if (KEYS.includes(key)) {
    key; // 'aaa' | 'bbb' | 'ccc'
  }
}

KEYSは文字列型の配列なのに、数値型を渡してしまってもエラーにならなくなってしまいました。

型ガードが誤判定を起こす

型ガード関数としてtrueを返すときは問題ないのですが、falseを返したときの型が…

function f3(keys: ReadonlyArray<'aaa' | 'bbb' | 'ccc'>, key: 'aaa' | 'ddd') {
    if (keys.includes(key)) {
        key; // "aaa" <- OK
    } else {
        key; // "ddd" <- NG
        // KEYSに’aaa’が含まれていない場合はkeyが’aaa’である可能性がある
    }
}

ReadonlyArrayはその型パラメーターであるすべての値を持っているとは限りません。includesがfalseを返すからと言ってT型ではないと判定されてしまっては困ることがあるのです。

解決案2: Genericsを使う

型チェックが効かなくなるのはunknownにしてしまうからです。includesの第1引数がeadonlyArrayの方パラメーターであるTのスーパータイプであれば誤って指定したわけではない、と考えても良いでしょうからそのような制約をつけてみたいと思います。

スーパータイプの制約

Genericsを使えば、型パラメーターが特定の型のサブタイプであること、という制約をつけることができますが、今回行いたいのは型パラメーターがTのスーパータイプであることという制約なので型制約をつけたいところですが、そのような方制約は何度かIssueに上がってはいるものの適用には至っていないようです。

ただ関数の引数でのみ使用できるスーパータイプで制約する方法を見つけました。

interface ReadonlyArray<T> {
  includes<U>(
    s: U,
    ...args: T extends U ? [fromIndex?: number] : never
  ): s is T extends U ? T : never;
}

残りの引数をConditionTypeでneverとしてしまうことで、制約にマッチしない場合はコンパイルエラーになるようにできました。

const KEYS = ['aaa', 'bbb', 'ccc'] as const;

function f1(key: string) {
    if (KEYS.includes(key)) {
        key; // key: "aaa" | "bbb" | "ccc"
    } else {
        key; // key: string
    }
}

function f2(key: number) {
    if (KEYS.includes(key)) { // ここでエラー
        key; // key: never
    }
}

Playground

解決案3: as constをつけた配列だけを対象とする

型ガードが誤判定を起こす、という点については現在の型ガードの仕様上ReadonlyArray型の全てにこのオーバーロードを適用してしまっては、解決の方法がありません。なぜならReadonlyArrayは空も許容される配列型であり、空であればincludesが常にfalseを返すことになり、never型にしかならないことになります。

const LIST: ReadonlyArray<unknown> = [];
function f3(key: unknown) {
    if (LIST.includes(key)) {
        key; // key: unknown だけどこちらにはこない
    }else{
        key; // key: never こちらには来るけどnever型になって使えない
    }
}

であれば割り切って「リテラルだけを持つas constが付けられた配列」だけに限定してしまうことにします。

リテラルだけを持つas constが付けられた配列を判別する型関数は以下のようになります。

type IsLiteralArray<T extends readonly unknown[]> = IsUnion<T> extends true
  ? false
  : IsLiteral<T['length']> extends true
  ? T extends readonly [infer F, ...infer R]
    ? IsLiteral<F> extends true
      ? IsLiteralArray<R>
      : false
    : true
  : false;

type IsLiteral<T> = IsUnion<T> extends true
  ? false
  : T extends null | undefined | boolean
  ? true
  : T extends PropertyKey
  ? {} extends Record<T, unknown>
    ? false
    : true
  : T extends bigint
  ? {} extends Record<`${T}`, unknown>
    ? false
    : true
  : false;

type IsUnion<T> = (T extends T ? (p: T) => 0 : never) extends (p: T) => 0
  ? false
  : true;

これを先程の型制約もどきに追加してやると

interface ReadonlyArray<T> {
  includes<U>(
    s: U,
    ...args: IsLiteralArray<this> extends true
        ? T extends U ? [fromIndex?: number] : never : never
  ): s is T extends U ? T : never;
}

先程の例に適用すると

const LIST: ReadonlyArray<unknown> = [];
function f3(key: unknown) {
    if (LIST.includes(key)) { // LISTはas const付きの配列ではないのでincludesは型ガードではない
        key; // key: unknown だけどこちらにはこない
    }else{
        key; // key: unknown 型ガードではないのでunknownのまま
    }
}

Playground

誤判定がなくなりました。

最終型

型ガードが誤判定を起こす、というのは型ガードにしなければ起こらない話なので型ガードでない版のincludesも用意します。ついでにreadonlyのつかないArrayにも用意しましょう。

それからモジュールを使っているとdeclare globalで囲む必要があります。またdeclare globalを使うには明示的にモジュールを使うということを宣言する必要があるので何もエクスポートしないexport {}をつけておきます。

本家の反応

TypeScriptの本体に取り込んでもらおうとIssueを立てたんですが、「複雑過ぎて何やってるかわかんないよ(意訳)」1ということで拒否されちゃいました。トホホ

というわけで供養のために投稿します。

  1. 標準ライブラリは可能な限りシンプルにするという設計理念だそうなので納得はしています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?