diff --git a/docs/typescript/statics-and-methods.md b/docs/typescript/statics-and-methods.md index 477b075820..4a8ae02d40 100644 --- a/docs/typescript/statics-and-methods.md +++ b/docs/typescript/statics-and-methods.md @@ -80,3 +80,173 @@ const doc = new User({ name: 'test' }); // Compiles correctly doc.updateName('foo'); ``` + +## Using `loadClass()` with TypeScript + +Mongoose supports applying ES6 classes to a schema using [`schema.loadClass()`](../api/schema.html#Schema.prototype.loadClass()) as an alternative to defining statics and methods in your schema. +When using TypeScript, there are a few important typing details to understand. + +### Basic Usage + +`loadClass()` copies static methods, instance methods, and ES getters/setters from the class onto the schema. + +```ts +class MyClass { + myMethod() { + return 42; + } + + static myStatic() { + return 42; + } + + get myVirtual() { + return 42; + } +} + +const schema = new Schema({ property1: String }); +schema.loadClass(MyClass); +``` + +Mongoose does not automatically update TypeScript types for class members. To get full type support, you must manually define types using Mongoose's [Model](../api/model.html) and [HydratedDocument](../typescript.html) generics. + +```ts +// 1. Define an interface for the raw document data +interface RawDocType { + property1: string; +} + +// 2. Define the Model type +// This includes the raw data, query helpers, instance methods, virtuals, and statics. +type MyCombinedModel = Model< + RawDocType, + {}, + Pick, + Pick +> & Pick; + +// 3. Define the Document type +type MyCombinedDocument = HydratedDocument< + RawDocType, + Pick, + {}, + Pick +>; + +// 4. Create the Mongoose model +const MyModel = model( + 'MyClass', + schema +); + +MyModel.myStatic(); +const doc = new MyModel(); +doc.myMethod(); +doc.myVirtual; +doc.property1; +``` + +### Typing `this` Inside Methods + +You can annotate `this` in methods to enable full safety, using the [Model](../api/model.html) and [HydratedDocument](../typescript.html) types you defined. +Note that this must be done for **each method individually**; it is not possible to set a `this` type for the entire class at once. + +```ts +class MyClass { + // Instance method typed with correct `this` type + myMethod(this: MyCombinedDocument) { + return this.property1; + } + + // Static method typed with correct `this` type + static myStatic(this: MyCombinedModel) { + return 42; + } +} +``` + +### Getters / Setters Limitation + +TypeScript currently does **not** allow `this` parameters on getters/setters: + +```ts +class MyClass { + // error TS2784: 'this' parameters are not allowed in getters + get myVirtual(this: MyCombinedDocument) { + return this.property1; + } +} +``` + +This is a TypeScript limitation. See: [TypeScript issue #52923](https://github.com/microsoft/TypeScript/issues/52923) + +As a workaround, you can cast `this` to the document type inside your getter: + +```ts +get myVirtual() { + // Workaround: cast 'this' to your document type + const self = this as MyCombinedDocument; + return `Name: ${self.property1}`; +} +``` + +### Full Example Code + +```ts +import { Model, Schema, model, HydratedDocument } from 'mongoose'; + +interface RawDocType { + property1: string; +} + +class MyClass { + myMethod(this: MyCombinedDocument) { + return this.property1; + } + + static myStatic(this: MyCombinedModel) { + return 42; + } + + get myVirtual() { + const self = this as MyCombinedDocument; + return `Hello ${self.property1}`; + } +} + +const schema = new Schema({ property1: String }); +schema.loadClass(MyClass); + +type MyCombinedModel = Model< + RawDocType, + {}, + Pick, + Pick +> & Pick; + +type MyCombinedDocument = HydratedDocument< + RawDocType, + Pick, + {}, + Pick +>; + +const MyModel = model( + 'MyClass', + schema +); + +const doc = new MyModel({ property1: 'world' }); +doc.myMethod(); +MyModel.myStatic(); +console.log(doc.myVirtual); +``` + +### When Should I Use `loadClass()`? + +`loadClass()` is useful for defining methods and statics in classes. +If you have a strong preference for classes, you can use `loadClass()`; however, we recommend defining `statics` and `methods` in schema options as described in the first section. + +The major downside of `loadClass()` in TypeScript is that it requires manual TypeScript types. +If you want better type inference, you can use schema options [`methods`](../guide.html#methods) and [`statics`](../guide.html#statics). diff --git a/test/types/loadclass.test.ts b/test/types/loadclass.test.ts new file mode 100644 index 0000000000..90af03764d --- /dev/null +++ b/test/types/loadclass.test.ts @@ -0,0 +1,177 @@ +import { Schema, model, Document, Model, Types } from 'mongoose'; +import { expectType, expectError } from 'tsd'; + + +// Basic usage of `loadClass` with TypeScript +function basicLoadClassPattern() { + class MyClass { + myMethod() { return 42; } + static myStatic() { return 42; } + get myVirtual() { return 42; } + } + + const schema = new Schema({ property1: String }); + schema.loadClass(MyClass); + + interface MySchema { + property1: string; + } + + + // `loadClass()` does NOT update TS types automatically. + // So we must manually combine schema fields + class members. + type MyCombined = MySchema & MyClass; + + // The model type must include statics from the class + type MyCombinedModel = Model & typeof MyClass; + + // A document must combine Mongoose Document + class + schema + type MyCombinedDocument = Document & MyCombined; + + // Cast schema to satisfy TypeScript + const MyModel = model('MyClass', schema as any); + + // Static function should work + expectType(MyModel.myStatic()); + + // Instance method should work + const doc = new MyModel(); + expectType(doc.myMethod()); + + // Getter should work + expectType(doc.myVirtual); + + // Schema property should be typed + expectType(doc.property1); +} + + +// Using `this` typing in class methods + +function thisParameterPattern() { + interface MySchema { + property1: string; + } + + class MyClass { + // Instance method typed with correct `this` type + myMethod(this: MyCombinedDocument) { + return this.property1; + } + + // Static method typed with correct `this` type + static myStatic(this: MyCombinedModel) { + return 42; + } + + + // TypeScript does NOT allow `this` parameters in getters/setters. + // So we show an example error here. + get myVirtual() { + expectError(this.property1); + // @ts-expect-error: getter does not support `this` typing + return this.property1; + } + } + + const schema = new Schema({ property1: String }); + schema.loadClass(MyClass); + + type MyCombined = MySchema & MyClass; + type MyCombinedModel = Model & typeof MyClass; + type MyCombinedDocument = Document & MyCombined; + + const MyModel = model('MyClass2', schema as any); + + // Test static + expectType(MyModel.myStatic()); + + const doc = new MyModel({ property1: 'test' }); + + // Instance method returns string + expectType(doc.myMethod()); + + // Schema field is typed correctly + expectType(doc.property1); + + + // Getter works at runtime, but TypeScript can't type `this` in getters. + // So we accept `any`. + const virtual = doc.myVirtual; + expectType(virtual); +} + + +// ---------------------------------------------------------- +// Test that `toObject()` / `toJSON()` lose class behavior. +// But TypeScript does NOT warn you about this. +// +// This matches the behavior described in issue #12813: +// > doc.toObject().myMethod() compiles but fails at runtime +// ---------------------------------------------------------- +function toObjectToJSONTest() { + class MyClass { + myMethod() { return 42; } + static myStatic() { return 42; } + get myVirtual() { return 42; } + } + + const schema = new Schema({ property1: String }); + schema.loadClass(MyClass); + + interface MySchema { + property1: string; + } + + type MyCombined = MySchema & MyClass; + type MyCombinedModel = Model & typeof MyClass; + type MyCombinedDocument = Document & MyCombined; + + const MyModel = model('MyClass3', schema as any); + + const doc = new MyModel({ property1: 'test' }); + + + // toObject(): + // returns plain object + // loses methods at runtime + // TypeScript still thinks methods exist + const pojo = doc.toObject(); + + // Schema property is still typed + expectType(pojo.property1); + + // TS still thinks class method exists (wrong at runtime) + expectType<() => number>(pojo.myMethod); + + // Same caveat applies to toJSON() + const json = doc.toJSON(); + + expectType<() => number>(json.myMethod); + expectType(json.property1); +} + + +// Getter limitation example +// TypeScript does not allow `this` param on getters + +function getterLimitationTest() { + interface MySchema { + name: string; + } + + class TestGetter { + name: string; + + // TS errors if you try `this` in getter + // @ts-expect-error TS2784: 'this' parameters are not allowed in getters + get test(this: TestDoc): string { + return this.name; + } + } + + interface TestDoc extends TestGetter, Omit { + _id: Types.ObjectId; + } +} +