以前 [TypeScript] Array.isArrayでReadonlyArrayの型ガード という記事を書きました。
その記事では、以下のようなグローバル拡張を定義していました。
declare global {
interface ArrayConstructor {
isArray(arg: any): arg is readonly unknown[];
}
}
これを運用してきたのですが、anyやunknownのナローイングで、意図しない挙動が発生していました。
function a(o: unknown) {
if (Array.isArray(o)) {
o; // expect any[], but readonly unknown[]
}
}
function b(o: any) {
if (Array.isArray(o)) {
o; // expect any[], but readonly unknown[]
}
}
anyやunknownに適用するとreadonlyな配列になってしまいます。
標準のままであれば書き込み可能な配列として判定されているので、コンパイルエラーが発生してしまいました。
そこでもうひと工夫加えたいと思います。
TL;DR
こう書き換えます。
declare global {
interface ArrayConstructor {
isArray<T>(
o: T,
..._: unknown extends T
? never
: T extends T
? T extends readonly unknown[]
? []
: never
: never
): o is Extract<T, readonly unknown[]>;
}
}
こうすることで、string | number[]のような通常のUnion型ではReadonlyArrayを考慮した型ガードとして機能しつつ、any型やunknown型を渡したときには標準の型ガードへ綺麗にフォールバックさせることができます。
解決の経緯
問題が発覚した当初、対象となる型がunknownである箇所については、以下のシンプルな記述で手を打とうとしました。
isArray<T extends readonly unknown[]>(
o: T,
): o is Extract<T, readonly unknown[]>;
要は「このシグネチャを適用する範囲を、最初から配列の可能性がある型だけに限定すればいい」と考えたのです。
しかしこれだと、本来の目的であった「Union型(例: TemplateStringsArray | string)から配列成分だけをスマートに抽出する型ガード」が機能しなくなってしまいました。型制約(T extends ...)の段階では、対象がUnion型だと綺麗に機能してくれないようです。
そこで型制約を諦め、可変長引数の型を使ったオーバーロード制御(C++におけるSFINAE的なアプローチ)を試みてみました。
isArray<T>(
o: T,
..._: T extends T ? (T extends readonly unknown[] ? [] : never) : never
): o is Extract<T, readonly unknown[]>;
引数の型に応じて、可変長引数 ..._ の型を[](マッチ成功)かnever(マッチ失敗)に分岐させます。
こうすることで、Union型に一つでも配列型が含まれていればこちらのジェネリック版 isArray が使用され、それ以外(配列の可能性がゼロの型)ではTypeScript標準の isArray が使用されるようになります。
…そう思っていた時期が私にもありました。
そう、 any型だけは特殊な挙動をする のを忘れていたのです。
function b(o: any) {
if (Array.isArray(o)) {
o; // expect any[], but any
} else {
o; // expect any, but never
}
}
なんと any型を渡すと、ifの中では配列に型付けされないばかりか、else側でnever(到達不能)になってしまうという恐ろしいバグが発生しました。
原因は、TypeScriptの「anyに対する条件付き型の評価仕様」にあります。
anyを条件分岐にかけると、真偽の両方のルートで評価され、結果がUnion型([] | never つまり [])になってしまいます。そのため、この複雑なジェネリック定義に意図せずマッチしてしまい、戻り値の Extract<any, readonly unknown[]> が暴走して型推論が壊れてしまっていたのでした。
原因さえ分かれば対策は簡単です。このオーバーロード制御の条件に、「any を弾く防壁」を一枚追加すれば解決します。
isArray<T>(
o: T,
..._: unknown extends T
? never
: T extends T
? T extends readonly unknown[]
? []
: never
: never
): o is Extract<T, readonly unknown[]>;
TypeScriptにおいて、unknown extends T が真になるのは、T が unknown または any のときだけです。
この条件を先頭に挟むことで、any と unknown が渡されたときは可変長引数が確実に never になり、このジェネリック定義自体が「マッチ失敗」になります。
マッチに失敗すればTypeScript標準の定義(lib.es5.d.ts)へと安全にフォールバックします。これにより、any型とunknown型はもとのママany[]に着地させることができるようになりました。
まとめ
今回の修正で、型安全性を高めつつ、副作用(意図しない readonly 化や any の破壊)をほとんど無くすことができました。
ただ、可変長引数を用いたオーバーロード制御はパッと見のコードが魔術的すぎる(C++におけるSFINAE級)ので、プロジェクトに導入する際はしっかりコメントを残しておくのが良さそうです(笑)。