Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
ce4bce2
docs: add detailed loadClass() TypeScript usage guide and new loadcla…
Necro-Rohan Nov 9, 2025
84a65d5
Update docs/typescript/statics-and-methods.md
Necro-Rohan Nov 10, 2025
2bb9e78
Update link text for typescript issue #52923
Necro-Rohan Nov 10, 2025
e1b0c45
1st changes according to suggestion
Necro-Rohan Nov 10, 2025
12f3d24
2st changes in loadclass doc according to suggestion
Necro-Rohan Nov 10, 2025
6df877c
fixed markdownlint errors
Necro-Rohan Nov 10, 2025
108230b
fixed markdownlint rule MD047 error
Necro-Rohan Nov 10, 2025
e7dd86a
removed horizontal rule's
Necro-Rohan Nov 10, 2025
80d60e6
Update api link path
Necro-Rohan Nov 10, 2025
ce7898f
Updating api link path for methods and statics
Necro-Rohan Nov 10, 2025
01e9e92
removed all Horizontal Rule's (<hr>) below the header for better ui a…
Necro-Rohan Nov 10, 2025
9a6b83f
adding expectError as suggested
Necro-Rohan Nov 10, 2025
c2c737c
fixed the error of importing expectError
Necro-Rohan Nov 10, 2025
79d1730
fixed inconsistent capitalization issue
Necro-Rohan Nov 11, 2025
486eea7
added bullet points to section "why should i use loadclass()"
Necro-Rohan Nov 11, 2025
5d372f9
fixed grammetical error in the "issue"
Necro-Rohan Nov 11, 2025
35c9ae8
Refactor loadClass types using HydratedDocument and Renames MySchema …
Necro-Rohan Nov 11, 2025
00d4fb2
correcting the limitation of Methods preserved in toObject() / toJSON()
Necro-Rohan Nov 11, 2025
1a94f96
Fixed static this type and removed inaccurate bullet
Necro-Rohan Nov 11, 2025
e8d0043
Refine loadClass() TS guide based on feedback by Removing redundant t…
Necro-Rohan Nov 15, 2025
ab18984
fixed heading 'Getter / Setter Limitation' to maintain consistancy
Necro-Rohan Nov 15, 2025
013cbbb
Update TypeScript documentation for loadClass() usage
vkarpov15 Nov 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions docs/typescript/statics-and-methods.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,203 @@ 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()).
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<MyClass, 'myMethod'>,
Pick<MyClass, 'myVirtual'>
> & Pick<typeof MyClass, 'myStatic'>;

// 3. Define the Document type
type MyCombinedDocument = HydratedDocument<
RawDocType,
Pick<MyClass, 'myMethod'>,
{},
Pick<MyClass, 'myVirtual'>
>;

// 4. Create the Mongoose model
const MyModel = model<RawDocType, MyCombinedModel>(
'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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to set the type of this in methods and statics? In my brief experiment it seems like Mongoose sets these correctly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've double-checked this, and you're absolutely right — Mongoose correctly handles the this context at runtime. However, the explicit this annotation (e.g., this: MyCombinedDocument) is still important for TypeScript during compile time. Without it, TypeScript has no way to infer that this.property1 is valid inside the class, since property1 isn’t actually defined on MyClass itself.
So I think it’s best to keep this section in place, as it provides important context and maintains type safety.
Thanks again for pointing this out!

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}`;
}
```

Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description mentions documenting the "lean" caveat - that toObject(), toJSON(), or .lean() return plain objects without class methods, leading to potential runtime errors. However, this limitation is not documented in this section. Consider adding a subsection (e.g., "### Lean Documents and Plain Objects") after the "Getters / Setters Limitation" section to warn users about this caveat, as it's demonstrated in the test file (toObjectToJSONTest function).

Suggested change
### Lean Documents and Plain Objects
When you use `.lean()`, `.toObject()`, or `.toJSON()` on a Mongoose document, the returned value is a plain JavaScript object. This object does **not** have any of your class methods, statics, or virtuals attached. Attempting to call a class method or access a virtual on such an object will result in a runtime error.
For example:
```ts
const doc = await MyModel.findOne().exec();
const plain = doc.toObject();
// This will throw an error, because 'plain' does not have 'myMethod'
plain.myMethod(); // TypeError: plain.myMethod is not a function
// Similarly, virtuals are not available
console.log(plain.myVirtual); // undefined

If you need to use class methods or virtuals, always work with the hydrated Mongoose document, not the plain object returned by these methods.

Copilot uses AI. Check for mistakes.
## `toObject()` / `toJSON()`

When using the correct [Model](../api/model.html) and [HydratedDocument](../typescript.html) generics (as shown above), Mongoose's types for `toObject()` and `toJSON()` work as expected. They will correctly return a type that includes **only the raw data** (e.g., `RawDocType`), not the methods or virtuals.

This is a feature, as it accurately reflects the plain object returned at runtime and prevents you from trying to access methods that don't exist.

```ts
const doc = new MyModel({ property1: 'test' });
const pojo = doc.toObject();

