TypeScriptにおけるスキーマ

Mongoose スキーマ は、ドキュメントの構造をMongooseに指示する方法です。MongooseスキーマはTypeScriptインターフェースとは別物であるため、生のドキュメントインターフェーススキーマの両方を定義する必要があります。または、Mongooseがスキーマ定義から型を自動的に推論することに依存します。

自動型推論

Mongooseは、以下の通り、スキーマ定義からドキュメント型を自動的に推論できます。スキーマとモデルを定義する際には、自動型推論に依存することをお勧めします。

import { Schema, model } from 'mongoose';
// Schema
const schema = new Schema({
  name: { type: String, required: true },
  email: { type: String, required: true },
  avatar: String
});

// `UserModel` will have `name: string`, etc.
const UserModel = mongoose.model('User', schema);

const doc = new UserModel({ name: 'test', email: 'test' });
doc.name; // string
doc.email; // string
doc.avatar; // string | undefined | null

自動型推論を使用する場合、いくつかの注意点があります。

  1. tsconfig.jsonstrictNullChecks: trueまたはstrict: trueを設定する必要があります。または、コマンドラインでフラグを設定する場合は、--strictNullChecksまたは--strictを使用します。厳格モードが無効になっている場合、自動型推論には既知の問題があります。
  2. new Schema()呼び出しでスキーマを定義する必要があります。スキーマ定義を一時変数に代入しないでください。const schemaDefinition = { name: String }; const schema = new Schema(schemaDefinition);のようなことをすると動作しません。
  3. スキーマでtimestampsオプションを指定した場合、MongooseはcreatedAtupdatedAtをスキーマに追加しますが、methodsvirtuals、またはstaticsも指定した場合は例外です。タイムスタンプとmethods/virtuals/staticsオプションを組み合わせた型推論には既知の問題があります。methods、virtuals、staticsを使用する場合は、createdAtupdatedAtをスキーマ定義に追加する必要があります。

スキーマを別途定義する必要がある場合は、as constconst schemaDefinition = { ... } as const;)を使用して、型の拡張を防ぎます。TypeScriptは、required: falseのような型をrequired: booleanに自動的に拡張しますが、これによりMongooseはフィールドが必須であると想定します。as constを使用すると、TypeScriptはこれらの型を保持します。

スキーマ定義から生のドキュメント型(doc.toObject()await Model.findOne().lean()などから返される値)を明示的に取得する必要がある場合は、MongooseのinferRawDocTypeヘルパーを以下のように使用できます。

import { Schema, InferRawDocType, model } from 'mongoose';

const schemaDefinition = {
  name: { type: String, required: true },
  email: { type: String, required: true },
  avatar: String
} as const;
const schema = new Schema(schemaDefinition);

const UserModel = model('User', schema);
const doc = new UserModel({ name: 'test', email: 'test' });

type RawUserDocument = InferRawDocType<typeof schemaDefinition>;

useRawDoc(doc.toObject());

function useRawDoc(doc: RawUserDocument) {
  // ...
}

自動型推論がうまくいかない場合は、いつでもドキュメントインターフェース定義に戻ることができます。

個別のドキュメントインターフェース定義

自動型推論がうまくいかない場合は、以下のように個別の生のドキュメントインターフェースを定義できます。

import { Schema } from 'mongoose';

// Raw document interface. Contains the data type as it will be stored
// in MongoDB. So you can ObjectId, Buffer, and other custom primitive data types.
// But no Mongoose document arrays or subdocuments.
interface User {
  name: string;
  email: string;
  avatar?: string;
}

// Schema
const schema = new Schema<User>({
  name: { type: String, required: true },
  email: { type: String, required: true },
  avatar: String
});

デフォルトでは、Mongooseは生のドキュメントインターフェースがスキーマと一致するかどうかを確認しません。たとえば、上記のコードでは、ドキュメントインターフェースでemailがオプションでも、schemarequiredになっていてもエラーは発生しません。

ジェネリックパラメーター

TypeScriptのMongoose Schemaクラスには、9つのジェネリックパラメーターがあります。

  • RawDocType - MongoDBにデータがどのように保存されるかを記述するインターフェース
  • TModelType - Mongooseモデルの型。クエリヘルパーやインスタンスメソッドを定義しない場合は省略できます。
    • デフォルト: Model<DocType, any, any>
  • TInstanceMethods - スキーマのメソッドを含むインターフェース。
    • デフォルト: {}
  • TQueryHelpers - スキーマに定義されたクエリヘルパーを含むインターフェース。デフォルトは{}
  • TVirtuals - スキーマに定義された仮想フィールドを含むインターフェース。デフォルトは{}
  • TStaticMethods - モデルのメソッドを含むインターフェース。デフォルトは{}
  • TSchemaOptions - Schema()コンストラクターの2番目のオプションとして渡される型。デフォルトはDefaultSchemaOptions
  • DocType - スキーマから推論されたドキュメント型。
  • THydratedDocumentType - ハイドレートされたドキュメント型。これは、await Model.findOne()Model.hydrate()などのデフォルトの戻り値の型です。
