TypeScriptの型エラーを踏み潰すときのお作法

TypeScriptは、型の合わないプログラムに対して型エラーを出すことを主な役目としています。

もちろんプログラムを正しく修正すれば型エラーは消えるのですが、TypeScriptを書いている方ならばそれ以外の方法で型エラーを消したことがある人がほとんどでしょう。すなわち、`as`、`any`、`// @ts-ignore`その他諸々です。このような手段を使うことで、本来の問題を解決せずに型エラーを消すことができます。

もちろんこれらを濫用するのは勧められたことではありません。それは筆者の過去の記事『[敗北者のTypeScript](https://qiita.com/uhyo/items/aae57ba0734e36ee846a)』で解説した通りです。プログラムの修正で`as`などを使わずに型エラーが消せるのならばそうすべきで、そうしないのは敗北者です。

しかしながら、`as`などをどうしても使わなければいけない場面はあります。それは、TypeScriptの型推論能力や型の表現力が足りないために型エラーが出ている場合です。例えば、「型は正しいのにTypeScriptがそのことを理解してくれない」という場合です。このような場合は、`as`などを使って型エラーを消すのが正着です。時にはまるで未熟な子供が頑張って作った作品をゴミのように踏み潰す悪い大人のように、`as`や`any`の力で以って型エラーを否定し、踏み潰さなければならないのです。

型を消すにしても先述のように色々な方法がありますが、この記事では型エラーを踏み潰すときにどのようにすべきかを議論します。

## 3行でまとめると

- とにかく`as`を使って
- `any`とか`// @ts-ignore`, `// @ts-expect-error`は避けて
- コメントもちゃんと書いて

## 今回の題材

この記事では「止むを得ず型エラーを踏み潰す」という例を一つ出して進めることにします。

典型的なのは、ミューテーション（オブジェクトの変更）が関わる場合です。TypeScriptの型システムは「徐々にオブジェクトが作られる」ようなパターンに対応できません。

例えば、数値のプロパティを持つ2つのオブジェクトを合成する`sumNumberObjects`を次のように作ったとします（まだ型は書いていません）。

```ts
function sumNumberObjects(left, right) {
    const result = {
        ...left
    };
    for (const key in right) {
        result[key] = (result[key] || 0) + right[key];
    }
    return result;
}
```

使い方としてはこのような感じになります。要するに、両方のオブジェクトから全ての数値を取得して新しいオブジェクトに書き込み、両方にあるプロパティの場合は両者の値を足すということです。次の例だと`bar`と`baz`が足されていますね。

```ts
const data = {
  foo: 10,
  bar: 5,
  baz: 1,
};
const data2 = {
  bar: 100,
  baz: 99,
  hoge: -20,
  fuga: -50,
}

// objは {
//  "foo": 10,
//  "bar": 105,
//  "baz": 100,
//  "hoge": -20,
//  "fuga": -50
// } 
const obj = sumNumberObjects(data, data2);
console.log(obj);
```

この関数`sumNumberObjects`の引数・返り値に型を付けてみると、こんな感じになります。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result = {
        ...left
    };
    for (const key in right) {
        result[key] = (result[key] || 0) + right[key];
    }
    return result;
}

const data = {
  foo: 10,
  bar: 5,
  baz: 1,
};
const data2 = {
    bar: 100,
    hoge: -20,
    fuga: -50,
}

// objは Record<"foo" | "bar" | "baz" | "hoge" | "fuga", number> 型
const obj = sumNumberObjects(data, data2);
console.log(obj);
```

つまり、引数`left`と`right`の型をそれぞれ`S`と`T`として、それが`Record<string, number>`の部分型である（全ての（文字列を名前に持つ）プロパティは数値である）という制限を付けています。下の呼び出し例の場合、`S`は`{ foo: number; bar: number; baz: number }`となり、`T`は`{ bar: number; hoge: number; fuga: number }`です。

返り値は`Record<keyof S | keyof T, number>`であり、`keyof S | keyof T`は「`S`または`T`のキーであるような文字列の型」と読めます（実は`keyof (S & T)`と書いても同じです）。つまり、返り値は`S`および`T`の各キーに対して`number`型を持つようなオブジェクトの型となります。

これでインターフェースは大丈夫ですね。これは[TypeScriptの型演習](https://qiita.com/uhyo/items/e4f54ef3b87afdd65546)なら星3つくらいの問題です。

ところが、`sumNumberObjects`の内部実装で型エラーが出ています。どちらも、`result`の型が`S`となっていることに由来するエラーです。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result = {
        ...left
    };
    for (const key in right) {
        // エラー: Type 'number' is not assignable to type 'S[Extract<keyof T, string>]'.
        result[key] = (result[key] || 0) + right[key];
    }
    // Type 'S' is not assignable to type 'Record<keyof S | keyof T, number>'.
    return result;
}
```

