TypeScriptのanyはなぜ良くないのか、またその回避方法
Kazuki Moriyama (森山 和樹)
基本的にTypeScriptを書くときにはeslintによってTypeScriptのanyの使用を原則禁止にしていることが多いです。
ただTypeScriptでは型の健全性の観点でanyを使用しなければならない場面が多々あります。
anyの取り扱いについてまとめます。
anyの回避方法
- よくanyが使用されるがうまくやればanyが避けれる場合について書きます
なにが入るか不明な場合にはanyではなくunknonwを使う
- 型として何が来るか不明なときはよくあります
- 「何が入るか不明」という文言を型に翻訳すると「任意の型の値が入りうる」となります
- この問題はanyではなくTypeScriptの型システムのトップ型として定義されているunknownを使用することで解決できます
// anyを使った例、良くない
let anyVal: any = 1
anyVal = "a"
// unknownを使った例、こちらを使うべき
let unknownVal: unknown = 1
unknownVal = "1"
ライブラリや外部サービスからの値がanyで出てくるときはtype guardを使用する
- ライブラリの中にはanyを戻り値として返すような関数を定義しているものがあります
- APIコールなどで外部サービスから値を受け取るときなども何が帰ってくるかは(実質apiの仕様などで定められていたとしても)実行時には不明です
- そのためこれらのコードが存在する場所ではanyな値を自分で定義するというよりもanyな値をハンドルする必要があります
- 一番雑なハンドル方法はtype assertionです
const result = libraryCall() as string // any型の値が返るがそれをstringとみなす
- これはコンパイルは通りますが仮にstring以外の値が返るとすると実行時に良ければクラッシュ、悪ければ不正な値が生きたままアプリケーションは動き続けます
- TypeScriptにはtype guardという機能があり、これを用いれば型安全にanyをハンドルすることができます
const result: any = libraryCall()
const guard = (res: any): res is string => typeof res === "string"
if (guard(result)) {
// guardが通ればそのスコープではresultはstringと推論される
doSomethingWithString(result)
} else {
throw new Exception("result is not string")
}
ジェネリックなインターフェースを定義するときにはジェネリクスを使用する
- ジェネリックなインターフェースを定義して、それを継承して実装することがあります
interface A {
do(): any; // unknownのときもある
}
class B implements A {
do() {
return "b";
}
}
class C implements A {
do() {
return 2;
}
}
- ここでdoの戻り地の型をany(もしくはunknown)にしてしまうのは継承先で戻り値の値を変えたいからです
- しかしこのようなユースケースではジェネリクスを使うと型安全に多態性を表現できます
interface A<T> {
do(): T; // ジェネリクスを戻り値に使用する
}
class B implements A<string> {
do() {
return "b";
}
}
class C implements A<number> {
do() {
return 2;
}
}
anyを使用せざるを得ないケース・使っても良いケース
関数の引数になにが来るかわからないケース
NOTE: そんなに遭遇頻度は高くないので細部は理解しなくても大丈夫です
- strictオプションを使用しているTypeScriptの場合関数は引数に関して反変です
- 反変の概念が難しいので少し説明します
-
一言でいうと、ある関数はその引数の型が求められた型の関数の引数の型のスーパータイプである場合にサブタイプとみなせるということです
-
言葉では難しいので例を見ます
class A { a() { return "a"; } } class B extends A { b() { return "b"; } } class C extends A { c() { return "c"; } } type F = (a: B) => string; const a: F = (a: A) => a.a(); // ok const b: F = (b: B) => b.b(); // ok const c: F = (c: C) => c.c(); // not ok
-
この様にFは引数としてBを要求するような型ですが
A => string
または当然B => string
な関数をFとして扱うことができます -
しかし
C => string
な関数をFとして扱うことができません -
理由は引数の型が求められている型のサブタイプ(B :> C)に当たるからです
-
このような関係性を反変と言います
-
- 関数が引数について反変であることを前提にした場合、引数が何なのかわからない場合にちょっと困ります
- すぐ思いつくのはunknownですが、反変性を考慮すると、引数の型として使用してしまうと引数にはunknownのスーパータイプ(そんな型は存在しない)しか使用できなくなってしまいます
type F = (a: unknown) => string;
// unknown is not assignable to ~ですべてコンパイルが通らない
const a: F = (a: string) => a;
const b: F = (b: number) => b.toString();
const c: F = (c: Record<string, string>) => JSON.stringify(c);
- 次に思いつくのはボトム型のneverですが、関数自体のコンパイルは通るもののその関数を使用する際に困ったことになります
type F = (a: never) => string;
// 定義自体はできる
const a: F = (a: string) => a;
const b: F = (b: number) => b.toString();
const c: F = (c: Record<string, string>) => JSON.stringify(c);
// 使用する際にnever型の値は存在しないのでその関数を呼び出せなくなってしまう
// stringじゃなくてもどんな値を引数に与えて呼び出してもコンパイルエラー
const useF = (f: F) => console.log(f("a")); // TS2345: Argument of type 'string' is not assignable to parameter of type 'never'.
- 結局これらの制約を回避するにはanyを使用するしか有りません
type F = (a: any) => string;
// 定義ができる
const a: F = (a: string) => a;
const b: F = (b: number) => b.toString();
const c: F = (c: Record<string, string>) => JSON.stringify(c);
// その関数を使用することもできる
const useF = (f: F) => console.log(f("a"));
useF(a);
テストでいちいちanyを安全に扱うのが面倒なとき
- プロダクションコードでは安全性が大事ですが、テストではある程度コスパも重要になってきます
- 型安全に書くのが面倒なときはanyを使っても構いません
- ただしやたらめったら使用するのはテストの確からしさを低下させることにもつながるので、必ずそのanyによってテストが壊れたことに気づけない状況が生まれないかどうかを意識して使用してください
そもそもなぜanyが良くないか
- かんたんに言うと型の親子関係を破壊するからです
- いろんな言語があり、いろんな型システムが存在しますがほとんどの場合型には親子関係があります
- 以下の議論では型Sが型Tの親であることを
S :> T
と書きます - そして親子関係にはいくつかの決まりごとがあります
- SとTに親子関係が存在するとき
S :> T
とT :> S
の少なくとも1つが成り立ちます S :> T
かつT :> S
のときSとTは等しいです- すべてのSとTについて親子関係が存在するわけではない
- SとTに親子関係が存在するとき
- anyはこの決まりごとの2に反します
- なので型の健全性を損なうためよく有りません