C#
TypeScript
adventcalendar2017

C#erから見たTypeScriptの罠

日頃C#を生業としているものが、しばらくTypeScriptというものに触れてみた雑感。
タイプガードや引数プロパティ宣言など、便利だなーと思う部分も多い反面、型付けに関しては、C#のような静的型付け言語の感覚で使っていると危険だなと感じたという話です。

TypeScriptの型付けはあくまでもラベルであって、実体はJavaScriptそのものである。
ここを十分に意識しないと、さまざまなトラップに足を取られることがわかってきました。

対象としているTypeScriptのバージョンは、だいたいは2.6。一部すでに古くなった情報が混ざっているかもしれません。

thisの参照先

thisに苦労するのはjsあるあるであろうけれど、TypeScriptも例外ではない。というか型付けを間違うことがあって余計にタチが悪い。

class App {

    private appId = 12345;

    public constructor() {
        const btn = document.getElementById("buttonA");
        if (btn != null) {
            btn.addEventListener("click", this.onButtonClick);
        }
    }

    public onButtonClick() {
        alert(this.appId);
    }
}

上のコードはビルドエラーにならない。が、 alert(this.appId); のthisの参照先はbuttonエレメントなので、 this.appId は12345ではなく、undefinedとなる。

thisに対し、あなたはbuttonですよと教えてあげることはできる。

    public onButtonClick(this:HTMLButtonElement) {
        alert(this.id); //buttonA
    }

undefinedを排除しきれない

※本項に記載の内容は、version 2.7.1より、コンパイルオプション strictPropertyInitialization でチェックできるようになりました!

stringを引数に取るメソッドがあったとして

    public alertName(name: string) {
        alert(name);
    }

これを alertName(undefined) とか alertName(null) のように呼び出そうとすると、undefinedやnullをstring型の引数に割り当てることはできませんということで、ビルドエラーになる。

だからnull安全かというと、そういうわけではない。例えば以下のコード

class App {

    private name: string;

    public constructor() {
        this.alertName(this.name);
    }

    public alertName(name: string) {
        alert(name);
    }
}

これはビルドエラーにならないが、alertNameに渡される引数はundefinedとなる。
なので、公開メソッド内では引数のnullチェックが常に必要。

さらにはnull/undefinedだけでなく、型のチェックも不要とはいえない(次項)

型はいくらでも偽れる

JavaScriptである限り、any型との相互変換をするケースは避けられない。
これがなかなかやっかいで、一度anyにしてしまうと、もうTypeScriptの型チェックはまるでザルになる。

    const num = 123; //number
    const numAsAny = num as any; //any
    const str: string = numAsAny; //string

    alert(str.trim());

上のコードもビルドエラーにならず、実行時エラー(str.trim is not a function)になる。

例えばJSONで外部とやり取りするときとか、interfaceを定義しておいて受け取ったJSONから変換して使うとか、非常に便利なんだけども、本当にJSONがそのinterfaceの形をしているかどうかっていうのは、結局個々にチェックしないとわからない。

キャスト(型アサーション)も、ビルド時のチェックがあるにはあるが、完璧ではない。

interface Person {
    name: string;
    age: number;
}

var p1 = { name: "hoge" } as Person;                    //OK
var p2 = { name: "hoge", age: 10, a: "xxx" } as Person; //OK
var p3 = { name: "hoge", a: "xxx" } as Person;          //NG

上記、p3はビルドエラーにしてくれる。
p2がエラーにならないのは別に良いと思うが、p1はエラーにしてくれても良いように思う。

Interfaceとタイプガード

※本項に記載の問題は、version 2.7.1より修正されたようです。コメントでご指摘いただきました。

interface Animal と、それを実装する Dog Cat クラスを作成する。
なおこのとき、実装クラスでメンバをreadonlyにしても、ビルドエラーにはならない。

interface Animal {
    name: string;
}

class Dog implements Animal {
    readonly name: string;
}

class Cat implements Animal {
    name: string;
}

さて、何の動物かわからない pochi オブジェクト(実は猫)があったとして、これが犬かどうかを判定してみる。

const pochi: Animal = new Cat();

if (pochi instanceof Dog) {
    console.log("pochi is a Dog.");
}
else {
    //ビルドエラー
    console.log(pochi.name);
}

上記コードのelse節はビルドエラーになる。
このelse節で pochi は never型、つまり存在しえない型と判定されている。

正直、理由はよくわからない。
タイプガードの仕組みにより、if節で pochi instanceof Dog を条件とすれば、else節内では、pochiDog である可能性は排除された上で型付けが行われる。それは良い。
問題は、 pochiDog 以外の Animal である可能性まで排除されてしまっていることだ。

なお、 Dogname からreadonlyを外せばビルドが通るようになる。

正直、これに関してはバグなんじゃないかとも思うが、TypeScriptに対するバグ指摘には、「それはバグじゃなく仕様だ」というものがとにかく多いらしく、コントリビュートガイドラインにも「バグを見つけたと思っても、まずFAQを読め」とあって、ちょっと尻込みする…。


以上です。快適なTypeScriptライフを!