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>;
}