// This now correctly causes a TypeScript error!
// pojo.myMethod(); // Property 'myMethod' does not exist on type 'RawDocType'.
```

## Limitations

| Behavior | Supported |
| ---------------------------------------------- | --------- |
| Copy instance / static methods | ✅ |
| Copy getters/setters | ✅ |
| Automatic TS merging | ❌ |
| `this` typing in methods | ✅ |
| `this` typing in getters/setters | ❌ |
| Methods preserved in `toObject()` / `toJSON()` | ❌ |
| Methods preserved with `.lean()` | ❌ |

## 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() {
return 42;
}

get myVirtual() {
const self = this as MyCombinedDocument;
return `Hello ${self.property1}`;
}
}

const schema = new Schema<RawDocType>({ property1: String });
schema.loadClass(MyClass);

type MyCombinedModel = Model<
RawDocType,
{},
Pick<MyClass, 'myMethod'>,
Pick<MyClass, 'myVirtual'>
> & Pick<typeof MyClass, 'myStatic'>;

type MyCombinedDocument = HydratedDocument<
RawDocType,
Pick<MyClass, 'myMethod'>,
{},
Pick<MyClass, 'myVirtual'>
>;

const MyModel = model<RawDocType, MyCombinedModel>(
'MyClass',
schema
);

const doc = new MyModel({ property1: 'world' });
doc.myMethod();
MyModel.myStatic();
console.log(doc.myVirtual);
```

## When Should I Use `loadClass()`?

`loadClass()` is useful when organizing logic in ES6 classes.

However:

* ✅ works fine
* ⚠ requires manual TS merging
* ⚠ methods lost in `toObject()` / `toJSON()` / `lean()`

If you want better type inference, [`methods`](../guide.html#methods) & [`statics`](../guide.html#statics) on schema are recommended.
177 changes: 177 additions & 0 deletions test/types/loadclass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { Schema, model, Document, Model, Types } from 'mongoose';
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import statement is missing HydratedDocument which is used in the documentation examples and recommended for typing documents with loadClass(). Consider adding HydratedDocument to the imports for consistency with the documentation.

Suggested change
import { Schema, model, Document, Model, Types } from 'mongoose';
import { Schema, model, Document, Model, Types, HydratedDocument } from 'mongoose';

Copilot uses AI. Check for mistakes.
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<MyCombined> & typeof MyClass;

// A document must combine Mongoose Document + class + schema
type MyCombinedDocument = Document & MyCombined;
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test uses Document type instead of HydratedDocument which is recommended in the documentation and used in other test files. Consider using HydratedDocument for consistency with the documentation examples and modern Mongoose TypeScript patterns. HydratedDocument provides better type inference for virtuals and methods.

Copilot uses AI. Check for mistakes.

Comment on lines +16 to +30
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file demonstrates a simpler typing pattern (Model<MyCombined> & typeof MyClass) that differs significantly from the recommended pattern in the documentation (Model<RawDocType, {}, Pick<MyClass, 'myMethod'>, Pick<MyClass, 'myVirtual'>> & Pick<typeof MyClass, 'myStatic'>).

The test should demonstrate the same pattern shown in the documentation for consistency. The documentation's pattern is more precise because it properly separates raw document types, query helpers, instance methods, and virtuals as distinct generic parameters.

Suggested change
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<MyCombined> & typeof MyClass;
// A document must combine Mongoose Document + class + schema
type MyCombinedDocument = Document & MyCombined;
// Raw document type (schema fields)
interface MySchema {
property1: string;
}
// Instance methods
type MyInstanceMethods = Pick<MyClass, 'myMethod'>;
// Virtuals
type MyVirtuals = Pick<MyClass, 'myVirtual'>;
// Statics
type MyStatics = Pick<typeof MyClass, 'myStatic'>;
// The model type using the recommended pattern
type MyCombinedModel = Model<MySchema, {}, MyInstanceMethods, MyVirtuals> & MyStatics;
// A document must combine Mongoose Document + schema + instance methods + virtuals
type MyCombinedDocument = Document<MySchema> & MySchema & MyInstanceMethods & MyVirtuals;

Copilot uses AI. Check for mistakes.
// Cast schema to satisfy TypeScript
const MyModel = model<MyCombinedDocument, MyCombinedModel>('MyClass', schema as any);

// Static function should work
expectType<number>(MyModel.myStatic());

// Instance method should work
const doc = new MyModel();
expectType<number>(doc.myMethod());

// Getter should work
expectType<number>(doc.myVirtual);

// Schema property should be typed
expectType<string>(doc.property1);
}
Comment on lines +6 to +46
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused function basicLoadClassPattern.

