MODE では収集したセンサーデータを可視化するためのクラウドサービスとして Sensor Cloud を提供しているのですが、サービスが生まれてから数年が経過し技術的に解消したい部分が目立つようになったため、実装のフルリプレイスを進めています*1。
フロントエンドはもともと JavaScript による実装でしたが、コンパイル時に極力問題を検知できるようにすることと、以前に発表した TypeScript による Sensor Cloud ユーザー向け開発キット*2の成果を鑑み、このリプレイスでも引き続き TypeScript を採用しました。 一方で Sensor Cloud のフロントエンドは主に MODE Platform API との HTTP による通信をすることでその機能を実現しています。 本稿ではこの API コールに関する TypeScript の型という観点から、Sensor Cloud の実装について触れていきたいと思います。
MODE の Key-Value ストアと TypeScript
さてこの TypeScript の型付けにあたって少し工夫が必要となったのは、不定形なデータを扱う タイプの API コールの実装を DRY にすることです。any の利用は甘えなのでできる限りきっちり型を付けていきたいところですが、MODE Platform には任意のデータを格納できる Key-Value ストアがあります。この Key-Value ストアはSensor Cloud の一部機能における永続化データバックエンド*3として活用される他、ゲートウェイのデータ伝搬にも利用されています。
ところがここに格納される Value は前述の通り任意であるため、TypeScript において型を定義するときに少しだけ手間が発生するというわけです。
Key-Value に型を付けてみる
まずは理解のために、Key-Value ストアとの HTTP によるリクエスト・レスポンス例を見てみましょう。
;; Request GET /homes/{homeId}/kv ;; Response payload [ { “key”: “foo”, “value”: { “name”: “hoge” }, “modificationTime”: "2020-01-01T00:00:00.00Z" }, { “key”: “bar”, “value”: { “counts”: [1, 1, 2, 3, 5], “interval”: 42 }, “modificationTime”: "2020-01-02T00:00:00.00Z", } ]
このように value 属性の中身が一定ではありません。任意の値の挿入が可能なためです。ではこのレスポンスのペイロードにまずはラフに型をつけてみます。
type Kv = { key: string; value: any; modificationTime: string }[];
value の型としてはひとまず any を置いています。ここで Sensor Cloud に話を限定すると、この value に投入される値には Sensor Cloud が規定している JSON 構造のいずれかが来ることになり、完全に未知のデータがやってくるわけではありません。センサーのハードウェア情報であったり、ユーザーが作成したダッシュボードの状態などに対して、それぞれお決まりの構造が定義されているわけです。したがって、value の型は型パラメーターで表現することが可能となります。
type Kv<T> = { key: string; value: T; modificationTime: string; }[]; // 汎用性を持たせるため少しリファクタする // Array の表現はここに含めない type Kv<T> = { key: string; value: T; modificationTime: string }; // 先程の HTTP レスポンス例を当てはめてみる type Foo = Kv<{ name: string }>; type Bar = Kv<{ count: number[]; interval: number }>; // この場合レスポンスのペイロードは以下のように型付けできる type KvPayload = (Foo | Bar)[];
これにより少々ヘテロジニアスな Array をジェネリックに表すことができました。
型付けされた Key-Value にアクセスする
型が付いたところで実際にアクセスしてみましょう。
// api.getKeyValues() で前述のKey-Value が取得できると仮定する // なおこの変数 kv の型アノテーションは記事の分かりやすさのために書いている const kv: KvPayload = await api.getKeyValues(); // しかしこれはコンパイルエラーになる // なお kv[0] が undefined のケースもあるため . ではなく ?. でプロパティアクセスしている const name = kv[0]?.value?.name;
先ほどのレスポンス例では最初の要素には name 属性が含まれていたにも関わらず、いざその name 属性にアクセスしようとするとコンパイルエラーとなります。これは kv[0]
が Union 型 Foo | Bar
のどちらにもなり得るため、もし Bar だったら name 属性が存在しないじゃないかとコンパイラが訴えかけてくるわけです。TypeScript を使ったことのある方であればこのあたりはすぐにピンとくる場所でしょう。
結論から言うとここではユーザー定義の Type Guard を使います。プリミティブであれば typeof が、クラスを new によりインスタンス化 (正しくは関数のコンストラクタ呼び出し*4)したのであれば instanceof が利用できます。しかし今回はそのどちらのケースにも当てはまらないため、ユーザーが自ら型を判定する関数として Tyep Guard を記述する必要があります。今回のケースに対して、最小の実装をしてみましょう。
// 数少ない any の使用箇所、それが Type Guard const isFoo = (arg: any): arg is Foo => { return arg && (arg as Foo).key === "foo"; }; const isBar = (arg: any): arg is Bar => { return arg && (arg as Bar)?.key === "bar"; };
これにより先ほどのコンパイルエラーに対処をすることができます。
const kv: KvPayload = await api.getKeyValues(); const foo = kv[0]; if (isFoo(foo)) { // 変数 foo の 型が Foo に確定するのでコンパイルエラーにならず name にアクセスできる const name = foo.value.name; }
Type Guard と Array の高階関数
なお、覚えておくと便利なのは Type Guard を Array.prototype.filter や Array.prototype.find と併用した場合にもきちんと型が推論されるということです。
const kv: KvPayload = await api.getKeyValues(); const foos = kv.filter(isFoo); const bar = kv.find(isBar); // コンパイルエラーにならない console.log(foos[0]?.value?.name); console.log(bar?.value?.interval);
おかげで高階関数によるアプローチもしやすいですね。
Sensor Cloud の Key-Value への応用
さて、TypeScript に詳しい方であればここまで読んで Key-Value の値に対して Type Guard は必ずしも必要ないのではないか? とお考えになったかも知れません。上記の例であれば key に対して Enum であったり文字列リテラルの Union 型を使えば表現できそうですよね。
// 文字列リテラルの Union による key の表現 // Enum でも代替可能 type Key = “foo” | “bar”; type Kv<K extends Key, V> = { key: K; value: V; modificationTime: string }; type Foo = Kv<”foo”, { name: string }>; type Bar = Kv<”bar”, { count: number[]; interval: number }>; type KvPayload = (Foo | Bar)[]; const kv: KvPayload = await api.getKeyValues(); const foo = kv[0]; // Type Guard を使わなくてもよい if (foo?.key === “foo”) { // 実はこれでもコンパイルが通る console.log(foo.value.name); }
しかし Sensor Cloud においてはこのアプローチでは実現できないことがあります。まずは Sensor Cloud の Key-Value 例を(説明の都合上簡略化して)お見せすると以下のようなものです。
[ { "key": "config", "value": {"TEMPERATURE":"C"}, "modificationTime": "2019-06-14T13:38:54.542Z" }, { "key": "sensorModule0107:ab1234cd5678", "value": { "gatewayId": "81321", "name": "Living room", "sensors": ["TEMPERATURE:0"] }, "modificationTime": "2020-05-18T10:24:08.107Z" }, { "key": "sensorModule0123:F0000001", "value": { "gatewayId": "81321", "name": "Dining room", "sensors": ["COUNT:0"] }, "modificationTime":"2019-10-11T10:55:50.882Z" }, { "key": "dashboard", "value": [ { "duration":"1d", "panelType":"GRAPH", "sensorModule":"0107:ab1234cd5678", "sensorId":"TEMPERATURE:0", "w":3, "h":1, "x":0, "y":0 } ], "modificationTime": "2020-05-15T18:16:15.896Z" } ]
注目すべきは key です。config、dashboard といった固定値に挟まれて存在しているのは sensorModule.+ というフォーマットのプレフィックスのみ固定されている key です。config や dashboard のように完全一致する key であれば先ほどの文字列リテラル Union 型を用いた型の判定ができますが、このようなプレフィックスを利用したものが混ざるとそうはいきません。そこで Sensor Cloud のリプレイスプロジェクトにおいては以下のような Type Guard を書きました。
type SensorModule = Kv<{ gatewayId: string; name: string; sensors: string[] }>; type Config = Kv<{ 略 }>; type Dashboard = Kv<{ 略 }>; type KvPayload = (SensorModule | Config | Dashboard)[]; const isSensorModule = (arg: any): arg is SensorModule => { return ( arg && typeof arg.key === "string" && arg.key.startsWith("sensorModule") ); };
これならばプレフィックスのみ固定されている key であっても、Type Guard による型の判定が可能となります。
const kv = await api.getKeyValues(); const sensorModules = kv.filter(isSensorModule); console.log(sensorModules[0]?.value?.gatewayId);
API コール時におけるエラーの表現
API コールは JavaScript ランタイムの外側に作用するものですので、ランタイム内で完結する他の処理と比べてエラーハンドリングについては少々シビアに考えておいた方がよいでしょう。こうした場合、TypeScript においてはどのような手段でアプローチをすべきでしょうか。
throw、try、catch の課題点
API コールに限った話ではないのですが、TypeScript においてどのようにエラーを表現するかというのは悩ましい問題です。伝統的かつ async/await でも利用可能な手段として throw、try、catch がありますが、これらは TypeScript のコンパイラによるエラー検知にはあまり寄与してくれません。
// 通常の try catch try { const kv = await api.getKeyValues(); console.log(kv[0]?.key); } catch (e) { // カスタムエラーの場合は e のキャストが必要になる // しかし api.getKeyValues のシグネチャにはそのカスタムエラーの情報が含まれない } // try catch は必須ではないため try の外側でもコンパイルは通る const kv2 = await api.getKeyValues(); console.log(kv2[0]?.key);
コード中のコメントをご覧ください。まず 1 つめ目の課題として、MODE Platform API ではエラーレスポンスの構造が規定されているためその構造を使ったカスタムエラーを定義したくなりますが、その場合は catch 節中でエラー変数 e
をそのそのカスタムエラーにキャストする必要があります。ところがそのカスタムエラーの情報は api.getKeyValues の関数シグネイチャには含まれないため、プログラマは頭の中でカスタムエラーについて把握をしていなければなりません。
続いて 2 つ目の課題として、アクセスした Key-Value ペアが偶然消されていて 404 が返った場合などを考えてエラーハンドリングを強制したい場合も出てくる可能性がありますが throw されたものを try-catch するかどうかは任意であるため強制はできません。
以上 2 つの課題を考えると throw、try、catch は若干の表現力不足を感じてしまいます(これは当然ながら全てにおいて使うべきではないという主張ではありません。これらで十分な表現力があれば導入しても問題はないと考えます)。
Union 型による API エラーの表現
関数型に慣れている人は Either を使いたくなってくる頃かも知れませんが、TypeScript には今のところ代数的データ型もパターンマッチもなく、またライブラリや自前実装で Either を導入したとしても通常の TypeScript とかなり世界観が変わってしまうためチームでの継続的な製品開発に導入するのは躊躇してしまいます。というわけでそのどちらでもなく Union 型で API コールエラーを表現することとしました。これならば TypeScript が元々備えている仕組みの上に乗りつつ、前述の 2 つの課題をクリアすることができます。
// エラーをクラスで定義する例 class ApiError { public getReason() { … } } class Api { // インタフェース定義 public async getKeyValues(): Promise<KvPayload | ApiError> { … } } const kv = await api.getKeyValues(); if (kv instanceof ApiError) { // さらにここでエラーを表すオブジェクトが getReason を持つことは保証済み console.error(kv.getReason()); } else { // この instanceof による判定を入れないと kv.find でエラーになる console sensorModule = kv.find(isSensorModule); console.info(sensorModule?.gatewayId); }
KvPayload の型定義は (Sensor | Config | Dashboard)[]
のように Array でしたので一見 Array.prototype.find は呼び出すことができるように見えますが、変数 kv は KvPayload | ApiError
という Union 型を持つため、ApiError である可能性があるうちは kv.find はエラーとなります。ApiError は Array ではないためです。また if 節の中、kv が ApiError クラスのインスタンスであることが保証される世界では、ApiError クラスのメソッド(今回の例では getReason)をキャストなしに呼び出すことが可能となります。
Union 型によるエラーチェックをすり抜けるケース
先ほどの例でエラーハンドリングを忘れた場合には、API から取得した(レスポンスをディシリアライズした)オブジェクトのプロパティアクセスにおいてコンパイルエラーが起きました。見方を変えると、こうしたプロパティアクセスが発生しないのであれば、エラーハンドリングを忘れたとしてもコンパイルエラーが起きることはありません。具体的には POST、PATCH、DELETE といった write 系の HTTP メソッドでリクエストをするような場合ですね。
const result = await api.setKeyValues({ key: “sensorModule123”, value: { gatewayId: “456” } }); // result のプロパティにアクセスする必要がないことも多いため // エラーハンドリングなしで次の処理に進んでもコンパイラは何も検知しない console.log(“go ahead!”);
また、上記 ApiError と Array が同形のメソッド呼び出しできる場合はエラーチェックは必須となりません。以下は Array.prototype.some と ApiError のメソッドが合致している場合です。
class ApiError { // Array.prototype.some と合致している (引数の型は簡略化している) public find(f: Function): boolean { … } } const kv = await api.getKeyValues(); // この場合もエラーチェックを免れて呼び出せすことは可能 kv.some(x => x);
これはダックタイピング的な思想においては良しとされると筆者は認識していていますし良し悪しは一概には論じ難いですが、上記 2 例をもって「Union 型によるエラー表現はエラーチェックを100%強要することはできない」という点を実装者は頭の片隅に置いておくべきだと思います。
最後に
本稿では以下の 2 点において Sensor Cloud における TypeScript の型付けに触れました。
- Key-Value ストアに対する型付け
- Union 型による API コールエラーの表現
TypeScript の型システムは表現力が高いため、ひょっとするとここで紹介したものよりも良い実装があるかも知れません。今後新たな学びがあれば Sensor Cloud の実装も改善できればと思います。そして洗練できたらゆくゆくは API コールの部分を MODE SDK として切り出して公開するという野望もあったりします。それがいつになるかの見通しはまだありませんが、そうして MODE を使った開発を顧客やサードパーティが活発にできる世界に近づけていきたいと思います。
*1:MODE には同じシステムを 3 回作るという哲学があります。ビジネスにおける発見や、初回実装時の反省点に基づいて適切な設計・実装に至るため、リリース後の再実装を厭いません。
*2:こちらは本文中にもある通り Sensor Cloud のユーザー向け開発キットという位置付けであり機能が部分的で、既存の Sensor Cloud を置き換えることはできませんでした。また、本体 Sensor Cloud と開発キット類似の 2 つのコードベースが生まれたことで運用の手間が増えているのも事実です。なので今回の書き直しはこの 2 つを統合しつつ完全に置き換えることを狙っています。
*3:https://dev.tinkermode.com/platform/how-to/key-value-pairs-homes
*4:現在では関数のコンストラクタ呼び出しは class 構文の中にすっかり紛れてしまいますね。