Skip to content

Commit

Permalink
Merge pull request #14542 from Automattic/vkarpov15/gh-14286
Browse files Browse the repository at this point in the history
docs: de-emphasize `InferSchemaType<>` in TypeScript docs in favor of automatic inference
  • Loading branch information
vkarpov15 authored Apr 29, 2024
2 parents 434dac8 + d5bd91d commit 72da808
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 89 deletions.
155 changes: 89 additions & 66 deletions docs/typescript/schemas.md
Original file line number Diff line number Diff line change
@@ -1,89 +1,98 @@
# Schemas in TypeScript

Mongoose [schemas](../guide.html) are how you tell Mongoose what your documents look like.
Mongoose schemas are separate from TypeScript interfaces, so you need to either define both a *document interface* and a *schema*; or rely on Mongoose to automatically infer the type from the schema definition.
Mongoose schemas are separate from TypeScript interfaces, so you need to either define both a *raw document interface* and a *schema*; or rely on Mongoose to automatically infer the type from the schema definition.

## Separate document interface definition
## Automatic type inference

Mongoose can automatically infer the document type from your schema definition as follows.
We recommend relying on automatic type inference when defining schemas and models.

```typescript
import { Schema } from 'mongoose';

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

// Schema
const schema = new Schema<User>({
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
```

By default, Mongoose does **not** check if your document interface lines up with your schema.
For example, the above code won't throw an error if `email` is optional in the document interface, but `required` in `schema`.
There are a few caveats for using automatic type inference:

