オイオイオイ書くわアイツ

ほうクソブログですか……たいしたものですね

TypeScript で少し快適に JavaScript を書こう

概要

TypeScript はね、よいよ。

はじめに

JavaScript を書きたくない人間は多い。 なぜかといえばしんどいからである。 だけれども大人はお仕事をしているので、JavaScript で動く何らかを開発しないといけないことがある。 そんな世界の悲惨を受けて、JavaScript のしんどさ(以下 Shindosa of JavaScript, SJS)を軽減するために生まれたのが TypeScript である。

TypeScript は AltJS と呼ばれる、JavaScript の実行環境で動く JavaScript ではない言語の一種である。 TypeScript には次の2つの特徴がある。

  1. JavaScript のスーパーセットである。(JavaScript のコードに変更を加えず利用できる)
  2. 静的な型検査をしてくれる。

1 は JavaScript の構文がそのまま使えるということであり、学習のし易さにつながる。 2 は JavaScript のしんどさをかなり軽減してくれる。 というのも、SJS の大きな要因の1つが暗黙的な型変換のエグさにあるからだ。 例えば JavaScriptChrome で動かすと、 "1" / 20.5 になり、"1" - 10 になり、 "1" + 1"11" になる。 TypeScript はこういった暗黙的な型変換が生じる計算に対して「オイオイオイ」「死ぬわアイツ」と警告してくれる。 すき。。。

本記事ではまず TypeScript の(かなり簡単で不正確な)紹介をする。 その後、情報可視化実験で用いた簡単なチュートリアルソースコードを解説し、ソースコードが有する問題点を TypeScript のトランスパイラを用いて検査する。 読者としては、主に東京大学電子情報工学科の情報可視化実験の参加者を想定している。 本記事によって、想定読者は

  1. TypeScirpt という言語の存在
  2. JavaScript を書く際に気を付けたほうが良いこと

について多少の知見を得ることができると思う。 また、本記事では TypeScript の環境構築については一切説明をしないし、なんならモジュールのimportについても全く説明をしたくないのでその部分のコードは載せないし、型定義ファイルとかの話もしないので、使いたくなったら自分でTypeScriptについて調べて何とかしてください。

TypeSript の基礎の基礎

TypeScript は基本的に JavaScript と同じ構文で書ける。 なので、次のコードはただの JavaScript のコードであるが、 TypeScript のコードでもある。

const gosencho = "5000000000000000";
const tax = 0.08;

TypeScript では次のように変数や関数に型をつけることができる。 基本的な型についてはここを参照。 勿論、型をつけなくても良い。 その場合は、可能であればトランスパイラが型を推論してくれる。

const gosencho: string = "5000000000000000";
const tax: number = 0.08;

ここで、JavaScriptgosencho * (1 + tax)5400000000000000 と評価する。 では、 gosencho * (1 + tax) という式を含む以下のコードを TypeScript でトランスパイルしようとするとどうなるか。

const gosencho: string = "5000000000000000";
const tax: number = 0.08;

const withTax = gosencho * tax;

次のようなエラーが出る。 * の記号の左側は anynumber の型じゃないとダメよ、と言っている。

The left-hand side of an arithmetic operation must be of type 'any', 'number' or an enum type.

今回、 gosenchostring であるから、トランスパイラはエラーを出力する。 TypeSript がエラーを出してくれるので、例えば政府が「今後は消費税は一律百円にしますね!」ということにしてしまった時に、 税込み価格を gosencho + 100 とし、 "5000000000000000100" を生み出してしまうという悲劇は避けられる。

他にも自分で型を定義したり、型を用いて別の型を作ったりと、色々便利な機能があるが、本記事では紹介しない。 本記事では「JavaScriptに型が着くとどんな嬉しいことがあるのか」を軽く紹介するだけに留める。 読者層として情報可視化実験で初めて JavaScript を扱う学生を想定しているからである。

ここでは TypeScript が JavaScript と似たような構文で書けること、型宣言を付けられること、そして型検査をしてエラーを出してくれることを紹介した。 次は D3js を用いて CSV データを元に円を出力するだけのプログラムを書く。 小規模なプログラムだが、(意図的に)幾つかの問題が引き起こされてしまう。 それらの問題は TypeScript を用いれば容易に検知できることを紹介し、 TypeScript をすき。。。になってもらいたいと思う。

D3js でみる型検査のありがたさ

D3jsはJavaScript用の情報可視化ライブラリある。 データの配列とDOM要素を対応させる機能を持っており、データに応じてDOM要素の見た目を設定できる。 情報可視化実験ではD3jsの初歩的なチュートリアルとして、以下のようなCSVデータを用いて円を描画するプログラムを作成する。

