Help us understand the problem. What is going on with this article?

TypeScriptのリフレクションでJSONの型変換を自動化する

More than 1 year has passed since last update.

12/21追記

npmに公開しました。

npm install @bitr/castable

Castable TypeScript Library
https://github.com/bitrinjani/castable

数値をダブルクォートでくくってJSONで送ってくるWebサービスに対処する

たまには純粋に技術的な話を。

今C#でプロトタイプを作ったビットコイン自動取引システムをNode.jsに移行しようと試みています。C#の型安全性を保ったまま移植するため、TypeScriptを採用しました。そこでぶつかったのが、外部サービスから受け取ったJSONの型の変換がうまくいかないという問題です。

例えば、以下のシンプルなJavaScriptをみてみましょう。外部サービスがserverResponseのデータをJSON文字列で返してきたとします。

const serverResponse = `{
  "name": "Milk", 
  "price": "200", 
  "tax": "10", 
}`;
const product = JSON.parse(serverResponse);
const sum = product.price + product.tax;
console.log(`sum: ${sum}`); // "200" + "10" = "20010"⛔️

JSON.parseでオブジェクトに変換し、価格が200円, 税金が10円で合計額210円、と計算されると思いきや、合計額sumにはなぜか"20010"という文字列が入ってされてしまいました。

この原因は、外部サービスが数値をダブルクォートでくくって返してきており、JSON.parseはそれを文字列型と認識してしまったことです。
ビットコイン取引所のAPIを使っていると、それぞれの取引所が適当な方法でJSON化した文字列が帰ってきます。価格や数量が文字列になっている取引所APIは私の知る限り複数あります。C#の場合は、静的型付け言語だけあってライブラリがほぼ自動変換してくれるのですが、JavaScriptではそう簡単にはいきません。

この問題を解決するには、明示的に型変換を行うコードを書きます。

const serverResponse = `{
  "name": "Milk", 
  "price": "200", 
  "tax": "10", 
}`;
const product = JSON.parse(serverResponse);
const sum = Number(product.price) + Number(product.tax);
console.log(`sum: ${sum}`); // 200 + 10 = 210👍

プロパティ2つ程度なら上記のように明示的にキャストすればよいのですが、これが数十プロパティを持つ数十クラスとなると、何百行にもなり不具合の温床となります。

TypeScriptで型指定すると。。。

これをTypeScriptで型に当てはめれば自動変換してくれるはず、と期待して以下を実行してみると、number型に指定しているのにもかかわらず文字列型として連結されてしまいます。

class Product {
  name: string;
  price: number;
  tax: number;
}

const serverResponse = `{"name": "Milk", "price": "200", "tax": "10"}`;
const product: Product = JSON.parse(serverResponse) as Product;
const sum = product.price + product.tax;
console.log(`sum: ${sum}`); // "200" + "10" = "20010"⛔️

この原因は、TypeScriptの"as Product"は型キャスト(型変換)ではなく、型アサーションにすぎないためです。ざっくりと違いをまとめると以下になります。

  • 型キャスト: 実行時に型変換を強制し、実行時エラーを抑制する。
  • 型アサーション: コンパイラに型を指示して、コンパイルエラーを抑制する。

TypeScriptのJavaScriptへコンパイルすると、TypeScriptで指定していた型情報はなくなります。実行時にはその型情報を使う方法はないのか、、と諦めかけましたが、いろいろと試した結果、ESの新機能のデコレータと仕様策定中のリフレクションで解決できることがわかりました。

Castableクラスの使用方法

実装方法は後で説明するとして、実際の使い方を先に示します。

import { cast, Castable } from '@bitr/castable';

class Product extends Castable { 
  @cast name: string;
  @cast price: number;
  @cast tax: number;
}

const serverResponse = `{"name": "Milk", "price": "200", "tax": "10"}`;
const product = new Product(JSON.parse(serverResponse));
const sum = product.price + product.tax;
console.log(`sum: ${sum}`); // 200 + 10 = 210👍

使い方は以下です。
1. 対象のクラスにCastableを継承させる
2. 各プロパティに@castデコレーターをつける
3. JSON.parseの結果をコンストラクタでくくる

これだけで、TypeScriptで指定した型に自動的に変換されます。

Castableの実装

では、Castableの実装をみてみましょう。そのコードはたった50行です。

import 'reflect-metadata';

export function cast(...args: any[]): any {
  if (args[0] instanceof Function) {
    return (target: any, propKey: string) => {
      Reflect.defineMetadata('custom:type', args[0], target, propKey);
    };
  }
}

export function element(...types: Function[]) {
  return (target: any, propertyKey: string) => {
    let metadataKey = 'custom:element-type0';
    for (const type of types) {
      Reflect.defineMetadata(metadataKey, type, target, propertyKey);
      metadataKey = metadataKey.replace(/(\d+)$/, (_, p1) => `${Number(p1) + 1}`);
    }
  };
}

