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にかけて処理しようとすると・・・
なんと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.ts
のQuery
からチェック。
//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 { ...
どうやらQuery
はDocumentQuery
のエイリアスらしい。
そしてDocumentQuery
はmquery
を継承したクラスで、ジェネリックなクラスであることがわかる。
// https://github.com/aheckmann/mquery // mquery currently does not have a type definition please // replace it if one is ever created class mquery {}
どうやらmquery
は今回関係なさそう。
DocumentQuery
のfind
メソッドの定義を見ると、
/** * 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
こう。
完璧。
ちなみに、schema.pre
で前処理とかを定義するときもジェネリック呼び出しでやってやる必要がある。
理由は簡単で、pre
の中のthis
はDocument
クラスオブジェクトを指しており、そんなプロパティねーよって怒られるから。
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(); })
これで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