TypeScriptは、型の合わないプログラムに対して型エラーを出すことを主な役目としています。
もちろんプログラムを正しく修正すれば型エラーは消えるのですが、TypeScriptを書いている方ならばそれ以外の方法で型エラーを消したことがある人がほとんどでしょう。すなわち、as、any、// @ts-ignoreその他諸々です。このような手段を使うことで、本来の問題を解決せずに型エラーを消すことができます。
もちろんこれらを濫用するのは勧められたことではありません。それは筆者の過去の記事『敗北者のTypeScript』で解説した通りです。プログラムの修正でasなどを使わずに型エラーが消せるのならばそうすべきで、そうしないのは敗北者です。
しかしながら、asなどをどうしても使わなければいけない場面はあります。それは、TypeScriptの型推論能力や型の表現力が足りないために型エラーが出ている場合です。例えば、「型は正しいのにTypeScriptがそのことを理解してくれない」という場合です。このような場合は、asなどを使って型エラーを消すのが正着です。時にはまるで未熟な子供が頑張って作った作品をゴミのように踏み潰す悪い大人のように、asやanyの力で以って型エラーを否定し、踏み潰さなければならないのです。
型を消すにしても先述のように色々な方法がありますが、この記事では型エラーを踏み潰すときにどのようにすべきかを議論します。
3行でまとめると
- とにかく
asを使って -
anyとか// @ts-ignore,// @ts-expect-errorは避けて - コメントもちゃんと書いて
今回の題材
この記事では「止むを得ず型エラーを踏み潰す」という例を一つ出して進めることにします。
典型的なのは、ミューテーション(オブジェクトの変更)が関わる場合です。TypeScriptの型システムは「徐々にオブジェクトが作られる」ようなパターンに対応できません。
例えば、数値のプロパティを持つ2つのオブジェクトを合成するsumNumberObjectsを次のように作ったとします(まだ型は書いていません)。
function sumNumberObjects(left, right) {
const result = {
...left
};
for (const key in right) {
result[key] = (result[key] || 0) + right[key];
}
return result;
}
使い方としてはこのような感じになります。要するに、両方のオブジェクトから全ての数値を取得して新しいオブジェクトに書き込み、両方にあるプロパティの場合は両者の値を足すということです。次の例だとbarとbazが足されていますね。
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の引数・返り値に型を付けてみると、こんな感じになります。
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の型演習なら星3つくらいの問題です。
ところが、sumNumberObjectsの内部実装で型エラーが出ています。どちらも、resultの型がSとなっていることに由来するエラーです。
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を使って型エラーを消してみましょう。これは、次の行の型エラーを消すという特殊なコメントです。どんな型エラーであっても抵抗させずに踏み潰すことができます。
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にしてしまえばエラーが消えます。
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>にしておくことでエラーを消すことができます。
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』ではこれを「嘘による汚染」と呼んでいました。asやanyという嘘がそれを前提とする全てを信頼できないものとするのです。今回の場合、sumNumberObjects内は全てasに汚染されていることになります。
プログラムの読み手はasを見た時点で「これ以降はTypeScriptの補助は信頼できない、自分の頭だけが頼りだ」という状態に陥ります。書かれているasの妥当性を自分で検証する労力がかかり、関数の内容を読んで理解するコストが大きく上昇します。
この状態を解消ないし緩和してくれるのがコメントです。コメントがあると読み手がasの妥当性・必要性を理解する助けとなり、読み手の労力が削減されます。コメントの内容は、そのasがいかに安全かを説明するものであるべきです。
先の例を再掲します。このasがなぜ安全なのか考えてみましょう。
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という嘘による汚染が関数の外に影響を与えることはありません。
このプログラムの読み手に以上のことを理解してもらうために、上記の考察をコメントに書いておきましょう。
ぺたり。
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を使うように変更することもできます。こうするとコメントで書くべきことが少なくて楽かもしれません。
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>;
}