MongooseのDocument/Model/Schemaの型情報を関連付ける方法
本記事で利用しているサンプルコードはGithubにて公開しています。
修正
・2020/11/18
Date型やObjectの中を定義せずに自由に格納した場合にMongooseSchemaDefinition
で指定できないことが判明したので修正しました。
問題意識
MongooseのDocumentとModelの型情報を関連付けはできるが、DocumentとSchemaを関連付けることができないこと。
型情報を関連付けることができないということは、コンパイルエラーにならないということです。
そのため、実行するまでエラーを見つけることができません。その上、エラーが発生しなかった場合は潜在バグとして残り続けることになるので、とても厄介な状態と言えます。
例えば、下記の定義方法だとレコードの情報をITestDocument
とTestModel
のtestSchema
の2か所で管理している状態になっています。なので、ITestDocument
を変更する場合はtestSchema
も合わせて変更する必要があります。
export interface ITestDocument extends Document {
name: string;
value: number;
stringList: string[];
objectList: { key: string; value: { a: number; b: number } }[];
testEnum: TestEnum;
}
import mongoose, { Schema } from "mongoose";
import { ITest } from "./ITest";
import { ITestDocument } from "./TestDocument";
import { TestEnumList } from "./TestEnum";
const testSchema: any = {
name: {
type: String,
required: true,
maxLength: 20,
},
value: {
type: Number,
min: 0,
max: 10,
},
stringList: {
type: [{ type: String, required: true }],
default: [],
},
objectList: {
type: [
{
key: String,
value: {
type: {
a: { type: Number },
b: { type: Number },
},
},
},
],
default: [],
},
testEnum: {
type: String,
enum: TestEnumList,
required: true,
},
};
export const TestModel = mongoose.model<ITestDocument>(
"TestTable",
new Schema(testSchema)
);
export const TestEnumList = ["A", "B", "C"] as const;
export type TestEnum = typeof TestEnumList[number];
mongoose/index.d.ts
まずはSchemaがどのような形になっているかを確認したいと思います。
mongoose/index.d.ts
を確認するとtestSchema
はSchemaDefinition
型と言えます。
class Schema<T = any> extends events.EventEmitter {
constructor(definition?: SchemaDefinition, options?: SchemaOptions);
}
interface SchemaDefinition {
[path: string]: SchemaTypeOpts<any> | Schema | SchemaType;
}
interface SchemaTypeOpts<T> {
alias?: string;
/* Common Options for all schema types */
type?: T;
/** Sets a default value for this SchemaType. */
default?: SchemaTypeOpts.DefaultFn<T> | T;
/**
* Adds a required validator to this SchemaType. The validator gets added
* to the front of this SchemaType's validators array using unshift().
*/
required?: SchemaTypeOpts.RequiredFn<T> |
boolean | [boolean, string] |
string | [string, string] |
any;
/** Declares an unique index. */
unique?: boolean | any;
}
class SchemaType {
constructor(path: string, options?: any, instance?: string);
}
解決策
DocumentとSchemaを関連付けることができないのは、SchemaDefinition
が何かの型に依存して定義されていない点にあります。
そのため、何かの型に依存したSchemaDefinition
を定義することで、この問題を解決することができます。
そして、Document
とtestSchema(SchemaDefinition型)
を関連付けるために作成した型がこちらです。
import { SchemaTypeOpts, Schema, SchemaType } from "mongoose";
type SchemaDefinitionValueType<T = any> =
| SchemaTypeOpts<T>
| Schema
| SchemaType;
export type MongooseSchemaDefinitionType<T> = {
readonly [U in keyof T]-?: T[U] extends (infer R)[]
? R extends object
? SchemaDefinitionValueType<MongooseSchemaDefinitionType<R>[]>
: SchemaDefinitionValueType
: T[U] extends object
? SchemaDefinitionValueType<MongooseSchemaDefinitionType<T[U]>> | SchemaDefinitionValueType
: SchemaDefinitionValueType;
};
export type MongooseSchemaDefinition<T> = MongooseSchemaDefinitionType<Omit<T, '_id'>>;
Mongooseを使用していると_id
を利用したいと思います。
それをT
に指定しているとMongooseSchemaDefinitionType
で_id
を定義しなくてはならなくなるのでOmit
で_id
キーを削除しています。
monngooseで定義しているSchemaDefinition
をMongooseSchemaDefinition<T>
に、SchemaTypeOpts<any> | Schema | SchemaType
をSchemaDefinitionValueType
に対応させています。
interface SchemaDefinition {
[path: string]: SchemaTypeOpts<any> | Schema | SchemaType;
}
MongooseSchemaDefinitionの解説
-
条件1
T[U]型がジェネリックR型配列かを判定
例:{ stringList: string[] }
のときT[U]型はstring[]
を示しているので、(infer R)[] == string[]
となります。
なので、ジェネリックR型はstring型となります。 -
条件2
ジェネリックR型がobject型かを判定
ジェネリックR型がオブジェクトの場合、定義1を利用し再帰的に評価します。
そうでない場合、T[U]型をSchemaDefinitionValueType
にします。 -
条件3
T[U]型がobject型かを判定
T[U]型がオブジェクトの場合、定義1を利用し再帰的に評価します。
そうでない場合、T[U]型をSchemaDefinitionValueType
にします。
Document/Model/Schemaの型情報を関連付ける
MongooseSchemaDefinition
を利用して、最初に示したITestDocument
とTestModel
を関連付けます。
このとき、直接Document
とSchema
を紐づけるわけではなく、ITest
というインターフェースを通して関連付けを行います。
export interface ITest {
name: string;
value: number;
stringList: string[];
objectList: { key: string; value: { a: number; b: number } }[];
testEnum: TestEnum;
}
import { Document } from "mongoose";
import { ITest } from "./ITest";
export interface ITestDocument extends ITest, Document {}
import mongoose, { Schema } from "mongoose";
import { MongooseSchemaDefinition } from "../../src/MongooseSchemaDefinition";
import { ITest } from "./ITest";
import { ITestDocument } from "./TestDocument";
import { TestEnumList } from "./TestEnum";
const testSchema: MongooseSchemaDefinition<ITest> = {
name: {
type: String,
required: true,
maxLength: 20,
},
value: {
type: Number,
min: 0,
max: 10,
},
stringList: {
type: [{ type: String, required: true }],
default: [],
},
objectList: {
type: [
{
key: String,
value: {
type: {
a: { type: Number },
b: { type: Number },
},
},
},
],
default: [],
},
testEnum: {
type: String,
enum: TestEnumList,
required: true,
},
};
export const TestModel = mongoose.model<ITestDocument>(
"TestTable",
new Schema(testSchema)
);
このように定義することで、ITestDocument
とTestModel
とtestSchema
がITest
を介して型の関連付けが行われます。
たとえば、ITest
に{ flag: boolean }
を追加した場合、MongooseSchemaDefinition
のほうで、flag
が定義されていないとエラーを表示するようになります。
ITest
から{ testEnum: TestEnum }
を削除した場合、余計な定義があるとエラーを表示するようになります。
番外編1 DTO
Mapped types関係で事前に定義されているRequired
,Partial
, Pick
を活用することで、ITest
とDtoを次のように関連付けることができます。
また、ジェネリックU型は|で結ぶことで複数指定することができるので、TestCreateDto
やTestUpdateDto
で必要なキーをITest
から選択することができます。
CreateDto
は指定されたキーのみをピックアップし必須パラメータにします。
UpdateDto
は指定されたキーのみをピックアップし任意パラメータにします。
export type CreateDto<T, U extends keyof T> = Required<Pick<T, U>>;
export type UpdateDto<T, U extends keyof T> = Partial<Pick<T, U>>;
import { ITest } from "./ITest";
import { CreateDto, UpdateDto } from "../../src/Dto";
export type TestCreateDto = CreateDto<ITest, "name" | "value" | "testEnum">;
export type TestUpdateDto = UpdateDto<
ITest,
"value" | "testEnum" | "stringList" | "objectList"
>;
番外編2 API Request Validator
APIのバリデーションデータにおいても、MongooseSchemaDefinition
と同じように定義することができます。
番外編1で作成したDTOを基にcreateSchema
,updateSchema
を規定することで、ITest
を基にDB周りからAPIの入り口までの範囲を1つのインターフェースで関連付けて管理することができるようになります。
interface StringRequestValidator {
readonly type: "string";
readonly required: boolean;
readonly regExp: RegExp;
}
interface NumberRequestValidator {
readonly type: "number";
readonly required: boolean;
readonly min?: number;
readonly max?: number;
}
interface BooleanRequestValidator {
readonly type: "boolean";
readonly required: boolean;
}
interface ObjectRequestValidator<T> {
readonly type: "object";
readonly required: boolean;
readonly validator: T;
}
interface ArrayRequestValidator<T> {
readonly type: "array";
readonly required: boolean;
readonly minLength?: number;
readonly maxLength?: number;
readonly validator: T;
readonly validatorTypeObject: boolean;
}
interface EnumRequestValidator {
readonly type: "enum";
readonly required: boolean;
readonly list: readonly (string | number)[];
}
type RequestValidator<T = any> =
| StringRequestValidator
| NumberRequestValidator
| BooleanRequestValidator
| EnumRequestValidator
| ObjectRequestValidator<T>
| ArrayRequestValidator<T>;
export type ApiRequestValidator<T> = {
readonly [U in keyof T]-?: T[U] extends (infer R)[]
? R extends object
? RequestValidator<ApiRequestValidator<R>>
: RequestValidator
: T[U] extends object
? RequestValidator<ApiRequestValidator<T[U]>>
: RequestValidator;
};
import { ApiRequestValidator } from "../src/ApiRequestValidator";
import { TestCreateDto, TestUpdateDto } from "./TestTable/TestDto";
import { TestEnumList } from "./TestTable/TestEnum";
const createSchema: ApiRequestValidator<TestCreateDto> = {
name: {
type: "string",
required: true,
regExp: /.{1,10}/,
},
value: {
type: "number",
required: true,
min: 0,
max: 10,
},
testEnum: {
type: "enum",
required: true,
list: TestEnumList,
},
};
const updateSchema: ApiRequestValidator<TestUpdateDto> = {
value: {
type: "number",
required: false,
min: 0,
max: 10,
},
testEnum: {
type: "enum",
required: false,
list: TestEnumList,
},
stringList: {
type: "array",
required: false,
validatorTypeObject: false,
validator: {
type: "string",
},
},
objectList: {
type: "array",
required: false,
validatorTypeObject: true,
validator: {
key: {
type: "string",
required: true,
regExp: /.{1,10}/,
},
value: {
type: "object",
required: true,
validator: {
a: {
type: "number",
required: true,
},
b: {
type: "number",
required: true,
},
},
},
},
},
};
最後に
今回、初めてTypescriptのMapped typesの機能を利用してみました。
MongooseSchemaDefinition
のように、ある型から全く違う型に変換することができるということは、他にもいろいろな応用ができると思います。
型同士に関連があるにもかかわらず関連付けができないやつに、どうにかMapped typesを適用できないかを考えていきたいと思います。
何かお気づきの点がありましたら、コメントよろしくお願いします。
・こうしないと特定のパターンが解決できない
・こういう型を定義したときにうまくできない