search
LoginSignup
4

More than 1 year has passed since last update.

posted at

updated at

Organization

mongoose の model と schema に TypeScript で型をつける

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 を取得する機能を実装します。
UserDocumentDocumentUserSchemaProperties (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);

someUserUserDocument 型になります。
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 以外のライブラリを使うべき

注意点

参考文献

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
4