MongooseのDocument／Model／Schemaの型情報を関連付ける方法

# MongooseのDocument／Model／Schemaの型情報を関連付ける方法

本記事で利用しているサンプルコードは[Github](https://github.com/AkasakiTomohiro/MongoSchemaSample)にて公開しています。

## 修正

・2020/11/18
Date型やObjectの中を定義せずに自由に格納した場合に`MongooseSchemaDefinition`で指定できないことが判明したので修正しました。

## 問題意識

Mongooseの**DocumentとModelの型情報を関連付けはできる**が、**DocumentとSchemaを関連付けることができない**こと。
型情報を関連付けることができないということは、**コンパイルエラーにならない**ということです。
そのため、実行するまでエラーを見つけることができません。その上、エラーが発生しなかった場合は潜在バグとして残り続けることになるので、とても厄介な状態と言えます。

例えば、下記の定義方法だとレコードの情報を`ITestDocument`と`TestModel`の`testSchema`の2か所で管理している状態になっています。なので、`ITestDocument`を変更する場合は`testSchema`も合わせて変更する必要があります。

``` ITestDocument.ts
export interface ITestDocument extends Document {
  name: string;
  value: number;
  stringList: string[];
  objectList: { key: string; value: { a: number; b: number } }[];
  testEnum: TestEnum;
}
```

``` TestModel.ts
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)
);

```

``` TestEnum.ts
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`型と言えます。

``` Schema.ts
class Schema<T = any> extends events.EventEmitter {
    constructor(definition?: SchemaDefinition, options?: SchemaOptions);
}
```

``` SchemaDefinition.ts
interface SchemaDefinition {
  [path: string]: SchemaTypeOpts<any> | Schema | SchemaType;
}
```

``` SchemaTypeOpts.ts
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;
}
```

``` SchemaType.ts
class SchemaType {
  constructor(path: string, options?: any, instance?: string);
}
```

## 解決策

DocumentとSchemaを関連付けることができないのは、`SchemaDefinition`が何かの型に依存して定義されていない点にあります。
そのため、何かの型に依存した`SchemaDefinition`を定義することで、この問題を解決することができます。
そして、`Document`と`testSchema(SchemaDefinition型)`を関連付けるために作成した型がこちらです。

``` MongooseSchema.ts
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`に対応させています。

``` SchemaDefinition.ts
interface SchemaDefinition {
  [path: string]: SchemaTypeOpts<any> | Schema | SchemaType;
}
```

### MongooseSchemaDefinitionの解説
![MongooseSchemaDefinition_T_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152941/8be592a4-fad1-1a2f-ea63-975867017591.png)

- 条件１
	T[U]型がジェネリックR型配列かを判定
	例：`{ stringList: string[] }`のときT[U]型は`string[]`を示しているので、`(infer R)[] == string[]`となります。
	なので、ジェネリックR型はstring型となります。

- 条件２
	ジェネリックR型がobject型かを判定
	ジェネリックR型がオブジェクトの場合、定義１を利用し再帰的に評価します。
	そうでない場合、T[U]型を`SchemaDefinitionValueType`にします。

- 条件３
	T[U]型がobject型かを判定
	T[U]型がオブジェクトの場合、定義１を利用し再帰的に評価します。
	そうでない場合、T[U]型を`SchemaDefinitionValueType`にします。

## Document／Model／Schemaの型情報を関連付ける

`MongooseSchemaDefinition`を利用して、最初に示した`ITestDocument`と`TestModel`を関連付けます。
このとき、直接`Document`と`Schema`を紐づけるわけではなく、`ITest`というインターフェースを通して関連付けを行います。

``` ITest.ts
export interface ITest {
  name: string;
  value: number;
  stringList: string[];
  objectList: { key: string; value: { a: number; b: number } }[];
  testEnum: TestEnum;
}
```

``` ITestDocument.ts
import { Document } from "mongoose";
import { ITest } from "./ITest";

export interface ITestDocument extends ITest, Document {}
```

``` TestModel.ts
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`が定義されていないとエラーを表示するようになります。
![2020-08-07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152941/c3509fd7-943e-7f56-8286-da3ab2a60980.png)

`ITest`から`{ testEnum: TestEnum }`を削除した場合、余計な定義があるとエラーを表示するようになります。
![2020-08-07 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152941/d0ba28d3-3bf8-8702-c486-1f65b1fb1f00.png)

## 番外編1　DTO

Mapped types関係で事前に定義されている`Required`,`Partial`, `Pick`を活用することで、`ITest`とDtoを次のように関連付けることができます。
また、ジェネリックU型は｜で結ぶことで複数指定することができるので、`TestCreateDto`や`TestUpdateDto`で必要なキーを`ITest`から選択することができます。

`CreateDto`は指定されたキーのみをピックアップし必須パラメータにします。
`UpdateDto`は指定されたキーのみをピックアップし任意パラメータにします。

``` Dto.ts
export type CreateDto<T, U extends keyof T> = Required<Pick<T, U>>;
export type UpdateDto<T, U extends keyof T> = Partial<Pick<T, U>>;
```

``` TestDto.ts
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の入り口までの範囲を１つのインターフェースで関連付けて管理することができるようになります。

``` ApiRequestValidator.ts
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;
};
```

``` ApiRequestValidatorSample.ts
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を適用できないかを考えていきたいと思います。

---
何かお気づきの点がありましたら、コメントよろしくお願いします。
・こうしないと特定のパターンが解決できない
・こういう型を定義したときにうまくできない