TypeScriptでJSON APIのclientコードを書いた時の初学者がハマりやすいポイントを紹介します。
そして解決策に関しては暫定的なものなのでこれより良い解決策があったら知りたいです。
はじめに
以下の様なコードがコンパイルが通った後にランタイムエラーになる場合があります1。
// domain
class Person {
constructor(
public name: string,
public age: number
) {}
say() {
return `${this.name}(${this.age})`;
}
}
// application
const client = new APIClient({
"/person/1": personAPI
});
const person: Person = client.get<Person>("/person/1").data;
// TypeError: person.say is not a function
console.log(person.say());
Person型としてコンパイルが通るのですが、実行時に say()
メソッドを持っていないということでランタイムエラーになります。
このコードだけだとわかりづらいかもしれないのでもう少し説明を加えます。
想定されているJSON APIのレスポンス
先程のclient.get<Person>('/person/1')
の部分の呼び出しは以下のようなperson dataを返すAPIがあると思ってください。
// url = /api/person/:personId
{
"name": "foo",
"age": 20
}
そしてAPIClientクラスは型変数で渡された型のresponseデータを返すメソッド get()
を持っているとします。
Response object自体は以下のようなインターフェイスだということにします。
interface Response<T> {
data: T;
}
そして先程のget()
の呼び出しによって、Response型の値が返ってきます。これで型検査が通る理由は明白になりました。
ただし、実行時にエラーになる理由に関してはもう少し説明が必要かもしれません。
コンパイルは通るもののランタイムエラーになる理由
webのJSON APIのレスポンスは単なるJSONであることに注意してください。そしてAPIClientクラスの実装にもよりますが、それが単なるキャストであった場合について考えてみてください。dummy codeとして実装してみると以下の様な形になります。
interface APIMapping {
[path: string]: Function ;
}
class APIClient {
constructor(public mapping: APIMapping) {}
get<T>(path: string) {
const data: T = this.mapping[path]();
return new Response<T>(data);
}
}
APIMappingは実際にはserverへのrequestを伴うapi呼び出しになります。型検査の範囲では型引数として渡されたPerson型のobjectとして解釈されます。そのためコンパイルは通るのですが、実行時には、単なるplainなJSON objectに過ぎないので say()
メソッドなど持っているわけも無く実行時エラーになるということです。
問題
何が問題なのかといと以下のような2種類のオブジェクトを混同して使っていたためです2。
- メソッドを持ちえる Rich Object
- メソッドを持たない Poor Object(JSONとほぼ互換性が存在)
Poor Objectは特別な値を持っていなければJSON objectをそれと見做しても問題無いですがRich Objectに関してはそうではありません。
一方でJSON APIのrequest呼び出しに関しては元々の定義の型制約が弱かったためRich Objectを型変数として渡してもコンパイルが通ってしまったのでした。
暫定的な解決策(JSONDataという制約を追加する)
制約を追加する事を考えてみましょう。
フラットな構造のJSON Dataの場合
genericsの制約が弱かったのでコンパイルエラーになったということなので、clientの get()
メソッドが要求する型変数に対して、それがJSONであるというような制約がついていればコンパイルエラーになりそうです。
client.get<Person>("/person/1").data; // personはJSONではないのでエラー
この事自体はそう大変ではないです。JSONデータを表すようなインターフェイスを定義して制約を追加すれば良いです。
interface JSONData {
[name: string]: JSONData|number|string|boolean| JSONData[];
}
class APIClient {
constructor(public mapping: APIMapping) {}
get<T extends JSONData>(path: string) {
const data: T = this.mapping[path]();
return new Response<T>(data);
}
}
すると、コンパイルエラーになります。
// error TS2344: Type 'Person' does not satisfy the constraint 'JSONData'. Index signature is missing in type 'Person'.
client.get<Person>("/person/1").data;
ということは、面倒ですが、JSONDataを実装したデータから、自分が定義したRich Objectへの変換のコードが必要ということになります。
まぁ、コンパイルエラーで教えてくれるのであればそう手間では無いかもしれません。
interface PersonData {
name: string;
age: number;
}
type PersonJSONData = PersonData & JSONData;
// ちょっと型定義追加(es6のObject.assign用)
interface ObjectConstructor {
assign(ob: any, ob2: any): any;
}
class Person {
name: string;
age: number;
constructor(data: PersonData) {
Object.assign(this, data); // これはあまり良くないかも?
}
say() {
return `${this.name}(${this.age}): hello`;
}
}
今度は真面目にJSONDataから自分で定義したRich Objectに変換する処理を書かなくてはいけないということをコンパイラが強制するようになりました。
const data: PersonData = client.get<PersonJSONData>("/person/1").data;
const person: Person = new Person(data);
console.log(person.say()); // ok
ネストした構造のJSON Dataの場合
ネストした構造のデータに関してはもう少し考える必要があります。と言ってもコンストラクター部分あるいはファクトリーメソッドの部分を変更するだけなので大筋は変わりません。これをもう少し楽にするにはどうするべきかということについては考える必要がありますが。
以下のようなAPIのレスポンスを期待しています。
// /api/group/:groupId
{
"name": "bar",
"members": [
{"name": "foo", "age": 20},
{"name": "boo", "age": 20}
]
}
これをparseするためのコードを書いていきます。
interface GroupData {
name: string;
members: PersonData[];
}
type GroupJSONData = GroupData & JSONData;
class Group {
name: string;
members: Person[];
constructor(data: GroupData) {
this.name = data.name;
this.members = data.members.map((d: PersonData) => { return new Person(d); } );
}
say() {
const message = this.members.map((p) => {return p.say(); }).join(" ");
return `${this.name}: ${message}`;
}
}
今回も Group
をclientに渡した場合にはエラーになります。その代わり GroupJSONData
を指定して取り出し、自分でGroup objectを作る必要があります。
const data2: GroupData = client.get<GroupJSONData>("/group/1").data;
const group: Group = new Group(data2);
console.log(group.say());
Poor Objectとして扱いたい場合
そうはいってもこれらの変換が面倒くさいという場合もありますね。Poor Objectとして扱うのであればただのcastでも十分です。
このようなときにも先程のような変換を強いるというのは面倒ですね。
このインターフェイスに関しては大丈夫というようなマーカーインターフェイスを定義したいのですが、TyepScriptはそもそもnominalではなくstructuralなカタチで型情報を利用するので無理そうでした。
しょうが無いのでテキトウなpropertyを持つということにしてみます。そして、client.get()
の型制約をこれとのunion typeにします。この辺りもう少し良い方法がありそうです。
interface PoorData {
_okJSON: any;
}
// client
class APIClient {
constructor(public mapping: APIMapping) {}
get<T extends (JSONData | PoorData)>(path: string) {
const data: T = this.mapping[path]();
return new Response<T>(data);
}
}
すると直接castで利用可能なオブジェクトが使えるようになります。
class Raw implements PoorData {
public _okJSON = true;
constructor(public name: string) {}
}
const data3: Raw = client.get<Raw>("/raw/1").data;
console.log(data3.name);
// 依然としてコンパイルエラー
// const data4: Person = client.get<Person>("/person/1").data;
// console.log(data4.say());