Migration mongoose models and schemas from JavaScript to TypeScript
今年は自宅にこもりがちになって腰椎椎間板ヘルニアと坐骨神経痛のコンボを決めた @algas です。
この記事は TypeScript Advent Calendar 2020 の1日目の記事として作成しました。
記事に登場するコードは github リポジトリで公開しています。
https://github.com/algas/typed-mongoose-example
概要
mongoose という mongoDB の Node.js では有名なライブラリを使うアプリケーションコードを JavaScript から TypeScript に移行する作業を行いました。
JavaScript で定義済みの mongoose のモデルとスキーマに「正しく」型をつけることができたのでそのノウハウを共有します。
基本的には @types/mongoose を使って型をつけています。
スキーマの型定義の一部が不十分だったので独自の定義をして補いました。
対象読者
この記事は次のような読者を対象に想定しています。
- mongoose または mongoDB を使っている
- TypeScript を書いたことがある
mongoDB や mongoose, TypeScript の詳しい説明はしません。
背景
この記事を書くに至った背景を説明します。
なぜ mongoose のコードに型をつけると良いのか
わざわざ書くまでもないとは思いますが、コードを静的型付きにすることでコンパイル時に不具合を見つけやすくなったり開発環境によるコード記述の補完を得られるようになります。適切に型をつけることができればスキーマやモデルに対して定義したフィールドやメソッドとして何が含まれているかやその型の情報を使って効率よく安心して開発をすることができます。
mongoose が TypeScript ネイティブではない
mongoose は JavaScript で古くから開発され続けているライブラリで、そのコードは TypeScript に対応していません。@types/mongoose
で型情報を別途付与することはできますが、TypeScript のアプリケーションから呼び出すのに便利な実装になっているとは言えません。
mongoose のスキーマやモデルに型をつけるのは難しい
mongoose スキーマやモデルに後から TypeScript で型をつけるのには工夫が必要です。mongoose の Collection スキーマには単純にデータベースに値を保持するフィールドだけではなく Virtual Property, Instance Method, Static Method, Plugin などの機能があります。これらに対応するモデルに型をつけ、さらにスキーマ自体にフィールドの型を付与するのが本記事の試みです。
初めから TypeScript で書く場合やこれから新しく mongoose スキーマを定義する場合には別のライブラリを使うなどの手法をオススメします。
実装例
具体的に mongoose で定義したスキーマとモデルに TypeScript で型をつけてみます。
https://github.com/algas/typed-mongoose-example/blob/main/src/model.ts
執筆時点では次のバージョンのライブラリにそれぞれ対応しています。
-
typescript
: 3.x -
mongoose
: 5.x -
@types/mongoose
: 5.x
mongoose の基本的な使い方は公式ドキュメントに書いてあります。
https://mongoosejs.com/docs/index.html
これを知っている前提で話を進めます。
Schema
mongoDB の Collection に入れるデータのスキーマを定義します。
User
という Collection を作ることにします。
Instance method として foo
という関数を追加しています。
import { Document, model, Model, Schema, Types } from 'mongoose';
interface UserSchemaFields {
email: string;
firstName: string;
lastName: string;
age?: number;
friends?: Types.ObjectId[];
}
const userSchemaFields: SchemaDefinition<UserSchemaFields> = {
email: {
type: String,
required: true,
unique: true
},
firstName: {
type: String,
required: true
},
lastName: {
type: String,
required: true
},
age: {
type: Number
},
friends: [Schema.Types.ObjectId],
};
const UserSchema: Schema<UserSchemaProperties> = new Schema(userSchemaFields);
// Instance methods
interface UserSchemaProperties extends UserSchemaFields {
foo: () => void;
}
UserSchema.methods.foo = function() {};
ここではスキーマとそのフィールドの定義に明示的に型を与えているのが一般的な方法との違いです。
Instance method の定義は Schema
だけに与えて SchemaDefinition
には与えません。
独自に定義し直した SchemaDefinition
は次のように書けます。
https://github.com/algas/typed-mongoose-example/blob/main/src/mongoose-util.d.ts
import { Schema, SchemaType, SchemaTypeOpts } from 'mongoose';
type SchemaPropType<T> = T extends string
? StringConstructor
: T extends number
? NumberConstructor
: T extends boolean
? BooleanConstructor
: any;
type SchemaTypeOptions<T> = Omit<SchemaTypeOpts<T>, 'type'> & {
type?: SchemaPropType<T>;
};
export type SchemaDefinition<T = any> = {
[K in keyof T]: SchemaTypeOptions<T[K]> | Schema | SchemaType;
};
SchemaTypeOpts から 'type' の定義を取り除いて使いました。
元の実装は次のコードで定義されています。
https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/mongoose/index.d.ts
Virtual properties (Document)
mongoose schema には virtual
という機能を使って仮想的な要素を追加できます。
ここでは fullName
を取得する機能を実装します。
UserDocument
に Document
と UserSchemaProperties
(fields + instance methods) を継承させて virtual property fullName
の型を足します。
// Virtual properties
interface UserDocument extends Document, UserSchemaProperties {
fullName: string;
}
UserSchema.virtual('fullName').get(function () {
return [this.firstName, this.lastName].join(' ');
});
mongoose Document
の詳細は mongoose の API reference に書かれています。
https://mongoosejs.com/docs/documents.html
Statics variables and functions
mongoose schema には static の変数や関数を追加できます。
UserModel
に上で定義した UserDocument
を指定した Model<UserDocument>
を継承させて static method bar
の型を足します。
// Static methods
interface UserModel extends Model<UserDocument> {
bar: () => string;
}
UserSchema.statics.bar = function(){
return 'bar';
}
mongoose Model
の詳細は mongoose の API reference に書かれています。
https://mongoosejs.com/docs/models.html
Plugins
他のスキーマで定義された関数などを plugin として呼び出すことができます。
plugin で作った変数や関数の interface は本体とは別に定義して個別に呼び出せるようにすべきです。
たとえば SomePluginSchema
という名前のスキーマで定義されている static 関数を呼び出す場合には次のように書きます。
interface SomePluginFunctions {
somePluginFunction: () => void;
}
UserSchema.plugin(SomePluginSchema, {});
interface UserModel extends Model<UserDocument>, SomePluginFunctions { ... }
Model
mongoose model を定義します。
引数にはモデルの名前とスキーマを渡します。
型指定に UserDocument
, UserModel
を明示することで User
モデルの型情報が使えるようになります。
export const User = model<UserDocument, UserModel>('User', UserSchema);
モデルとスキーマを使ってみる
ここまででスキーマとモデルの定義はおしまいです。
モデルを使うにはインスタンス化する必要があります。
const someUserData: UserSchemaFields {
...
}
const someUser = new User(someUserData);
someUser
は UserDocument
型になります。
User()
の引数に渡すオブジェクトに UserSchemaFields
の型を使えば厳密に定義できます。
また User
データを mongoDB から取得するコードは次のように書きます。
User.find(function (err, users) {
if (err) return console.error(err);
console.log(users);
})
TypeScript が正しく設定された開発環境を使うと User
やそのインスタンスにフィールドやメソッドが適切に補完されることを確認できます。
まとめ
- 適切に型をつければ TypeScript でも mongoose を使った開発をできる
- mongoose に TypeScript で適切な型をつけるのはちょっと大変
- 新しく TypeScript + mongoDB を使った開発をするのであれば mongoose 以外のライブラリを使うべき
注意点
- 本記事内に登場するコードはMITライセンスで公開しています
https://github.com/algas/typed-mongoose-example - Schema の型定義の変更は
@types/mongoose
にもフィードバックする予定です