Suggested change
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<MyCombined> & typeof MyClass;
// A document must combine Mongoose Document + class + schema
type MyCombinedDocument = Document & MyCombined;
// Cast schema to satisfy TypeScript
const MyModel = model<MyCombinedDocument, MyCombinedModel>('MyClass', schema as any);
// Static function should work
expectType<number>(MyModel.myStatic());
// Instance method should work
const doc = new MyModel();
expectType<number>(doc.myMethod());
// Getter should work
expectType<number>(doc.myVirtual);
// Schema property should be typed
expectType<string>(doc.property1);
}
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<MyCombined> & typeof MyClass;
// A document must combine Mongoose Document + class + schema
type MyCombinedDocument = Document & MyCombined;
// Cast schema to satisfy TypeScript
const MyModel = model<MyCombinedDocument, MyCombinedModel>('MyClass', schema as any);
// Static function should work
expectType<number>(MyModel.myStatic());
// Instance method should work
const doc = new MyModel();
expectType<number>(doc.myMethod());
// Getter should work
expectType<number>(doc.myVirtual);
// Schema property should be typed
expectType<string>(doc.property1);

Copilot uses AI. Check for mistakes.


// 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<MySchema>({ property1: String });
schema.loadClass(MyClass);

type MyCombined = MySchema & MyClass;
type MyCombinedModel = Model<MyCombined> & typeof MyClass;
type MyCombinedDocument = Document & MyCombined;
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the earlier test, this uses Document instead of HydratedDocument. For consistency with the documentation and other test files, consider using HydratedDocument.

Copilot uses AI. Check for mistakes.

Comment on lines +80 to +83
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to the first test function, this test uses the simpler Model<MyCombined> & typeof MyClass pattern instead of the more precise Model generics pattern shown in the documentation. For consistency with the documentation examples, this should use the same typing approach with Pick utility types to properly separate instance methods, virtuals, and statics.

Suggested change
type MyCombined = MySchema & MyClass;
type MyCombinedModel = Model<MyCombined> & typeof MyClass;
type MyCombinedDocument = Document & MyCombined;
// Separate instance methods, statics, and virtuals using Pick
type InstanceMethods = Pick<MyClass, 'myMethod'>;
type Statics = Pick<typeof MyClass, 'myStatic'>;
type Virtuals = Pick<MyClass, 'myVirtual'>;
type MyCombinedDocument = Document & MySchema & InstanceMethods & Virtuals;
type MyCombinedModel = Model<MyCombinedDocument> & Statics;

Copilot uses AI. Check for mistakes.
const MyModel = model<MyCombinedDocument, MyCombinedModel>('MyClass2', schema as any);

// Test static
expectType<number>(MyModel.myStatic());

const doc = new MyModel({ property1: 'test' });

// Instance method returns string
expectType<string>(doc.myMethod());

// Schema field is typed correctly
expectType<string>(doc.property1);


// Getter works at runtime, but TypeScript can't type `this` in getters.
// So we accept `any`.
const virtual = doc.myVirtual;
expectType<any>(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<MyCombined> & typeof MyClass;
type MyCombinedDocument = Document & MyCombined;
Comment on lines +127 to +128
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test also uses the simpler typing pattern instead of the more precise pattern shown in the documentation. For consistency, all test functions should use the same Model generics pattern with Pick utility types as demonstrated in the documentation.

Suggested change
type MyCombinedModel = Model<MyCombined> & typeof MyClass;
type MyCombinedDocument = Document & MyCombined;
type MyCombinedModel = Model<Pick<MyCombined, keyof MySchema>> & typeof MyClass;
type MyCombinedDocument = Document & Pick<MyCombined, keyof MySchema>;

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test also uses Document instead of HydratedDocument. For consistency with the documentation and other test files, consider using HydratedDocument.

Copilot uses AI. Check for mistakes.

const MyModel = model<MyCombinedDocument, MyCombinedModel>('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<string>(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<string>(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<Document, '_id'> {
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test uses Omit<Document, '_id'> instead of HydratedDocument. For consistency with the documentation and other test files, consider using HydratedDocument.

Copilot uses AI. Check for mistakes.
_id: Types.ObjectId;
}
}

Comment on lines +158 to +177
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused function getterLimitationTest.

Suggested change
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<Document, '_id'> {
_id: Types.ObjectId;
}
}

Copilot uses AI. Check for mistakes.