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

# Migration mongoose models and schemas from JavaScript to TypeScript

今年は自宅にこもりがちになって腰椎椎間板ヘルニアと坐骨神経痛のコンボを決めた [@algas](https://twitter.com/algas) です。
この記事は [TypeScript Advent Calendar 2020](https://qiita.com/advent-calendar/2020/typescript) の1日目の記事として作成しました。
記事に登場するコードは github リポジトリで公開しています。
https://github.com/algas/typed-mongoose-example

## 概要

[mongoose](https://mongoosejs.com/) という [mongoDB](https://www.mongodb.com/) の Node.js では有名なライブラリを使うアプリケーションコードを JavaScript から TypeScript に移行する作業を行いました。
JavaScript で定義済みの mongoose のモデルとスキーマに「正しく」型をつけることができたのでそのノウハウを共有します。
基本的には [@types/mongoose](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/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` という関数を追加しています。

```ts
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

```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` の型を足します。

```ts
// 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` の型を足します。

```ts
// 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 関数を呼び出す場合には次のように書きます。

```ts
interface SomePluginFunctions {
  somePluginFunction: () => void;
}

UserSchema.plugin(SomePluginSchema, {});
interface UserModel extends Model<UserDocument>, SomePluginFunctions { ... }
```


### Model

mongoose model を定義します。
引数にはモデルの名前とスキーマを渡します。
型指定に `UserDocument`, `UserModel` を明示することで `User` モデルの型情報が使えるようになります。

```ts
export const User = model<UserDocument, UserModel>('User', UserSchema);
```

### モデルとスキーマを使ってみる

ここまででスキーマとモデルの定義はおしまいです。
モデルを使うにはインスタンス化する必要があります。

```ts
const someUserData: UserSchemaFields {
  ...
}
const someUser = new User(someUserData);
```

`someUser` は `UserDocument` 型になります。
`User()` の引数に渡すオブジェクトに `UserSchemaFields` の型を使えば厳密に定義できます。

また `User` データを mongoDB から取得するコードは次のように書きます。

```ts
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` にもフィードバックする予定です

## 参考文献

- [DefinitelyTyped mongoose](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/mongoose)
- [Complete guide for Typescript with Mongoose for Node.js](https://medium.com/@agentwhs/complete-guide-for-typescript-for-mongoose-for-node-js-8cc0a7e470c1)
- [MongooseのDocument／Model／Schemaの型情報を関連付ける方法](https://qiita.com/Akasaki/items/8f8ac0484193afa1b80a)