現行の ts-reset の [Readonly]Array.prototype.includes は引数の型をガードしてくれない

ts-reset は、歴史的経緯によって生じている TypeScript のイケてない部分を矯正してくれるライブラリです。

qiita.com

これを使えば、 JSON.parse の戻り値を any から unknown にしたりといった、地味だけど とても強力な恩恵を受けることができます。

さて、その ts-reset の機能の一つとして、 Arrayincludes の型改善があります。

以下のコードをご覧ください:

const modes = ['dev', 'stg', 'prd'] as const;

const mode: string = process.env.MODE ?? '(undefined)';
if (!modes.includes(mode)) {
  throw new Error(`unknown mode: ${mode}`);
}

// do something with mode...

一見なんの問題も無さそうなコードですが、このコードは ts-reset 未導入の状態では modes.includes(mode) の部分で型エラーになります:

Argument of type 'string' is not assignable to parameter of type '"dev" | "stg" | "prd"'.

これは TypeScript の Array<T> (または ReadonlyArray<T> )の includes が引数として T 型を要求するためです(この場合 Tstring ではなく更に具体的な型 'dev' | 'stg' | 'prd' になっているため、 string を渡すと型エラーになってしまう)。

ts-reset を導入すれば、この不便な挙動を修正することが出来ます。

更に、 ts-reset のかつてのバージョンでは、これに伴い型ガードも行ってくれていました。 すなわち:

const modes = ['dev', 'stg', 'prd'] as const;

const mode: string = process.env.MODE ?? '(undefined)';
if (!modes.includes(mode)) {
  throw new Error(`unknown mode: ${mode}`);
}

// ここで mode は 'dev' | 'stg' | 'prd' と推論される
switch (mode) {
  case 'dev':
    // do something;
    break;
  case 'stg':
    // do something;
    break;
  case 'prd':
    // do something;
    break;
  default:
    // should not get here
    mode satisfies never;
}

こんな感じで、 includestrue になった mode に対しては、それが 'dev''stg''prd' のいずれかであることを推論してくれていたのです。

しかし、現行の ts-reset (バージョン 0.4.2 以降)では、この挙動は削除されました。

その理由として、公式では以下のコードを挙げて、この挙動がおかしいことが説明されています:

type Code = 0 | 1 | 2;
type SpecificCode = 0 | 1;

const currentCode: Code = 0;

// Create an empty list of subset type
const specificCodeList: ReadonlyArray<SpecificCode> = [];

// This will be false, since 0 is not in []
if (specificCodeList.includes(currentCode)) {
  currentCode; // -> SpecificCode
} else {
  // This branch will be entered, and ts will think z is 2, when it is actually 0
  currentCode; // -> 2
}

要するに、 includes を呼び出す配列が T の各要素を全て網羅していない場合に、 ifelseelse 周りで意図しない挙動が起きるから、というものです。

この変更は極めて妥当なものであり、この変更を批判するつもりは筆者にはありません。

仮に型ガードを行いたい場合には、独立した関数に分けた方が確実ですしね:

const modes = ['dev', 'stg', 'prd'] as const;
type Mode = modes[number];

function isMode(x: string): x is Mode {
  return modes.includes(x);  // ts-reset 前提
}

が、以前の型ガードの挙動も それはそれで便利だったので、以前の挙動を再現する関数を紹介して、記事を終わらせたいと思います。

// ts-reset なしでも動く
const includes = <A extends readonly unknown[]>(arr: A, x: unknown): x is A[number] => arr.includes(x);

使い方はこちら:

const includes = <A extends readonly unknown[]>(arr: A, x: unknown): x is A[number] => arr.includes(x);

const modes = ['dev', 'stg', 'prd'] as const;

const mode: string = process.env.MODE ?? '(undefined)';
if (!includes(modes, mode)) {
  throw new Error(`unknown mode: ${mode}`);
}

// ここで mode は 'dev' | 'stg' | 'prd' と推論される
switch (mode) {
  case 'dev':
    // do something;
    break;
  case 'stg':
    // do something;
    break;
  case 'prd':
    // do something;
    break;
  default:
    // should not get here
    mode satisfies never;
}

TypeScript: TS Playground - An online editor for exploring TypeScript and JavaScript