## Automatic type inference
1. You need to set `strictNullChecks: true` or `strict: true` in your `tsconfig.json`. Or, if you're setting flags at the command line, `--strictNullChecks` or `--strict`. There are [known issues](https://github.com/Automattic/mongoose/issues/12420) with automatic type inference with strict mode disabled.
2. You need to define your schema in the `new Schema()` call. Don't assign your schema definition to a temporary variable. Doing something like `const schemaDefinition = { name: String }; const schema = new Schema(schemaDefinition);` will not work.
3. Mongoose adds `createdAt` and `updatedAt` to your schema if you specify the `timestamps` option in your schema, *except* if you also specify `methods`, `virtuals`, or `statics`. There is a [known issue](https://github.com/Automattic/mongoose/issues/12807) with type inference with timestamps and methods/virtuals/statics options. If you use methods, virtuals, and statics, you're responsible for adding `createdAt` and `updatedAt` to your schema definition.

If automatic type inference doesn't work for you, you can always fall back to document interface definitions.

Mongoose can also automatically infer the document type from your schema definition as follows.
## Separate document interface definition

If automatic type inference doesn't work for you, you can define a separate raw document interface as follows.

```typescript
import { Schema, InferSchemaType } from 'mongoose';
import { Schema } from 'mongoose';

// Document interface
// No need to define TS interface any more.
// interface User {
// name: string;
// email: string;
// avatar?: string;
// }
// 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({
const schema = new Schema<User>({
name: { type: String, required: true },
email: { type: String, required: true },
avatar: String
});

type User = InferSchemaType<typeof schema>;
// InferSchemaType will determine the type as follows:
// type User = {
// name: string;
// email: string;
// avatar?: string;
// }

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

There are a few caveats for using automatic type inference:

1. You need to set `strictNullChecks: true` or `strict: true` in your `tsconfig.json`. Or, if you're setting flags at the command line, `--strictNullChecks` or `--strict`. There are [known issues](https://github.com/Automattic/mongoose/issues/12420) with automatic type inference with strict mode disabled.
2. You need to define your schema in the `new Schema()` call. Don't assign your schema definition to a temporary variable. Doing something like `const schemaDefinition = { name: String }; const schema = new Schema(schemaDefinition);` will not work.
3. Mongoose adds `createdAt` and `updatedAt` to your schema if you specify the `timestamps` option in your schema, *except* if you also specify `methods`, `virtuals`, or `statics`. There is a [known issue](https://github.com/Automattic/mongoose/issues/12807) with type inference with timestamps and methods/virtuals/statics options. If you use methods, virtuals, and statics, you're responsible for adding `createdAt` and `updatedAt` to your schema definition.

If automatic type inference doesn't work for you, you can always fall back to document interface definitions.
By default, Mongoose does **not** check if your raw document interface lines up with your schema.
For example, the above code won't throw an error if `email` is optional in the document interface, but `required` in `schema`.

## Generic parameters

The Mongoose `Schema` class in TypeScript has 4 [generic parameters](https://www.typescriptlang.org/docs/handbook/2/generics.html):
The Mongoose `Schema` class in TypeScript has 9 [generic parameters](https://www.typescriptlang.org/docs/handbook/2/generics.html):

* `DocType` - An interface describing how the data is saved in MongoDB
* `M` - The Mongoose model type. Can be omitted if there are no query helpers or instance methods to be defined.
* `RawDocType` - An interface describing how the data is saved in MongoDB
* `TModelType` - The Mongoose model type. Can be omitted if there are no query helpers or instance methods to be defined.
* default: `Model<DocType, any, any>`
* `TInstanceMethods` - An interface containing the methods for the schema.
* default: `{}`
* `TQueryHelpers` - An interface containing query helpers defined on the schema. Defaults to `{}`.
* `TVirtuals` - An interface containing virtuals defined on the schema. Defaults to `{}`
* `TStaticMethods` - An interface containing methods on a model. Defaults to `{}`
* `TSchemaOptions` - The type passed as the 2nd option to `Schema()` constructor. Defaults to `DefaultSchemaOptions`.
* `DocType` - The inferred document type from the schema.
* `THydratedDocumentType` - The hydrated document type. This is the default return type for `await Model.findOne()`, `Model.hydrate()`, etc.

<details>
<summary>View TypeScript definition</summary>

```typescript
class Schema<DocType = any, M = Model<DocType, any, any>, TInstanceMethods = {}, TQueryHelpers = {}> extends events.EventEmitter {
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 {
// ...
}
```
Expand Down Expand Up @@ -154,33 +163,47 @@ This is because Mongoose has numerous features that add paths to your schema tha

## Arrays

When you define an array in a document interface, we recommend using Mongoose's `Types.Array` type for primitive arrays or `Types.DocumentArray` for arrays of documents.
When you define an array in a document interface, we recommend using vanilla JavaScript arrays, **not** Mongoose's `Types.Array` type or `Types.DocumentArray` type.
Instead, use the `THydratedDocumentType` generic to define that the hydrated document type has paths of type `Types.Array` and `Types.DocumentArray`.

```typescript
import { Schema, Model, Types } from 'mongoose';
import mongoose from 'mongoose'
const { Schema } = mongoose;

interface BlogPost {
_id: Types.ObjectId;
title: string;
interface IOrder {
tags: Array<{ name: string }>
}

interface User {
tags: Types.Array<string>;
blogPosts: Types.DocumentArray<BlogPost>;
}

const schema = new Schema<User, Model<User>>({
tags: [String],
blogPosts: [{ title: 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.Types.DocumentArray<{ name: string }> }
>;
type OrderModelType = mongoose.Model<
IOrder,
{},
{},
{},
OrderHydratedDocument
>;

const orderSchema = new mongoose.Schema<IOrder, OrderModelType>({
tags: [{ name: { type: String, required: true } }]
});
```
const OrderModel = mongoose.model<IOrder, OrderModelType>('Order', orderSchema);

Using `Types.DocumentArray` is helpful when dealing with defaults.
For example, `BlogPost` has an `_id` property that Mongoose will set by default.
If you use `Types.DocumentArray` in the above case, you'll be able to `push()` a subdocument without an `_id`.
// Demonstrating return types from OrderModel
const doc = new OrderModel({ tags: [{ name: 'test' }] });

```typescript
const user = new User({ blogPosts: [] });
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 }>

user.blogPosts.push({ title: 'test' }); // Would not work if you did `blogPosts: BlogPost[]`
const leanDoc = await OrderModel.findOne().orFail().lean();
leanDoc.tags; // Array<{ name: string }>
};
```
28 changes: 14 additions & 14 deletions docs/typescript/subdocuments.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,23 +34,23 @@ doc.names.ownerDocument();
```

Mongoose provides a mechanism to override types in the hydrated document.
The 3rd generic param to the `Model<>` is called `TMethodsAndOverrides`: originally it was just used to define methods, but you can also use it to override types as shown below.
Define a separate `THydratedDocumentType` and pass it as the 5th generic param to `mongoose.Model<>`.
`THydratedDocumentType` controls what type Mongoose uses for "hydrated documents", that is, what `await UserModel.findOne()`, `UserModel.hydrate()`, and `new UserModel()` return.

```ts
// Define property overrides for hydrated documents
type UserDocumentOverrides = {
names: Types.Subdocument<Types.ObjectId> & Names;
};
type UserModelType = Model<User, {}, UserDocumentOverrides>;
type THydratedUserDocument = {
names?: mongoose.Types.Subdocument<Names>
}
type UserModelType = mongoose.Model<User, {}, {}, {}, THydratedUserDocument>;

const userSchema = new Schema<User, UserModelType>({
names: new Schema<Names>({ firstName: String })
const userSchema = new mongoose.Schema<User, UserModelType>({
names: new mongoose.Schema<Names>({ firstName: String })
});
const UserModel = model<User, UserModelType>('User', userSchema);

const UserModel = mongoose.model<User, UserModelType>('User', userSchema);

const doc = new UserModel({ names: { _id: '0'.repeat(24), firstName: 'foo' } });
doc.names.ownerDocument(); // Works, `names` is a subdocument!
doc.names!.ownerDocument(); // Works, `names` is a subdocument!
```

## Subdocument Arrays
Expand All @@ -69,10 +69,10 @@ interface User {
}

// TMethodsAndOverrides
type UserDocumentProps = {
names: Types.DocumentArray<Names>;
};
type UserModelType = Model<User, {}, UserDocumentProps>;
type THydratedUserDocument = {
names?: Types.DocumentArray<Names>
}
type UserModelType = Model<User, {}, {}, {}, THydratedUserDocument>;

// Create model
const UserModel = model<User, UserModelType>('User', new Schema<User, UserModelType>({
Expand Down
2 changes: 1 addition & 1 deletion docs/typescript/virtuals.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const schema = new Schema(
```

Note that Mongoose does **not** include virtuals in the returned type from `InferSchemaType`.
That is because `InferSchemaType` returns the "raw" document interface, which represents the structure of the data stored in MongoDB.
That is because `InferSchemaType` returns a value similar to the raw document interface, which represents the structure of the data stored in MongoDB.

```ts
type User = InferSchemaType<typeof schema>;
Expand Down
16 changes: 8 additions & 8 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,18 +217,18 @@ declare module 'mongoose' {
TStaticMethods> = (schema: Schema<DocType, M, TInstanceMethods, TQueryHelpers, TVirtuals, TStaticMethods>, opts?: any) => void;

export class Schema<
EnforcedDocType = any,
TModelType = Model<EnforcedDocType, any, any, any>,
RawDocType = any,
TModelType = Model<RawDocType, any, any, any>,
TInstanceMethods = {},
TQueryHelpers = {},
TVirtuals = {},
TStaticMethods = {},
TSchemaOptions = DefaultSchemaOptions,
DocType extends ApplySchemaOptions<
ObtainDocumentType<DocType, EnforcedDocType, ResolveSchemaOptions<TSchemaOptions>>,
ObtainDocumentType<DocType, RawDocType, ResolveSchemaOptions<TSchemaOptions>>,
ResolveSchemaOptions<TSchemaOptions>
> = ApplySchemaOptions<
ObtainDocumentType<any, EnforcedDocType, ResolveSchemaOptions<TSchemaOptions>>,
ObtainDocumentType<any, RawDocType, ResolveSchemaOptions<TSchemaOptions>>,
ResolveSchemaOptions<TSchemaOptions>
>,
THydratedDocumentType = HydratedDocument<FlatRecord<DocType>, TVirtuals & TInstanceMethods>
Expand All @@ -237,10 +237,10 @@ declare module 'mongoose' {
/**
* Create a new schema
*/
constructor(definition?: SchemaDefinition<SchemaDefinitionType<EnforcedDocType>, EnforcedDocType> | DocType, options?: SchemaOptions<FlatRecord<DocType>, TInstanceMethods, TQueryHelpers, TStaticMethods, TVirtuals, THydratedDocumentType> | ResolveSchemaOptions<TSchemaOptions>);
constructor(definition?: SchemaDefinition<SchemaDefinitionType<RawDocType>, RawDocType> | DocType, options?: SchemaOptions<FlatRecord<DocType>, TInstanceMethods, TQueryHelpers, TStaticMethods, TVirtuals, THydratedDocumentType> | ResolveSchemaOptions<TSchemaOptions>);

/** Adds key path / schema type pairs to this schema. */
add(obj: SchemaDefinition<SchemaDefinitionType<EnforcedDocType>> | Schema, prefix?: string): this;
add(obj: SchemaDefinition<SchemaDefinitionType<RawDocType>> | Schema, prefix?: string): this;

/**
* Add an alias for `path`. This means getting or setting the `alias`
Expand Down Expand Up @@ -308,14 +308,14 @@ declare module 'mongoose' {
methods: AddThisParameter<TInstanceMethods, THydratedDocumentType> & AnyObject;

/** The original object passed to the schema constructor */
obj: SchemaDefinition<SchemaDefinitionType<EnforcedDocType>, EnforcedDocType>;
obj: SchemaDefinition<SchemaDefinitionType<RawDocType>, RawDocType>;

/** Returns a new schema that has the `paths` from the original schema, minus the omitted ones. */
omit<T = this>(paths: string[], options?: SchemaOptions): T;

/** Gets/sets schema paths. */
path<ResultType extends SchemaType = SchemaType<any, THydratedDocumentType>>(path: string): ResultType;
path<pathGeneric extends keyof EnforcedDocType>(path: pathGeneric): SchemaType<EnforcedDocType[pathGeneric]>;
path<pathGeneric extends keyof RawDocType>(path: pathGeneric): SchemaType<RawDocType[pathGeneric]>;
path(path: string, constructor: any): this;

/** Lists all paths and their type in the schema. */
Expand Down

0 comments on commit 72da808

Please sign in to comment.