TypeScriptでPromiseを書く

PromiseなしではJavaScriptを書けない身体になってきたのでTypeScriptでの使い方メモ。

developer.mozilla.org

Promise! resolveせずにはいられない!

昔はThenableパターンとかいうのでこういう感じで描いてたっぽい。

  • 時間のかかる処理heavyFuncがあるとする。 例えばファイルアクセスとかDBアクセスとか。
  • heavyFuncはthenを実装したthenableClass objを返す。
  • thenの引数はonResolve, onReject。
  • 実際に処理をするのはthenメソッドの中。
  • thenはチェインできない。resolve, rejectに相当するコールバック関数を引数に渡してあげる必要がある。

$.ajaxとかが有名な例。

function thenableClass(query) {
  this.query = query;
  this.data = {
    key: 'value'
  };
}

thenableClass.prototype.then = function(onResolve, onReject) {
  if (this.query) {
    var self = this;
    setTimeout( function() {
      onResolve(self.data[self.query]);
    }, 1000);
  } else {
    onReject(new Error('Invalid query'));
  }
}

function someClass() {
};
someClass.prototype.heavyFunc = function(query) {
  return new thenableClass(query);
}

var someObj = new someClass();
someObj.heavyFunc('key').then(function (result) {
  console.log('heavyFunc finished!');
  console.log(result);
}, function(err) {
  console.error(err);
});

console.log('end line');

困ること

thisを束縛する必要がある上にthenがチェインできない。しかもthenableClassを実装してその上でそのオブジェクトを返す必要がある。

イマドキのthenの書き方

昔はthenを持つなんらかのオブジェクトを返していたが、現代ではPromiseオブジェクトを返してやれば良い。 return new Promise((resolve, reject) => {...});を返すだけでthenのチェインが使えるようになる。えらい。

const Promise = require('es6-promise');

function someClass() {
  this.data = {
    key: 'value'
  };
};

someClass.prototype.heavyFunc = function(query) {
  return new Promise((resolve, reject) => {
    //promise objの中で実際の処理をする
    if(query) {
    setTimeout(() => {
      resolve(this.data[query]);
     }, 1000);
    } else {
      reject('invalid query');
    }
  });
}

var someObj = new someClass();
//注意点として、ajaxのthenとは違い引数は一つしか取らない
//onRejectコールバックは.catch(onReject)でチェインする
someObj.heavyFunc('key').then((result) => {
  console.log('heavyFunc finished!');
  console.log(result);
}).catch((err) => {
  console.error(err);
});
console.log('end line');

TypeScriptという選択肢

一人で書いてる分にはあまり問題にならないけど、他人のコードを読んだ時に「このresultって何・・・」みたいなシーンが結構あるのと、IDEのレールに乗りにくいという部分がネックになると感じることが最近多い気がします。

もともとVimをよく使っていたんですが、最近VSCodeを触り始めて「これめっちゃ良くない・・・?」という感じになっていて、特にJavaScriptデバッグ実行して処理を追っていけるというのは大変魅力的であり、正直キーバインドさえVimっぽかったらもうVSCodeでもいいかなーみたいな気持ちになりつつあります。

というわけでTypeScriptでPromiseを書く

Sample

SampleController上で、とあるODMのSampleDocumentクラスから特定のidもしくはnameを持ったSampleUser Class objを取得する。といった例を考えました。(まあNode.jsではほぼmongooseがODMのデファクトですが、、、)

プロジェクト構成は以下の通り。

./src/
├── Classes
│   ├── SampleController.ts
│   ├── SampleDocument.ts
│   └── SampleUser.ts
└── Interfaces
    ├── IDocument.ts
    └── IUser.ts

TypeScriptの利点を生かしてInterfaceから定義していくことにします。

//IDocument.ts

//T型のジェネリックインターフェイスにすることで、扱うモデルに一貫性を持たせてやる
export default interface IDocument<T>{
    findById(query: string): Promise<T>;
    findByName(query: string): Promise<T>;
    write(target: T): Promise<T>;
}
//IUser.ts
export default interface IUser {
    getId(): string;
    getName(): string;
}

TypeScriptのPromiseでは何の型を返すのか指定してやる必要があります。 最初ちょっと混乱してPromise<object>とかしてましたが普通にTで大丈夫でした。

IDocumentはなんらかのODMを想定していて、クエリによる検索と書き込みができるようになっています。

IUserはなんらかのユーザモデルで、IDや名前が取得できるgetterメソッドが定義されています。

//SampleDocument.ts
import IUser from '../Interfaces/IUser'
import IDocument from '../Interfaces/IDocument';
import SampleUser from './sampleUser';

export default class SampleUserDocument implements IDocument<IUser> {
    private _data: Array<IUser>
    private _delay: number;

    //In an actual environment, this data has to be given by some ODM class.
    constructor(delay: number) {
        this._delay = delay;
        this._data = new Array<IUser>();
        this._data.push(new SampleUser('aaa', 'test1'));
        this._data.push(new SampleUser('bbb', 'test2'));
        this._data.push(new SampleUser('ccc', 'test3'));
    }

    findById(query: string): Promise<IUser> {
        return this._readFromDBbyId(query);
    }

    findByName(query: string): Promise<IUser> {
        return this._readFromDBbyName(query);
    }

    write(user: IUser): Promise<IUser> {
        return this._writeToDB(user);
    }

    private _writeToDB(user: IUser): Promise<IUser> {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                const isUserFound = this._data.some((x: IUser) => {
                    return (x.getId() === user.getId());
                });
                if (isUserFound) {
                    reject(user);
                } else {
                    this._data.push(user);
                    resolve(user);
                }
            }, this._delay);
        });
    }

    private _readFromDBbyId(query: string): Promise<IUser> {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                const map: IUser | undefined = this._data.find((x: IUser) => { return (x.getId() === query) });
                if (map === undefined) {
                    reject('no user matched');
                } else {
                    resolve(map);
                }
            }, this._delay);
        });
    }

    private _readFromDBbyName(query: string): Promise<IUser> {
        return new Promise((resolve, reject) => {
            setTimeout(() => {
                const map: IUser | undefined = this._data.find((x: IUser) => { return (x.getName() === query) });
                if (map === undefined) {
                    reject('no user matched');
                } else {
                    resolve(map);
                }
            }, this._delay);
        });
    }
}
///SampleUser.ts
import IUser from '../Interfaces/IUser'

export default class SampleUser implements IUser{
    private _id: string
    private _name: string
    constructor(id: string, name: string) {
        if (!id || !name) {
            throw new Error("Invalid Arguments");
        }

        this._id = id;
        this._name = name;
    }

    getId(): string {
        return this._id;
    }

    getName(): string {
        return this._name;
    }
}

このように実装できました。細かい点ですがPromiseっぽさを出すためにDBアクセスにn秒かかるようにしています。(newするときに指定)

実際にController側で利用する際はこんな感じでしょうか。

//SampleController.ts
import SampleUserDocument from './SampleDocument';

const sampleDocument = new SampleUserDocument(2000);
sampleDocument.findById('aaa').then((result) => {
    //resultはIUser型なのでgetNameがインテリセンスで補完される。便利!
    console.log(result.getName());
}).catch((err) => {
    console.error(err);
});

console.log('end line');

実行すると以下のような表示になります。

$ npm i
$ tsc
$ node ./dist/Classes/SampleController.js
end line
#(2秒後に以下の行が表示される)
test1

いい感じにPromiseできてますね。

サンプルコードはこちらから。 github.com

ちなみにTypeScriptでmongooseを使った場合は若干の罠があるのでその辺も記事に書く予定。

おわりに

TypeScript(今んとこ)最高!