JavaScript
TypeScript

class-transformerでpureなjsオブジェクトとクラスとの変換が便利だった

この記事はWanoグループ Advent Calendar 2017の2日目の記事になります。
なんか忘れてた気がするけど断じて2日目なのです。

クライアントで扱うエンティティの「型」

jsでAPIからエンティティをやり取りするとき、json.stringifyやjson.parseを使っていると、クライアント用に変換されるデータは単純なobjectとなります。
特にアーキテクチャとしてRedux等を使っている方は、クライアント側が保持するデータをclass等でなく純粋なjsonと等価になるobjectで保持していることが多いかと思います。
その形式で保持することは、特にサーバーサイドレンダリングのやりやすさなどを考えると妥当な手段ですが、エンティティをクラスとして扱ってメソッドを生やしたい、という時には取り回しが悪いです。
特に僕はアプリのステートをmobx を使っており、基本的にはステートをclassで扱っているので、 SSRしたステートのクライアントサイドでの復元にも困っていました。

ここではclassとobjectを雑に相互変換する、typestack/class-transformerを紹介したいと思います。

基本

基本的な使い方は簡単で、まず、エントリーポイントとなるjsなどに

import "reflect-metadata";

を読み込みます。

あとは使用するだけです。

{
  "id" : 200,
  "name" : "hoge",
  "point" : 0
}

というjsonオブジェクトがあったとして、以下のようなclassに変換したいときは、

class SampleEntity {
    id? : number;
    name : string;
    point : number;

    public increment(){
        this.point++;
    }
}

次のようににリフレクションかけるだけです。

import {plainToClass} from "class-transformer";

const sampleJson = {
  "id" : 200,
  "name" : "hoge",
  "point" : 0
};

class SampleEntity {
    id? : number;
    name : string;
    point : number;

    public increment(){
        this.point++;
    }
}

const sampleEntity = plainToClass(SampleEntity, sampleJson);
sampleEntity.increment(); // point = 1

問題なくSampleEntityのメソッドを使用することができます。

ネストしたクラス構造

class Parent {
    id? :number;
    name : string;

    @Type(() => Child)
    nestedChild : Child

    incrementChildPoint() : void{
        this.nestedChild.increment()
    }
}

class Child {
    name : string;
    point : number;
    increment(){
        this.point++
    }
}

const sampleJson2 = {
    "id" : 1,
    "name" : "parent",
    "nestedChild" : {
        "name" : "child",
        "point" : 300
    }
}

const parentEntity = plainToClass(Parent , sampleJson2);
parentEntity.incrementChildPoint() // 301

Typesデコレータによって、変換時にnestedChildプロパティはChildクラスとして変換されます。
このように、同梱されているデコレータによって、様々なclassへの変換や別名を与えることができます。
この辺はGoのタグでやっていることとほぼ同じような感じですね。

文字列とDateを相互変換する

const fetchedArticleJson = {
    "id" : 2,
    "text" : "hogehoge",
    "release_time" : "2017-10-15T10:02:15+09:00",
    "create_time" : "2017-10-05T10:02:15+09:00"
}

class Article {
    id? :number;
    text: string;
    release_time : Date;

    @Type(() => Date)
    create_time : Date;
}

const fetchedArticle : Article = plainToClass(Article , fetchedArticleJson);
console.log(fetchedArticle.create_time.getTime()) //1507165335000
console.log(fetchedArticle.release_time.getTime()) //fetchedArticle.release_time.getTime is not a function',

サーバから受け取るjsonでは、時間を扱うプロパティも単なる文字列ですが、変換時にDateになるように指定できます。
デコレータをつけていない create_time の方は、単なる文字列のままとしての扱いなため、getTime() が生えません。

アプリ用エンティティをサーバにjsonとして送るときは、

const serializedArticle = classToPlain(fetchedArticle);

として戻してやればOKです。

Transformで自由にフックする

プロパティを、jsで時間を扱う時に有用な moment のオブジェクトとして扱いたい、という時も、class変換時、プレーンなjs変換時で自由にフック処理が書けます。

export class Photo {

    id: number;

    @Type(() => Date)
    @Transform(value => moment(value), { toClassOnly: true })
    date: Moment;

    getReadableDate() : string {
        return this.date.format("YYYY[年]MM[月]DD[日]  HH[時]mm[分]ss[秒]")
    }
}

const photoJson = {
    "id" : 300,
    "date" : "Thu, 06 Sep 2012 10:00:00 +0900"
}


const photo : Photo = plainToClass(Photo , photoJson);
photo.getReadableDate()) //2012年09月06日  10時00分00秒

この時、Transformフックはそのままでは サーバ送信用に classToPlain(photo) を書けた時も実行されてしまうため、{ toClassOnly: true } オプションを有効にしておきます。

string <=> enum変換

ちょっと例が悪いのですが、数値をgetしてきたときはstringとして扱い、サーバーにput処理かけるときはまた数値に戻す...といった手順は例えば以下のようになります。

type colorType = "red" | "blue" | "green"
enum COLOR {
    RED = 1,
    BLUE,
    GREEN
}

const colorToString = (i : COLOR)=>{

    const mapping = {
        [COLOR.RED] : "red",
        [COLOR.BLUE] : "blue",
        [COLOR.GREEN] : "green",
    }

    return mapping[i]
}

const colorTypeToENUM = (str : string)=>{

    const mapping = {
        "red" :  COLOR.RED,
        "blue" :   COLOR.BLUE ,
        "green" :  COLOR.GREEN ,
    }

    return mapping[str]
}

class ColorInfo {
    // put時、get時でそれぞれ変換
    @Transform(value => colorToString(value) , { toClassOnly: true })
    @Transform(value => colorTypeToENUM(value) , { toPlainOnly: true })
    base_color : colorType

}

同じプロパティへのデコレータ増えて見通し悪いときは、1つの高階関数にまとめてもいいかもしれません。

まとめ

他にもいろんなオペレータあるのですが、とりあえずDate変換だけでも便利ですので、是非使ってみてほしいと思います。