TypeScriptの型検査を無効化して型安全性を失わせる落とし穴5つとその避け方

TypeScriptにはanyを使わないよう注意しても型検査が無効化され型安全性が失われるパターンがいくつかあり、中には致命的なものも存在する。今回はこのパターンをいくつか紹介する。

コールバックのvoid戻り値型

コールバックのvoid戻り値型はすべての型を受け入れるためvoid型 === any型となる。

function f(cb: (a: number) => void): void {
    return cb(0);
}
function g(a: number): number {
    return a; 
}
f(g);

このためvoid型の値はvoid演算子を使用して即座に強制的にvoid型に変換しなければならない。

void f(g);

ここでは手遅れ。

function f(cb: (a: number) => void): void {
    return void cb(0);
}

ここで変換する。

コールバックのパラメータ数

コールバックのパラメータ数の不足は型エラーにならず検出できない。

type F = (cb: (a: number, b: string) => number) => number;
type G = (cb: (a: number) => number) => number;
var f: F = <G>((cb: (a: number) => number) => cb(0));
f((a, b) => a + b.length);

これを回避する方法はない。TypeScript 3.3までに修正済み。

共変・反変

interface A {
  x: string;
}
interface B {
  x: string;
  y: string;
}

const fb = (fa: (a: A) => void) => fa({x: 'x'});
const       fa= (b: B) => alert(b.y);
fb(fa);

まあ、これは仕方ないだろうか。

Generic型

TypeScriptの型検査は基本的に構造型の照合であり、型パラメータは照合の対象となる構造に含まれていないため、構造に反映されない型パラメータの型は無視される。

class C<T extends Foo | Bar> {
    constructor(d) {
        d.cmd();
    }
}
class Foo {
    f;
    cmd() {
        return this;
    }
}
class Bar {
    b;
    cmd() {
        return this;
    }
}
var c: C<Foo> = new C<Bar>(new Bar());

このため型パラメータのみで完結する純粋な幽霊型により型検査を行わせようとしても失敗する。

class C<T> {
}
var c: C<number> = new C<string>();

これを回避するには型パラメータで受け取った型をプロパティやメソッドで持って構造型に反映させなければならない。

class C<T> {
    c: T;
}
var c: C<number> = new C<string>();

このほか、private/protectedアクセス修飾子を使用するとクラスが公称型となり型が一意に識別される(継承クラスの型を除く)。

Generic型定義

TypeScriptがデフォルトで定義しているいくつかの型ではこの性質のため致命的な不整合を見落とす可能性がある。

var s: PromiseLike<number | string> = <PromiseLike<string>>null;
var n: PromiseLike<number> = <PromiseLike<number | string>>s;

どれだけ厳格に型を守っても、上記のように数値のみを返す型が文字列を返すようになる不正な代入が可能である。 型によっては不整合を検出するので不正に代入可能な型を即座に見分けることは難しい。

var s: ArrayLike<number | string> = <ArrayLike<string>>null;
var n: ArrayLike<number> = <ArrayLike<number | string>>s;

この不整合を防ぐにはインターフェイスを拡張して先ほどと同様型パラメータを構造に落とせばよい。

interface PromiseLike<T> {
    _?: T;
}

import/export文を含めると新規の型定義となるため含ないこと。 拡張を行っても外部からは拡張を含まない.d.tsファイルだけを参照させれば拡張を隠蔽できるので外部の型定義を汚染したり競合したりすることはない。

TypeScriptではこの種の不整合によるバグを防ぐため上記のような宣言をおまじないのように毎回追加しておく必要があるだろう。TypeScript 3.3までに修正済み。

まとめ

型があってるのに変な値が入るバグに苦しみたくなかったらここに書いたパターンと対処法を開発メンバーのTypeScript使用者全員に周知することをお勧めする。