group,score
A,5
B,25
C,50
D,75
E,100

円は一番上の行のデータ(A, 5)から順に、左から右に配置されるようにする。 各行の score に応じた大きさの円を描画し、円に group のラベルをつける。 出力したい画像は以下の通り。

f:id:Tak_Yaz:20171101013606p:plain

JavaScript による実装

まずソースコード全体を概観し、そこから各部分を説明する。 このソースコードは実際に講義で用いているものである。 以下のコードが JavaScript による実装になる。

d3.csv("data_basics.csv", function(error, dataset){

    if (error) {
        console.log(error);
    } else {
        showGraph(dataset);
    }

});

function showGraph(dataset)
{
    var svg = d3.select("body")
                .append("svg")
                .attr("width",1000)
                .attr("height", 1000);

    var circles = svg.selectAll("circle")
                    .data(dataset)
                    .enter()
                    .append("circle");
    circles.attr("stroke", "black")
            .attr("fill", "lightblue")
            .attr("cx", (_, i) => (i * 100) + 50 )
            .attr("cy", 100)
            .attr("r", (d) => d.score/2 );

    var label = svg.selectAll("text")
                    .data(dataset)
                    .enter()
                    .append("text");
    label.text((d) => d.group);
    label.attr("x", (d, i) => (i * 100) + 45 )
        .attr("y", 30);
}

D3jsの詳しい説明は省くが、 d3.csv(path, callback)path で指定されたCSVファイルを読み込んで、ヘッダをキーに持つオブジェクトの配列にパースして関数 callback に渡す。 今回の例だと [{group: "A", score: "5"}, {group: "B", score: "25"}, ...] というような配列を作ってくれる。

d3.csv("data_basics.csv", function(error, dataset){
    // datasetに [{group: "A", score: "5"}...] のようなオブジェクトの配列が渡されている
    if (error) {
        console.log(error);
    } else {
        showGraph(dataset); // 円を描画する関数に dataset を渡す
    }

});

d3.selectd3.selectAll はDOM要素を選択し、操作するための関数である。 どちらもDOM要素を操作するための selection と呼ばれるオブジェクトを返す。 このselection を用いて d3.select("body").append("svg") で要素を追加したり、 attr(...) で属性を追加したりできる。