export class Castable {
  constructor(source: any) {
    Object.getOwnPropertyNames(source).forEach(propertyKey => {
      const designType = Reflect.getMetadata('design:type', this, propertyKey);
      const customType = Reflect.getMetadata('custom:type', this, propertyKey);
      const type = customType !== undefined ? customType : designType;
      this[propertyKey] = this.convert(source[propertyKey], propertyKey, type, 0);
    });
  }

  convert(source: any, propertyKey: string, type: any, depth: number) {
    if (type === undefined) {
      return source;
    }
    switch (type.name) {
      case 'Number':
        return Number(source);
      case 'String':
        return String(source);
      case 'Boolean':
        return source.toString() === 'true';
      case 'Array':
        const elementType = Reflect.getMetadata('custom:element-type' + depth, this, propertyKey) as Function;
        const nextDepth = depth + 1;
        return (source as any[]).map(el => this.convert(el, propertyKey, elementType, nextDepth));
      default:
        return new type(source);
    }
  }
}

はじめに、リフレクションのpolyfillであるreflect-metadataをインポートします。このモジュールは、MicrosoftのTypeScriptチームによって作られており、将来的に標準規格に含まれる可能性があります。

次に、デコレータ@cast, @element()を定義します。
基本的に以下の型に対しては、@castを頭につけるだけでOKです。

  • プリミティブ型 (string, number, boolean)
  • TypeScript内で明示的にclass定義されている型

例外的にDate型に対しては、@cast(Date)と明示的に指定する必要があります。これはTypeScriptのリフレクションの現時点の制限によるものです。また、配列型に対しても制限があり、要素型を@element()で指定する必要があります。これらは次の節で例示します。

次に抽象クラスCastableを定義します。
コンストラクタ内で、派生クラスのプロパティを列挙し、そのプロパティのコンパイル時型情報を実行時にリフレクションで取得します。
convertメソッドにその型情報を渡し、それぞれの型に対してカスタムの型変換を実装しています。ネストされた型や配列型に対しても、再帰的に変換をしていきます。
型変換が完了した後、コンストラクタは生成されたオブジェクトを返します。

Castable使用例

ネストされた型

ネストされた型がある場合、含まれるすべての型に対しCastableを継承し、プロパティにデコレータをつけます。

import { cast, element, Castable } from '@bitr/castable';

class Detail extends Castable {
  @cast shipping: number;
  @cast address: string;
}

class Product extends Castable {
  @cast name: string;
  @cast price: number;
  @cast tax: number;
  @cast detail: Detail
}

配列型の要素の型を指定するデコレータ

現時点では、reflect-metadataは配列型の型情報はArrayとだけしか返さず、その要素の型は返してくれません。(Github issue)
この制限に対応するため、配列型に対しては@castだけでなく@element()デコレータを指定する必要があります。

import { cast, element, Castable } from '@bitr/castable';

class Product extends Castable {
  @cast name: string;
  @cast price: number;
  @cast tax: number;
}

class OrderList extends Castable {
  @cast @element(Product) orders: Product[]; // <--- 配列の要素型を指定
  @cast(Date) dueDate: Date;                 // <--- Date型に対しては@cast(Date)とする
}

const serverResponse = `{
  "orders": [
    {"name": "Milk", "price": "200", "tax": "10"},
    {"name": "Water", "price": "50", "tax": "5"}
  ],
  "dueDate": "2017-10-25T06:28:08Z"
}`;
const products = new OrderList(JSON.parse(serverResponse));

トップレベルが配列型のケース

トップレベルが配列型で、その中にオブジェクトを含むケースは、mapで変換可能です。

import { cast, Castable } from '@bitr/castable';

class Product extends Castable {
  @cast name: string;
  @cast price: number;
  @cast tax: number;
  @cast(Date) date: Date;  
}

const serverResponse = `[
  {"name": "Milk", "price": "200", "tax": "10", "date": "2017-10-20T06:28:08Z"},
  {"name": "Water", "price": "50", "tax": "5", "date": "2017-10-20T06:28:08Z"}
]`;
const products = JSON.parse(serverResponse).map(x => new Product(x));

多次元配列のケース

@element()は、多次元配列対応のため可変長引数を受け取ります。二次元配列の場合は、@element(Array, <要素型>)と指定します。

class Pair extends Castable {
  @cast name: string;
  @cast n: number;
}
class C extends Castable {
  @cast @element(Array, Pair) arr: Pair[][];
}
const s = `{ 
  "arr": [
    [ { "name": "abc", "n": "123" }, { "name": "def", "n": "999" } ],
    [ { "name": "abc2", "n": "200" }, { "name": "def2", "n": "300" } ] 
  ]
}`;
const c = new C(JSON.parse(s));

終わりに

リフレクション自体が実験的機能であることもあり、Castableも実験的な実装です。うまく動作しないケースも多々あるかと思います。もしより良い実装方法や、すでに存在するライブラリ等ご存知だったら教えていただけるとうれしいです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした