TypeScriptでMongooseを使うときの落とし穴とその対策

例えば

JSでMongooseのModelはこんな感じに書ける。 とあるUserモデル。

//user.js
const mongoose = require('mongoose');

const schema = new mongoose.Schema({
    userId: {
        type: String,
        required: true,
        unique: true,
        min: 2,
        max: 255
    },
    firstName: {
        type: String,
        required: true,
        min: 2,
        max: 255
    },
    lastName: {
        type: String,
        required: true,
        min: 2,
        max: 255
    },
    email: {
        type: String,
        required: true,
        unique: true,
        min: 2,
        max: 255
    },
    password: {
        type: String,
        required: true,
        min: 2,
        max: 255
    },
    created_at: {
        type: Date,
        required: true,
    },
    updated_at: {
        type: Date,
        required: true
    }
});
const User = mongoose.model("User", schema);
module.exports = User;

さて、これをTypeScirptで書くと・・・

//user.ts
import mongoose from 'mongoose'

const schema = new mongoose.Schema({
    userId: {
        type: String,
        required: true,
        unique: true,
        min:2,
        max: 255
    },
    firstName: {
        type: String,
        required: true,
        min: 2,
        max: 255
    },
    lastName: {
        type: String,
        required: true,
        min: 2,
        max: 255
    },

    email: {
        type: String,
        required: true,
        unique: true,
        min: 2,
        max: 255
    },

    password: {
        type: String,
        required: true,
        min: 2,
        max: 255
    },
    created_at: {
        type: Date,
        required: true,
    },
    updated_at: {
        type: Date,
        required: true
    }
});

const User = mongoose.model("User", schema);

export default User;

特に特筆するところはない。が、しかし・・・。 このModelを使って、例えば、urlで指定されたUserのメールアドレスを使ってなんやかんや処理したいというRouterを書くとする。

//routes/users.ts
import express from 'express'
import User from '../models/user2'

const router = express.Router();

router.get('/:username', async(req, res, next) => {
    const result = await User.find({username: req.params.username});
    result.map((value, index, arr) => {
        const email = value.email;
        ...
    });
    ...
});

さて、User.findではクエリで取得できたユーザのArrayが返ってくる(callbackを書かなかった場合)。コレをmapにかけて処理しようとすると・・・

f:id:osamtimizer:20180707204046p:plain
"!?"

なんとemailは無いよって言われて取得できない。というかそもそもDocumentって型にアクセスしに行こうとしている。 JSなら特に何のエラーも起きずに取得できるというのに。

なぜこんなことに

結論から書くと、この挙動は正しい。

mongoose.modelメソッドは、Documentを継承したModelというクラスをさらに継承した特別なModelクラスを返すからで、さらにModel.findは内部でQuery.execを実行して、Document[]を返すからだ。ややこしい。

ものすごく雑に書くと多分こんな感じ。(ソースは個人のイメージであり、所属する組織の見解ではありません)

class Model extends Document{
  find(){
    return this.query.exec();
  }
};

class Query{
  exec(){
    return Promise.resolve(new Array<Document>(...));
  }
};

型定義ファイルを読んで対策を考える

まずはmongoose.d.tsQueryからチェック。