function showGraph(dataset)
{
    var svg = d3.select("body")         // bodyを選択
                .append("svg")          // svgを追加
                .attr("width",1000)     // svg要素の幅と高さをセット
                .attr("height", 1000);

svg.selectAll("circle").data(dataset).enter() の部分でCSVの各行のデータに紐付いたcircle要素をsvg要素の子要素として作成している。 dataやらenterやらの関数についての詳しい説明は長くなるので省く。 とにかく [{group: "A", score: "5"}, {group: "B", score: "25"}, ...] の各データ(というかオブジェクト)に紐付いた circle 要素をまとめて追加し、それらをひとまとまりにして扱うための selection を返す。

    var circles = svg.selectAll("circle")   // circleを選択。
                                            // circleは存在しないので、empty selection というものを返す。
                    .data(dataset)          //
                    .enter()                // enterによって dataset のデータ数だけ要素を扱えるようになる。
                    .append("circle");      // dataset のデータ数だけ circle を追加する。

selectAll が返す selectionattr 関数は、選択されたDOM要素全てに適用される。 circles について言えば、追加された circle 要素全てに適用される。

    circles.attr("stroke", "black")         // stroke, fill の属性が全ての circle に適用される。
            .attr("fill", "lightblue")

selectionattr に自身と紐付いたデータを渡すことができる。 attr(name, value) の形でDOM要素の name 属性に value の値をセットする。 value に引数を2つ取る関数を渡すと、第1引数にデータの値、第2引数にデータのインデックス(CSVファイルの第何行目のデータかを表す値)を渡して、その返り値を属性の値としてセットする。 各行のデータは {group: "A", score: "5"} のようなオブジェクトとして与えられているので、 d.scored.group で各列のデータを個別に取り出して利用できる。

    circles.attr("stroke", "black")
            .attr("fill", "lightblue")
            .attr("cx", (_, i) => (i * 100) + 50 )  // i 行目のデータと紐付いた円の中心のx座標を指定する。
            .attr("cy", 100)
            .attr("r", (d) => d.score/2 );          // 円の半径を score の値に応じて指定する。
                                                    // 第2引数は省略しても良い。

ラベルについても同様に、 d.group の値をラベルとして取り出し、 データのインデックスを用いて各円の上部に配置している。

    var label = svg.selectAll("text")
                    .data(dataset)
                    .enter()
                    .append("text");
    label.text((d) => d.group )
        .attr("x", (d, i) => (i * 100) + 45 )
        .attr("y", 30);

ソースコードの問題点

前節で上げたソースコードは目的の画像を出力する。 しかし、このソースコードは果たして“良い”ソースコードだろうか。 例えば円の半径を少し大きくするとか、あるいはCSVデータを少しいじるという変更が容易に行えるだろうか。

結論から言うと、前節のソースコードには幾つか問題がある。 例えば円の半径を少し大きくしようとして (d.score + 20) / 2 と変更するとどうなるか。

f:id:Tak_Yaz:20171101013700p:plain

世界が円で覆われ、円を描画するという概念が崩壊した。 (特にエラーを出力することもないまま突然円がクソデカくなった。)

他にも例えばCSVファイルから誤ってヘッダー(カラム名が書いてある1行目の部分)を消したりすると、Chromeでは下の画像のような画面が描画された。

f:id:Tak_Yaz:20171101013733p:plain

白である。改行じゃなくて画像なので確認してみて欲しい。 上述のソースコードにはこのように、ちょっとした変更を加えるだけで不審な挙動を示すという問題点がある。 変更を気軽に加えにくいソースコードであると言っても良い。 では、どこをどう修正すればこの問題は解決するのだろうか? ソースコードを読み直すのはかったるいので文明の利器に任せることにする。

TypeScript による型検査

ここからは、実際にどんな問題があってこのような挙動をするのか、TypeScript の型検査を用いて確認してみようと思う。 ちなみに、私は開発にはVSCodeを用いているので、この節ではVSCodeの画面のスクリーンショットを多用する。 VSCodeを使わなくても TyepSCript のトランスパイラが同じようなエラーを出力してくれるが、VSCodeの画面の方がわかりやすいと思うので、スクリーンショットを用いて説明していく。 また、私はVSCodeの背景に高槻やよいさんを表示しているのでスクリーンショット内にちらちら高槻さんが映り込む。

繰り返しになるが、TypeScript は JavaScript のスーパーセットなので、JavaScriptソースコードをそのままトランスパイラにかけて型検査を行える。 そこで、とりあえず前節のソースコードを型検査にかけてみる。 すると、まずは showGraph の引数の型がよくわからないというエラーが出て来る。

f:id:Tak_Yaz:20171101013804p:plain

TypeScript の型検査はちょっと弱い(らしい)ので、実際に関数がどう使用されるかを解析して引数の型を推論するというつよい振る舞いはしてくれないっぽい。 なので、showGraph の引数として与えている変数の型を見て型注釈を加えてあげる。

f:id:Tak_Yaz:20171101013845p:plain

どうやら d3.csvDSVParsedArray<DSVRowString> なる型のデータを callback に渡すらしい。 showGraph の関数定義を以下のように変更する。

function showGraph(dataset: d3.DSVParsedArray<d3.DSVRowString>)

すると、 d.scored.group を利用している部分でエラーが出ていることがわかる。

f:id:Tak_Yaz:20171101013924p:plain

まずは d.score の部分で出ているエラーについて見ていこう。 d.score の部分では以下の2つのエラーが出ている。 画像の右下には高槻さんの顔が写っている。

f:id:Tak_Yaz:20171101014001p:plain

d.score で出ているエラーの内、[ts] The left-hand side of an arithmetic operation must be of type 'any', 'number' or an enum type.は、「TypeScriptの基礎の基礎」でも触れた string に対して数学的な演算を適用しようとしているときのエラーである。 算術演算子の左に number (あるいは any)型でない変数を置くなと言っている。

d3.csvCSVをパースしてカラム名をキーに持つオブジェクトの配列を返す。 つまり、 [{group: "A", score: "5"}, {group: "B", score: "25"}, ...] のような配列を返す。 ここで、 groupscorestring 型の値を持っているという点に注意して欲しい。 d3.csvCSVファイルの各カラムの値を全て文字列として扱う。 上手いこと数値(number)として扱ったり、日付(Date)として扱ったりはしてくれない。 したがって、 d.score / 2 は一見数値計算のように見えるが、実際には文字列を数値の2で割っていることになる。 先の例のように (d.score + 20) / 2 とすると、半径の桁数が2桁程大きくなり、世界が円で覆われる。

円がクソデカくなった原因を突き止めるには d.score が文字列であることに思い至る必要があるが、実際にはそれを忘れていて d.score + 20 をしてしまったせいでクソデカい円を描画してしまったのである。 原因を突き止めるのは少し手間だろう。

その点、TypeScript はトランスパイラが「オイオイオイ」「死ぬわアイツ」「ほう暗黙的な型変換ですか……」とヤバさを教えてくれるので、安心してプログラミングができる。 d.score については、 Number(d.score) / 2 として明示的にstring型をnumber型に変換すると TypeScript はエラーを出力しなくなる。 このようなコード、すなわち TypeScript の型検査でエラーが出ないコードでは、円をクソでかくするようなミスは起こりにくくなる。 「トランスパイラの型検査を通るようにコードを書けばエラーが起こりにくい」というのは大きな魅力だ。

[ts] Object is possibly 'undefined'. は「d.scoreundefined かもしれないよ」というエラーである。 d3.csvCSVファイルを読み込んでオブジェクトの配列を返すが、当然配列内のオブジェクトが scoregroup をキーとして持つことは保証されていない。 プログラム側はCSVファイルを読み込むまでカラム名を知ることができないからである。

CSVデータにgroupなるカラムがない場合、d.groupundefinedを返す。 undefined / 2 はどう評価されるのか。Chromeでは NaN になるようだ。 NaN を円の半径にしようとすると上手く円が描画されず、D3jsがコンソールにd3.js:1382 Error: <circle> attribute r: Expected length, "NaN". というエラーを が出力する。 このエラーメッセージを見ただけでは、ソースコードのどの部分でエラーが起きたのかわかりにくい。 先の暗黙的な型変換と同じく、原因を突き止めるのは手間だ。 その点、TypeScript はトランスパイラが「オイオイオイ」「死ぬわアイツ」「ほうundefinedになりうる変数ですか……」とヤバさを教えてくれるので、安心してプログラミングができる。

今回の例では、if (d.score === undefined) { ... } else { ... } のように、明示的に undefined に対処してあげればトランスパイラはエラーを出力しなくなる。 if文を書くのがめんどくさければ、 d.score || "10" のよう書いても良い。 d.score === undefined の場合は "10" と評価される。 トランスパイラのエラーが出ないように undefined を明示的に扱うコードを書けば undefined のエラーも起きにくくなって嬉しい。

d.group で出ているエラーも、 d.groupundefined でありえることに起因するエラーであるから、解説は省く。 トランスパイラのエラーを解消したコードは下の通りである。

d3.csv("data_basics.csv", function(error, dataset){
    
        if (error) {
            console.log(error);
        } else {
            showGraph(dataset);
        }
    
    });
    
function showGraph(dataset: d3.DSVParsedArray<d3.DSVRowString>)
{
    var svg = d3.select("body")
                .append("svg")
                .attr("width",1000)
                .attr("height", 1000);

    var circles = svg.selectAll("circle")
                    .data(dataset)
                    .enter()
                    .append("circle");
    circles.attr("stroke", "black")
            .attr("fill", "lightblue")
            .attr("cx", (_, i) => (i * 100) + 50 )
            .attr("cy", 100)
            .attr("r", (d) => Number(d.score || "10") / 2 );

    var label = svg.selectAll("text")
                    .data(dataset)
                    .enter()
                    .append("text");
    label.text((d) => d.group || "Nothing");
    label.attr("x", (_, i) => (i * 100) + 45 )
        .attr("y", 30);
}

JavaScript に無い構文は dataset: d3.DSVParsedArray<d3.DSVRowString> の型宣言だけである。 その他の変更は、JavaScriptソースコードに存在する問題点を改善するための変更であり、 JavaScript の機能を用いている。 今回の例では、TypeScript は型検査によってソースコードの問題点を洗い出すための補助をしたに過ぎない。 このように、JavaScript で書いたプログラムをより良いものにするための補助として扱えるのが、 JavaScript のスーパーセットである TypeScript の特徴であり、大きな魅力でもある。

おわりに

本記事では TypeScript の基礎を紹介し、情報可視化実験で用いるチュートリアルソースコードが有する問題を TypeScript の型検査を用いて簡単に見つけ出した。 JavaScript は雑に書こうとすれば死ぬほど雑に書けてしまうし、その結果として書いた本人も死んでしまうタイプのアレなので、実験の自由課題製作においてはちょっと気合を入れて TypeScript を使ってみるのも却って労力節約に良いんじゃないかなと思う。

本記事では全く触れていないが、 TypeScript では

type Data = {
    group: string,
    score: number
}

のようにして自前の型を定義できる。 こういうのがあるとソースコードの保守性と可読性が高まると思う。 この他にも Partial だの & だの色々面白い機能があるので、本当に是非試して欲しい。 ていうかこのクソ記事を最後まで読んじゃうような人は試したほうが良いと思う。多分向いている。

「情報可視化実験で TypeScript 使うはW」というキッズがいたらお菓子をあげるのでツイッターで連絡してください。以上。おしまい。