3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-08-16

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

本記事で利用しているサンプルコードはGithubにて公開しています。

修正

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

問題意識

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

例えば、下記の定義方法だとレコードの情報をITestDocumentTestModeltestSchemaの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を確認するとtestSchemaSchemaDefinition型と言えます。

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を定義することで、この問題を解決することができます。
そして、DocumenttestSchema(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で定義しているSchemaDefinitionMongooseSchemaDefinition<T>に、SchemaTypeOpts<any> | Schema | SchemaTypeSchemaDefinitionValueTypeに対応させています。

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

MongooseSchemaDefinitionの解説

MongooseSchemaDefinition_T_.png

  • 条件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を利用して、最初に示したITestDocumentTestModelを関連付けます。
このとき、直接DocumentSchemaを紐づけるわけではなく、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)
);

このように定義することで、ITestDocumentTestModeltestSchemaITestを介して型の関連付けが行われます。
たとえば、ITest{ flag: boolean }を追加した場合、MongooseSchemaDefinitionのほうで、flagが定義されていないとエラーを表示するようになります。
2020-08-07.png

ITestから{ testEnum: TestEnum }を削除した場合、余計な定義があるとエラーを表示するようになります。
2020-08-07 (1).png

番外編1 DTO

Mapped types関係で事前に定義されているRequired,Partial, Pickを活用することで、ITestとDtoを次のように関連付けることができます。
また、ジェネリックU型は|で結ぶことで複数指定することができるので、TestCreateDtoTestUpdateDtoで必要なキーを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の入り口までの範囲を1つのインターフェースで関連付けて管理することができるようになります。

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


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

3
0
0

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
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?