TypeScript定義の表示
export class Schema<
  RawDocType = any,
  TModelType = Model<RawDocType, any, any, any>,
  TInstanceMethods = {},
  TQueryHelpers = {},
  TVirtuals = {},
  TStaticMethods = {},
  TSchemaOptions = DefaultSchemaOptions,
  DocType = ...,
  THydratedDocumentType = HydratedDocument<FlatRecord<DocType>, TVirtuals & TInstanceMethods>
>
  extends events.EventEmitter {
  // ...
}

最初のジェネリックパラメーターであるDocTypeは、MongooseがMongoDBに保存するドキュメントの型を表します。Mongooseは、ドキュメントミドルウェアのthisパラメーターなどの場合に、DocTypeをMongooseドキュメントでラップします。例えば、

schema.pre('save', function(): void {
  console.log(this.name); // TypeScript knows that `this` is a `mongoose.Document & User` by default
});

2番目のジェネリックパラメーターであるMは、スキーマで使用されるモデルです。Mongooseは、スキーマで定義されたモデルミドルウェアでM型を使用します。

3番目のジェネリックパラメーターであるTInstanceMethodsは、スキーマで定義されたインスタンスメソッドの型を追加するために使用されます。

4番目のパラメーターであるTQueryHelpersは、チェーン可能なクエリヘルパーの型を追加するために使用されます。

スキーマとインターフェースのフィールド

Mongooseは、スキーマ内のすべてのパスがドキュメントインターフェースに定義されていることを確認します。

たとえば、以下のコードは、emailがスキーマ内のパスであるが、DocTypeインターフェースにはないため、コンパイルに失敗します。

import { Schema, Model } from 'mongoose';

interface User {
  name: string;
  email: string;
  avatar?: string;
}

// Object literal may only specify known properties, but 'emaill' does not exist in type ...
// Did you mean to write 'email'?
const schema = new Schema<User>({
  name: { type: String, required: true },
  emaill: { type: String, required: true },
  avatar: String
});

しかし、Mongooseは、ドキュメントインターフェースには存在するが、スキーマには存在しないパスについては確認しません。たとえば、以下のコードはコンパイルされます。

import { Schema, Model } from 'mongoose';

interface User {
  name: string;
  email: string;
  avatar?: string;
  createdAt: number;
}

const schema = new Schema<User, Model<User>>({
  name: { type: String, required: true },
  email: { type: String, required: true },
  avatar: String
});

これは、Mongooseには、Schema()コンストラクターにこれらのパスを明示的に追加することなく、DocTypeインターフェースに含める必要がある多くのパスをスキーマに追加する機能があるためです。たとえば、タイムスタンププラグインなどです。

配列

ドキュメントインターフェースで配列を定義する場合は、MongooseのTypes.Array型またはTypes.DocumentArray型ではなく、プレーンなJavaScript配列を使用することをお勧めします。代わりに、モデルとスキーマのTHydratedDocumentTypeジェネリックを使用して、ハイドレートされたドキュメント型にTypes.Array型とTypes.DocumentArray型のパスがあることを定義します。

import mongoose from 'mongoose'
const { Schema } = mongoose;

interface IOrder {
  tags: Array<{ name: string }>
}

// Define a HydratedDocumentType that describes what type Mongoose should use
// for fully hydrated docs returned from `findOne()`, etc.
type OrderHydratedDocument = mongoose.HydratedDocument<
  IOrder,
  { tags: mongoose.HydratedArraySubdocument<{ name: string }> }
>;
type OrderModelType = mongoose.Model<
  IOrder,
  {},
  {},
  {},
  OrderHydratedDocument // THydratedDocumentType
>;

const orderSchema = new mongoose.Schema<
  IOrder,
  OrderModelType,
  {}, // methods
  {}, // query helpers
  {}, // virtuals
  {}, // statics
  mongoose.DefaultSchemaOptions, // schema options
  IOrder, // doctype
  OrderHydratedDocument // THydratedDocumentType
>({
  tags: [{ name: { type: String, required: true } }]
});
const OrderModel = mongoose.model<IOrder, OrderModelType>('Order', orderSchema);

// Demonstrating return types from OrderModel
const doc = new OrderModel({ tags: [{ name: 'test' }] });

doc.tags; // mongoose.Types.DocumentArray<{ name: string }>
doc.toObject().tags; // Array<{ name: string }>

async function run() {
  const docFromDb = await OrderModel.findOne().orFail();
  docFromDb.tags; // mongoose.Types.DocumentArray<{ name: string }>

  const leanDoc = await OrderModel.findOne().orFail().lean();
  leanDoc.tags; // Array<{ name: string }>
};

配列サブドキュメントの型にはHydratedArraySubdocument<RawDocType>を、単一サブドキュメントの型にはHydratedSingleSubdocument<RawDocType>を使用します。

スキーマメソッド、ミドルウェア、または仮想フィールドを使用していない場合は、Schema()の最後の7つのジェネリックパラメーターを省略し、new mongoose.Schema<IOrder, OrderModelType>(...)を使用してスキーマを定義できます。スキーマのTHydratedDocumentTypeパラメーターは、主にメソッドと仮想フィールドのthisの値を設定するためのものでです。