JSONオブジェクトをTypeScriptクラスにキャストする方法

2021年6月6日

経緯

Chromeの拡張機能をTypeScriptで作っていた。

その際に、chrome.tabs.sendMessageで転送したデータを受け取るときに元のclass/interfaceで受け取れなかった。

sendMessageでの転送はJSONオブジェクトなので、言い換えるとJSONオブジェクトをTypeScriptクラスにキャストする方法について調べるというのが今回の目的となります。

結論を先に言うと、class-transformerを使ってキャストさせるよという話です。

上手くいかなかったコード

最初、C#みたいなノリで以下のようなコードを書いていました。

メッセージクラス
送受信に使うためのクラス。例示のためにかなりコンパクトにしてます。

export interface IWordInfo{
    get pinyin() : string;
    get description() : string;
}

export class WordInfo{
    private _site : string
    private _pinyinEle : HTMLCollectionOf<HTMLElement>
    private _descriptionEle: HTMLCollectionOf<HTMLElement>
    
    private _pinyin : string = "";
    public get pinyin() : string {
        return this._pinyin;
    }
    private set pinyin(v : string) {
        this._pinyin = v;
    }
    
    
    private _description : string = "";
    public get description() : string {
        return this._description;
    }
    public set description(v : string) {
        this._description = v;
    }
    
    constructor(site : string, pinyinEle : HTMLCollectionOf<HTMLElement>, descriptionEle: HTMLCollectionOf<HTMLElement>) {
        this._site = site;
        this._pinyinEle = pinyinEle;
        this._descriptionEle = descriptionEle;
        this.setPinyin();
        this.setDescription();
        console.log(this._site);
    }
}

送信側
こちらもかなり省略していますが、 メッセージクラスを送信する側になります。ちなみに、chromeの拡張機能でいうところのbackground.jsに書いている内容になります。

chrome.tabs.sendMessage(fromTabId, {
        type: 'getWordInfo',
        msg:wordInfo
    });

受信側
こちらは受け取ったメッセージをWordInfoにキャストしています。

chrome.runtime.onMessage.addListener((request, sender, sendMessage) => {
  if (request.type === 'getWordInfo') {
    let wordInfo : WordInfo = request.msg as WordInfo;
・
・
・以下略

っで、JSONから定義しているクラス/インターフェースにキャストする場合、上記のやり方ではうまくいきません。

chromeのデバッガーで見てみると、以下のようなデータになっています。

request.msg as WordInfo
で変換したにも関わらず、 以下のプロパティにアクセスできないのです。
public get pinyin() : string {
return this._pinyin;
}

原因

調べていると、以下のページにいきつきました。

https://www.geeksforgeeks.org/how-to-cast-a-json-object-inside-of-typescript-class/

ざっくり訳すと、Ajaxリクエストから得られた昔ながらのJavaScriptの結果を、プロトタイプのJavaScript/TypeScriptクラスのインスタンスに単純にキャストすることはできません。これにはいくつかの方法がありますが、一般的にはデータのコピーを伴います。クラスのインスタンスを作成しない限り、クラスは何のメソッドもプロパティも持ちません。クラスのインスタンスを作成しない限り、クラスはメソッドやプロパティを持たず、単純なJavaScriptオブジェクトのままです。

つまり
request.msg as WordInfo
としたところで、単純なJavaScriptオブジェクトのままってことです。

また、sendMessageでの転送が何かはわからなかったので、そこも調べてみました。結果は以下引用で詳細書いてますが、 まとめると1回限りのJSONシリアライズ可能なメッセージでデータをやり取りしている、となります。

https://developer.chrome.com/docs/extensions/mv3/messaging/

If you only need to send a single message to another part of your extension (and optionally get a response back), you should use the simplified runtime.sendMessage or tabs.sendMessage . This lets you send a one-time JSON-serializable message from a content script to extension , or vice versa, respectively . An optional callback parameter allows you handle the response from the other side, if there is one.
訳:拡張機能の別の部分に単一のメッセージを送信するだけでよい場合(そして、オプションでレスポンスを返す必要がある場合)は、シンプルな runtime.sendMessage または tabs.sendMessage を使用してください。これにより、1回限りのJSONシリアライズ可能なメッセージを、コンテンツスクリプトからエクステンションに、またはその逆に送信することができます。オプションのコールバック パラメータを使用すると、相手側からの応答がある場合にそれを処理できます。

https://developer.chrome.com/docs/extensions/mv3/messaging/

class-transformerでキャストさせる

class-transformer以外にもやり方はあって、それについてはこちらに書いてありました。

https://www.geeksforgeeks.org/how-to-cast-a-json-object-inside-of-typescript-class/

一つ目のやり方は以下のようにライブラリを使わずにやる方法

let newTodo = Object.assign(new wordInfo(), jsonData);

コードを見た感じこれには少しデメリットもあるなと思ってます。
どの部分かというと、new wordInfo()のところ。

現状、WordInfoのコンストラクタには三つの引数があります。
なので、引数なしのコンストラクタのためにオーバーロードしないといけません。

ただキャストしたいだけなのに、キャストのためにコンストラクタをオーバーロードしないといけないのです。
しかし、TypeScriptのオーバーロードはC#のようにはいきません。

どうなっているかというと、typescriptはオーバーロードの宣言だけをして、最後に全てのケースの処理を書きます。

オーバーロードのパターンが増えたら最後に書かないといけない型チェックとか、ごちゃごちゃしてきそうな匂いがプンプンします。

というわけで、私はほかの方法を試すことにしました。

さて、やっと本題です。

今回試す方法は以下で、使うライブラリはclass-transformerです。https://www.geeksforgeeks.org/how-to-cast-a-json-object-inside-of-typescript-class/

これを使えばキャストのために、クラスを変更する必要がないです。
以下手順です。

①必要なライブラリをダウンロードします。

npm install class-transformer reflect-metadata --save

②キャストする場所、あるいはグローバルにclass-transformerをimportします。
import { plainToClass } from 'class-transformer';
※このメソッド以外にも色々あるのでgithubのREADMEを読むことをお勧めします。

③キャストする。
let wordInfo = plainToClass(WordInfo, request.msg as WordInfo);

ところで、最初以下のようにキャストしていた。
let wordInfoArray = plainToClass(WordInfo, request.msg);
これだと、wordInfoがArrayになる。

しかし、想定としては単一のクラスが戻ってくることを想定していた。
定義を見てみると、オーバーロードがあって、単一のクラスを返しているのもあった。何かしらおかしいとわかって調べてみると以下にいきついた。https://github.com/typestack/class-transformer/issues/97

つまり、Typescript(Visual Studio Codeの問題?)は適切なシグネチャーを判断できないので、Arrayで返すシグネチャーを選択しているっぽい?

【※実行時にArrayで返すシグネチャーが選択されているかを確認してみると、Chromeのデバッガではちゃんと単一のクラスを返すメソッドが呼ばれていた。ただ、この問題で一番キツイのは、VisualStudioCode上でArrayで返すシグネチャーが選択されているため、その後のインテリセンスが期待通りに動かないことでした。】

よって、明示的に上記のようにrequest.msgがWordInfoだよと教えてあげたほうがよい。