この型エラーをどのように解消するかというのが今回の題材となります。

## 色々なエラーの消し方

例えば、`// @ts-ignore`を使って型エラーを消してみましょう。これは、次の行の型エラーを消すという特殊なコメントです。どんな型エラーであっても抵抗させずに踏み潰すことができます。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result = {
        ...left
    };
    for (const key in right) {
        // @ts-ignore
        result[key] = (result[key] || 0) + right[key];
    }
    // @ts-ignore
    return result;
}
```

これなら見事に型エラーを消せますね。また、TypeScript 3.9で追加された`// @ts-expect-error`を使っても同様に消せます。こちらは逆にエラーがある場所以外で使うとエラーになるという機能を持っており、もし将来的にエラーが消えた場合に無駄な`// @ts-ignore`が残らないという点でより優れています。

また、`any`を使ってもエラーを消せます。`result`を使っている場所2箇所で型エラーが出ているので、`result`の型を`any`にしてしまえばエラーが消えます。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result: any = {
        ...left
    };
    for (const key in right) {
        result[key] = (result[key] || 0) + right[key];
    }
    return result;
}
```

最後に、`as`を使う場合はこうなります。あらかじめ`result`の型を強制的に`Record<keyof S | keyof T, number>`にしておくことでエラーを消すことができます。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result = {
        ...left
    } as Record<keyof S | keyof T, number>;
    for (const key in right) {
        result[key] = (result[key] || 0) + right[key];
    }
    return result;
}
```

`{ ...left }`を`Record<keyof S | keyof T, number>`に代入することはできないので、`any`の場合と異なり、明示的に`as`を用いて`{}`の型を`Record<keyof S | keyof T, number>`に強制変更する必要があります。これは、`result`の型が最終的に`Record<keyof S | keyof T, number>`になるので、最初からその型に（強制的に）しておいたと見ることができます。

## どの方法が良いのか

筆者の考えでは、`as`を使った最後のやり方が最も優れています。それは、**`as`を使って“正解”を示すことができる**からです。`as`を使った強制的な型の変更は、**変更後の型がコード中に明示される**という点で他の方法とは大きな差があります。上の例では`result`の型が`Record<keyof S | keyof T, number>`に修正されたことがコードから明らかです。

一応`any`の場合も「`any`型になった」という情報がありますが、`any`はその中身についてなんの情報も持たないので無意味です。`// @ts-ignore`に至っては何の情報もありませんね。情報という観点では一番の愚策です。

コードを読む人は、書いた人の意図をコードから読み取ります。`as Record<keyof S | keyof T, number>`と書いてあれば「TypeScriptの型推論の理解が足りないので`as`で補って`Record<keyof S | keyof T, number>`にした」ということが読み手に伝わりますが、`any`や`// @ts-ignore`が書いてあっても「なんかエラーがあったから消したんだな」ということしか伝わりません。コードの読みやすさの点で、`as`に大きく差をつけられています。

## 考察してコメントを書こう

筆者の意見では、上の`as`を使った例もまだ情報が足りません。足りない情報を、コメントという形で補うべきです。