//mongoose.d.ts
  /*
   * section query.js
   * http://mongoosejs.com/docs/api.html#query-js
   *
   * Query<T> is for backwards compatibility. Example: Query<T>.find() returns Query<T[]>.
   * If later in the query chain a method returns Query<T>, we will need to know type T.
   * So we save this type as the second type parameter in DocumentQuery. Since people have
   * been using Query<T>, we set it as an alias of DocumentQuery.
   */
  class Query<T> extends DocumentQuery<T, any> {}
  class DocumentQuery<T, DocType extends Document> extends mquery {
  ...

どうやらQueryDocumentQueryエイリアスらしい。 そしてDocumentQuerymqueryを継承したクラスで、ジェネリックなクラスであることがわかる。

  // https://github.com/aheckmann/mquery
  // mquery currently does not have a type definition please
  //   replace it if one is ever created
  class mquery {}

どうやらmqueryは今回関係なさそう。

DocumentQueryfindメソッドの定義を見ると、

  /**
     * Finds documents. When no callback is passed, the query is not executed. When the
     * query is executed, the result will be an array of documents.
     * @param criteria mongodb selector
     */
    find(callback?: (err: any, res: DocType[]) => void): DocumentQuery<DocType[], DocType>;
    find(criteria: any,
      callback?: (err: any, res: DocType[]) => void): DocumentQuery<DocType[], DocType>;

とあり、クエリが実行されたらDocument[]が得られると書いてある。

Query.execの定義は以下の通り。

    /** Executes the query */
    exec(callback?: (err: any, res: T) => void): Promise<T>;
    exec(operation: string | Function, callback?: (err: any, res: T) => void): Promise<T>;

ここまで見ると、どうやらT型のPromiseを返してくるらしい。 ということは、現状ではtypeof(T) === typeof(Document)となっているようだ。

ここでmongoose.modelの定義を見てみる。

  /**
   * Defines a model or retrieves it.
   * Models defined on the mongoose instance are available to all connection
   *   created by the same mongoose instance.
   * @param name model name
   * @param collection (optional, induced from model name)
   * @param skipInit whether to skip initialization (defaults to false)
   */
  export function model<T extends Document>(name: string, schema?: Schema, collection?: string,
    skipInit?: boolean): Model<T>;
  export function model<T extends Document, U extends Model<T>>(
    name: string,
    schema?: Schema,
    collection?: string,
    skipInit?: boolean
  ): U;

おお!ここで<T extends Document>という記述がされている。 つまり、Documentを継承したなんらかのインターフェイスを定義してやり、mongoose.modelメソッドをジェネリックで呼び出してやれば良さそう。

こんな感じにインターフェイスを作って、

//IUserDocument.ts
import mongoose from 'mongoose'

export default interface IUserDocument extends mongoose.Document {
    userId: String
    firstName: String
    lastName: String
    email: String
    password: String
    created_at: Date
    updated_at: Date
}

こうして、

import mongoose from 'mongoose';
import hash from '../modules/hash';
import IUserDocument from '../interfaces/IUserDocument'

const schema = new mongoose.Schema({
  //...
});

//ジェネリックで呼び出し
const User = mongoose.model<IUserDocument>("User", schema);

//ちなみにキャストしてやってもいい
//でもなんかわかりにくいので上記の方が良さそう
//const User = <mongoose.Model<IUserDocument>>mongoose.model("User", schema);

export default User

こう。

f:id:osamtimizer:20180707204109p:plain

完璧。

ちなみに、schema.preで前処理とかを定義するときもジェネリック呼び出しでやってやる必要がある。 理由は簡単で、preの中のthisDocumentクラスオブジェクトを指しており、そんなプロパティねーよって怒られるから。

f:id:osamtimizer:20180707204117p:plain

schema.preの定義。

//mongoose.d.ts
  /*
   * section schema.js
   * http://mongoosejs.com/docs/api.html#schema-js
   */
  class Schema extends events.EventEmitter {
    /*
    中略
    */
    /**
     * Defines a pre hook for the document.
     */
    pre<T extends Document = Document>(
      method: "init" | "validate" | "save" | "remove",
      fn: HookSyncCallback<T>,
      errorCb?: HookErrorCallback
    ): this;

ジェネリック呼び出しでOK。

schema.pre<IUserDocument>('save', function (next) {
    console.log('pre process');
    this.updated_at = new Date();
    if (!this.created_at) this.created_at = new Date();
    const hashed = hash(String(this.password), 'secret')
    this.password = hashed;
    next();
})

f:id:osamtimizer:20180707204627p:plain

これでTypeScriptでもちゃんとmongooseが使えるようになった。

おまけ: mongooseの実装を読んでみる

まずはmongoose.modelから。

//mongoose/lib/index.js
Mongoose.prototype.model = function(name, schema, collection, skipInit) {
  let model;
  /*
  中略
  */

  //modelを実際に生成している部分
  //Model.compileはModelのstatic method
  const connection = options.connection || this.connection;
  model = this.Model.compile(model || name, schema, collection, connection, this);

  if (!skipInit) {
    model.init();
  }

  if (options.cache === false) {
    return model;
  }

  this.models[name] = model;
  return this.models[name];
};

結構端折ったけど、mongoose.modelを呼ぶと内部でthis.Model.compileというメソッドでコンパイル?という処理を行って、その結果を返している。

ちなみにmongooseはシングルトンで動いていて、どのModelが存在しているかをmodels[]配列で持っているようだ。ということは、mongoose.model(existingModelName)で既に定義済みのModelが取得できることが予想できる。

次はModelクラスの実装を見る。 Model.compileを呼ぶとなんやらfunctionが代入されたmodelが返ってくる。そのfunctionの実態はModelクラスオブジェクトを生成する機能を持つようだ。 つまり、Modelクラスを継承したなんらかのクラスオブジェクトを生成するコンストラクタを返している。

//mongoose/lib/model.js
Model.compile = function compile(name, schema, collectionName, connection, base) {

  /*
  中略
  */

  var model;
  if (typeof name === 'function' && name.prototype instanceof Model) {
    model = name;
    name = model.name;
    schema.loadClass(model, false);
    model.prototype.$isMongooseModelPrototype = true;
  } else {
    // generate new class
    // user.jsで実際にスキーマからModelを作る場合
    // これから作るModelの生成機能を持ったfunction(つまりコンストラクタ)をmodelオブジェクトに代入、中身ではnew model もしくは、Model.call(this);
    model = function model(doc, fields, skipId) {
      if (!(this instanceof model)) {
        return new model(doc, fields, skipId);
      }
      Model.call(this, doc, fields, skipId);
    };
  }
  
  //modelオブジェクトにはModelクラスのプロトタイプやらなんやらを詰め込んでいる
  model.hooks = schema.s.hooks.clone();
  model.base = base;
  model.modelName = name;
  if (!(model.prototype instanceof Model)) {
    model.__proto__ = Model;
    model.prototype.__proto__ = Model.prototype;
  }
  model.model = Model.prototype.model;
  model.db = model.prototype.db = connection;
  model.discriminators = model.prototype.discriminators = undefined;

  model.prototype.$__setSchema(schema); 
  /*
  中略
  */
  
  return model;
};

次はModelクラス。コンストラクタでは、Document.call(this, doc, fields, skipId)が呼ばれており、Modelは実はDocumentクラスを継承していることがわかる。

//mongoose/lib/model.js
function Model(doc, fields, skipId) {
  if (fields instanceof Schema) {
    throw new TypeError('2nd argument to `Model` must be a POJO or string, ' +
      '**not** a schema. Make sure you\'re calling `mongoose.model()`, not ' +
      '`mongoose.Model()`.');
  }
  //function Model()はfunction Document()を呼んでいるのに等しい
  //class記法で言う所のconstructor(){super()}
  Document.call(this, doc, fields, skipId);
}

つまり、TypeScriptの以下のようなコードは、

const User = mongoose.model("User", schema);

新しいUserというModelクラス(かつDocumentを継承している)を動的に生成していることに等しい。

終わりに

T as Documentを継承したModelにクエリを投げるとT as Documentが返ってくるのはなんか不思議な感じ。まあ確かにODMっぽさはあるけど。 他のORMと設計を比較してみても良いかもしれない。

参考: http://chords.hatenablog.com/entry/2014/10/08/mongoose_%2B_typescript