Repository: nestjs/swagger
Pull request #3423 (2025-04-30 02:26:15)

feat(swagger-plugin): add skipDefaultValues option to omit unspecified default fields and corresponding test
## PR Checklist
Please check if your PR fulfills the following requirements:

- [x] The commit message follows our guidelines: https://github.com/nestjs/nest/blob/master/CONTRIBUTING.md
- [x] Tests for the changes have been added (for bug fixes / features)
- [ ] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Other... Please describe:

## What is the current behavior?
When using @ApiProperty({ enum: SubjectEnum, enumName: 'Subject' }) readonly __caslSubjectType__: SubjectEnum = this.constructor.name as SubjectEnum;, the generated OpenAPI schema always includes:
```
"__caslSubjectType__": {
  "default": "Function",
  "allOf": [
    { "$ref": "#/components/schemas/Subject" }
  ]
}
```

There is no built-in way to omit that default unless you explicitly set default: null.

Issue Number: #3338 


## What is the new behavior?
Introduce a skipDefaultValues option on the Swagger plugin.

Default: skipDefaultValues: false (existing behavior)

When enabled: any property without an explicit default initializer will not have a default field in the schema.

Example – with skipDefaultValues: true:
```
@ApiProperty({ enum: SubjectEnum, enumName: 'Subject' })
readonly __caslSubjectType__: SubjectEnum = this.constructor.name as SubjectEnum;
```

```
"__caslSubjectType__": {
  "allOf": [
    { "$ref": "#/components/schemas/Subject" }
  ]
}
```


## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No


## Other information
Docs will be added upon positive review and merge of this PR.



Patch:
diff --git a/lib/plugin/merge-options.ts b/lib/plugin/merge-options.ts
index 7ee56906..abfde676 100644
--- a/lib/plugin/merge-options.ts
+++ b/lib/plugin/merge-options.ts
@@ -18,6 +18,10 @@ export interface PluginOptions {
    * Skip auto-annotating controller methods with HTTP status codes (e.g., @HttpCode(201))
    */
   skipAutoHttpCode?: boolean;
+  /**
+   * Skip add default for properties that do not specify default values.
+   */
+  skipDefaultValues?: boolean;
 }
 
 const defaultOptions: PluginOptions = {
@@ -30,7 +34,8 @@ const defaultOptions: PluginOptions = {
   introspectComments: false,
   esmCompatible: false,
   readonly: false,
-  debug: false
+  debug: false,
+  skipDefaultValues: false
 };
 
 export const mergePluginOptions = (
diff --git a/lib/plugin/visitors/model-class.visitor.ts b/lib/plugin/visitors/model-class.visitor.ts
index fe1d6538..6a6c9920 100644
--- a/lib/plugin/visitors/model-class.visitor.ts
+++ b/lib/plugin/visitors/model-class.visitor.ts
@@ -656,6 +656,12 @@ export class ModelClassVisitor extends AbstractFileVisitor {
     options: PluginOptions
   ) {
     const key = 'default';
+    /**
+     * Skip add default for properties that do not specify default values.
+     */
+    if (options.skipDefaultValues) {
+      return undefined;
+    }
     if (hasPropertyKey(key, existingProperties)) {
       return undefined;
     }


Test patch:
diff --git a/test/plugin/fixtures/create-option.ts b/test/plugin/fixtures/create-option.ts
index ea68157f..98e5fa87 100644
--- a/test/plugin/fixtures/create-option.ts
+++ b/test/plugin/fixtures/create-option.ts
@@ -18,7 +18,8 @@ export const mergedCliPluginMultiOption = {
   introspectComments: true,
   esmCompatible: false,
   readonly: false,
-  debug: false
+  debug: false,
+  skipDefaultValues: false
 };
 
 export const mergedCliPluginSingleOption = {
@@ -31,5 +32,6 @@ export const mergedCliPluginSingleOption = {
   introspectComments: true,
   esmCompatible: false,
   readonly: false,
-  debug: false
+  debug: false,
+  skipDefaultValues: false
 };
diff --git a/test/plugin/model-class-visitor.spec.ts b/test/plugin/model-class-visitor.spec.ts
index 6274333c..6c03a8fc 100644
--- a/test/plugin/model-class-visitor.spec.ts
+++ b/test/plugin/model-class-visitor.spec.ts
@@ -306,7 +306,9 @@ describe('API model properties', () => {
         ]
       }
     });
-    expect(esmResult.outputText).toEqual(parameterPropertyDtoTextTranspiled(true));
+    expect(esmResult.outputText).toEqual(
+      parameterPropertyDtoTextTranspiled(true)
+    );
   });
 
   it('should ignore Exclude decorator', () => {
@@ -442,4 +444,57 @@ describe('API model properties', () => {
     });
     expect(result.outputText).toEqual(createCatExclusiveDtoTextTranspiled);
   });
+
+  it('should skip default property when skipDefaultValues is true', () => {
+    const options: ts.CompilerOptions = {
+      module: ts.ModuleKind.ES2020,
+      target: ts.ScriptTarget.ES2020,
+      newLine: ts.NewLineKind.LineFeed,
+      noEmitHelpers: true,
+      experimentalDecorators: true,
+      strict: true
+    };
+    const filename = 'default-value.dto.ts';
+    const fakeProgram = ts.createProgram([filename], options);
+
+    const defaultValueDtoText = `
+      import { ApiProperty } from '../../decorators';
+      export class DefaultValueDto {
+        @ApiProperty()
+        count: number = 5;
+      }
+    `;
+
+    const resultWithoutSkip = ts.transpileModule(defaultValueDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            { introspectComments: true, classValidatorShim: true },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(resultWithoutSkip.outputText).toContain(`default: 5`);
+
+    const resultWithSkip = ts.transpileModule(defaultValueDtoText, {
+      compilerOptions: options,
+      fileName: filename,
+      transformers: {
+        before: [
+          before(
+            {
+              introspectComments: true,
+              classValidatorShim: true,
+              skipDefaultValues: true
+            },
+            fakeProgram
+          )
+        ]
+      }
+    });
+    expect(resultWithSkip.outputText).not.toContain(`default: 5`);
+  });
 });