読み手の視点で考えると、`as`や`any`などを使った時点で「TypeScriptの推論結果を覆し、踏み潰して塗り替えた」ということが分かります。人間のやることですから、ミスがあるかもしれません。そして、誤った前提からは誤った結論が導かれますから、`as`や`any`が使われた時点でそれ以降TypeScriptの推論結果は一切信頼できないことになります。筆者の以前のトーク『[安全性の極北から見るTypeScript](https://speakerdeck.com/uhyo/an-quan-xing-falseji-bei-karajian-rutypescript)』ではこれを「嘘による汚染」と呼んでいました。`as`や`any`という嘘がそれを前提とする全てを信頼できないものとするのです。今回の場合、`sumNumberObjects`内は全て`as`に汚染されていることになります。

プログラムの読み手は`as`を見た時点で「これ以降はTypeScriptの補助は信頼できない、自分の頭だけが頼りだ」という状態に陥ります。書かれている`as`の妥当性を自分で検証する労力がかかり、関数の内容を読んで理解するコストが大きく上昇します。

この状態を解消ないし緩和してくれるのがコメントです。コメントがあると読み手が`as`の妥当性・必要性を理解する助けとなり、読み手の労力が削減されます。コメントの内容は、その`as`がいかに安全かを説明するものであるべきです。

先の例を再掲します。この`as`がなぜ安全なのか考えてみましょう。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result = {
        ...left
    } as Record<keyof S | keyof T, number>;
    for (const key in right) {
        result[key] = (result[key] || 0) + right[key];
    }
    return result;
}
```

一見すると、`{ ...left } as Record<keyof S | keyof T, number>`は明らかな嘘です。なぜなら、`Record<keyof S | keyof T, number>`は「`S`に存在するプロパティ名と`T`に存在するプロパティ名を全て持つオブジェクト」であり、`{ ...left }`は`S`に存在するプロパティは持っているものの、`T`のプロパティを持っているとは限らないからです。

ですから、コメントではこの嘘を正当化する必要があります。つまり、嘘の型がついた`result`がコード中で使われている場所全てについて、なぜそれが問題ないか考察してコメントにまとめるのです。上のコードでは、`result`を使っている場所が3箇所あります。

- `result[key] = ...` と書き込むところ
- `(result[key] || 0)`と読み込むところ
- `return result;`で`result`を返り値として返すところ

それぞれについて考察を与えると、次のようになります。

- `result[key]`への書き込みについては、`result`の型を本来より強い型（上位互換の型）に変更しているので問題ない。
- `result[key]からの読み込み`は本来`number | undefined`型が正しいところ`number`になってしまうが、`undefined`は直後の`|| 0`で`number`型になり結局`(result[key] || 0)`は常に`number`となるので問題ない。
- `result`を返り値として返すところについては、関数内のfor文で`result`が`Record<keyof S | keyof T, number>`となるために必要な値を`result`に全て書き込んでいるので、この時点では実際に`result`が`Record<keyof S | keyof T, number>`の条件を満たしており問題ない。

この考察により、この関数内で`as`を用いることが正当化されます。少なくとも、最終結果を見ると返される`result`は確かに`Record<keyof S | keyof T, number>`型の条件を満たして関数の条件を満たしており、`as`という嘘による汚染が関数の外に影響を与えることはありません。

このプログラムの読み手に以上のことを理解してもらうために、上記の考察をコメントに書いておきましょう。

ぺたり。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    // 次のasはコンパイルエラーを防ぐために必要であり、以下の理由で問題ない。
    // - `result[key]`への書き込みについては、`result`の型を本来より強い型（上位互換の型）に変更しているので問題ない。
    // - `result[key]からの読み込み`は本来`number | undefined`型が正しいところ`number`になってしまうが、`undefined`は直後の`|| 0`で`number`型になり結局`(result[key] || 0)`は常に`number`となるので問題ない。
    // - `result`を返り値として返すところについては、関数内のfor文で`result`が`Record<keyof S | keyof T, number>`となるために必要な値を`result`に全て書き込んでいるので、この時点では実際に`result`が`Record<keyof S | keyof T, number>`の条件を満たしており問題ない。
    const result = {
        ...left
    } as Record<keyof S | keyof T, number>;
    for (const key in right) {
        result[key] = (result[key] || 0) + right[key];
    }
    return result;
}
```

素晴らしいですね。これなら読み手が`as`を見て不安になることはありません。

## まとめ

例を通して示したように、型エラーを踏み潰す方法の中でも`as`は情報量の観点で優れており、積極的に使うべきです。

しかし、`as`も危険な操作であることには変わりがないため、読み手に負担を書けないために`as`の安全性をコメントにまとめるべきです。

### `as`の使い方の別解

今回示した例は、ちょっと関数内の書き方を工夫することで、関数の最後のみで`as`を使うように変更することもできます。こうするとコメントで書くべきことが少なくて楽かもしれません。

```ts
function sumNumberObjects<
  S extends Record<string, number>,
  T extends Record<string, number>
>(left: S, right: T): Record<keyof S | keyof T, number> {
    const result: Partial<Record<keyof S | keyof T, number>> = {};
    for (const key in left) {
      const current: number | undefined = result[key];
      result[key] = (current || 0) + left[key];
    }
    for (const key in right) {
      const current: number | undefined = result[key];
      result[key] = (current || 0) + right[key];
    }
    // 上のfor文によりkeyof S | keyof Tに相当するキーは全てresultに存在するので、
    // 次のasによる型の変更は正しい。
    return result as Record<keyof S | keyof T, number>;
}
```