Repository: nestjs/swagger
Pull request #2863 (2024-02-29 16:41:15)

feat: add skip null properties option to partial type
## 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)
- [x] Docs have been added / updated (for bug fixes / features)


## PR Type
What kind of change does this PR introduce?

<!-- Please check the one that applies to this PR using "x". -->
- [ ] 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?

`PartialType` will return a class for which validations are ignored `null` properties (in addition to `undefined` properties).

Issue Number: [2843](https://github.com/nestjs/swagger/issues/2843), [2623](https://github.com/nestjs/swagger/issues/2623)

## What is the new behaviour?
`PartialType` can optionally return a class for which validations are ignored only for `undefined` properties, not `null` properties. This can be useful if you are creating a DTO for a PATCH endpoint: there may be a certain field, say `name`, that should not be `null`, but which doesn't need to be included in PATCH updates.

## Does this PR introduce a breaking change?
- [ ] Yes
- [x] No


## Other information
This is actually just a copy-paste of the same PR to mapped-types repo: https://github.com/nestjs/mapped-types/pull/1274
Thanks to @tf3 


Patch:
diff --git a/lib/type-helpers/partial-type.helper.ts b/lib/type-helpers/partial-type.helper.ts
index 1872b8ad..d6e62685 100644
--- a/lib/type-helpers/partial-type.helper.ts
+++ b/lib/type-helpers/partial-type.helper.ts
@@ -1,6 +1,7 @@
 import { Type } from '@nestjs/common';
 import {
   applyIsOptionalDecorator,
+  applyValidateIfDefinedDecorator,
   inheritPropertyInitializers,
   inheritTransformationMetadata,
   inheritValidationMetadata
@@ -15,7 +16,25 @@ import { clonePluginMetadataFactory } from './mapped-types.utils';
 
 const modelPropertiesAccessor = new ModelPropertiesAccessor();
 
-export function PartialType<T>(classRef: Type<T>): Type<Partial<T>> {
+export function PartialType<T>(
+  classRef: Type<T>,
+  /**
+   *  Configuration options.
+   */
+  options: {
+    /**
+     * If true, validations will be ignored on a property if it is either null or undefined. If
+     * false, validations will be ignored only if the property is undefined.
+     * @default true
+     */
+    skipNullProperties?: boolean;
+  } = {}
+): Type<Partial<T>> {
+  const applyPartialDecoratorFn =
+    options.skipNullProperties === false
+      ? applyValidateIfDefinedDecorator
+      : applyIsOptionalDecorator;
+
   const fields = modelPropertiesAccessor.getModelProperties(classRef.prototype);
 
   abstract class PartialTypeClass {
@@ -30,7 +49,7 @@ export function PartialType<T>(classRef: Type<T>): Type<Partial<T>> {
   if (keysWithValidationConstraints) {
     keysWithValidationConstraints
       .filter((key) => !fields.includes(key))
-      .forEach((key) => applyIsOptionalDecorator(PartialTypeClass, key));
+      .forEach((key) => applyPartialDecoratorFn(PartialTypeClass, key));
   }
 
   inheritTransformationMetadata(classRef, PartialTypeClass);
@@ -48,7 +67,7 @@ export function PartialType<T>(classRef: Type<T>): Type<Partial<T>> {
         PartialTypeClass[METADATA_FACTORY_NAME]()
       );
       pluginFields.forEach((key) =>
-        applyIsOptionalDecorator(PartialTypeClass, key)
+        applyPartialDecoratorFn(PartialTypeClass, key)
       );
     }
 
@@ -65,7 +84,7 @@ export function PartialType<T>(classRef: Type<T>): Type<Partial<T>> {
         required: false
       });
       decoratorFactory(PartialTypeClass.prototype, key);
-      applyIsOptionalDecorator(PartialTypeClass, key);
+      applyPartialDecoratorFn(PartialTypeClass, key);
     });
   }
   applyFields(fields);
diff --git a/package-lock.json b/package-lock.json
index a8fd5ec3..a0ebfd4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -35,7 +35,7 @@
         "eslint": "8.57.0",
         "eslint-config-prettier": "9.1.0",
         "eslint-plugin-import": "2.29.1",
-        "express": "4.18.3",
+        "express": "4.18.2",
         "husky": "9.0.11",
         "jest": "29.7.0",
         "lint-staged": "15.2.2",
@@ -2345,108 +2345,6 @@
         "@nestjs/core": "^10.0.0"
       }
     },
-    "node_modules/@nestjs/platform-express/node_modules/debug": {
-      "version": "2.6.9",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
-      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
-      "dev": true,
-      "dependencies": {
-        "ms": "2.0.0"
-      }
-    },
-    "node_modules/@nestjs/platform-express/node_modules/express": {
-      "version": "4.18.2",
-      "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
-      "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
-      "dev": true,
-      "dependencies": {
-        "accepts": "~1.3.8",
-        "array-flatten": "1.1.1",
-        "body-parser": "1.20.1",
-        "content-disposition": "0.5.4",
-        "content-type": "~1.0.4",
-        "cookie": "0.5.0",
-        "cookie-signature": "1.0.6",
-        "debug": "2.6.9",
-        "depd": "2.0.0",
-        "encodeurl": "~1.0.2",
-        "escape-html": "~1.0.3",
-        "etag": "~1.8.1",
-        "finalhandler": "1.2.0",
-        "fresh": "0.5.2",
-        "http-errors": "2.0.0",
-        "merge-descriptors": "1.0.1",
-        "methods": "~1.1.2",
-        "on-finished": "2.4.1",
-        "parseurl": "~1.3.3",
-        "path-to-regexp": "0.1.7",
-        "proxy-addr": "~2.0.7",
-        "qs": "6.11.0",
-        "range-parser": "~1.2.1",
-        "safe-buffer": "5.2.1",
-        "send": "0.18.0",
-        "serve-static": "1.15.0",
-        "setprototypeof": "1.2.0",
-        "statuses": "2.0.1",
-        "type-is": "~1.6.18",
-        "utils-merge": "1.0.1",
-        "vary": "~1.1.2"
-      },
-      "engines": {
-        "node": ">= 0.10.0"
-      }
-    },
-    "node_modules/@nestjs/platform-express/node_modules/express/node_modules/body-parser": {
-      "version": "1.20.1",
-      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
-      "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
-      "dev": true,
-      "dependencies": {
-        "bytes": "3.1.2",
-        "content-type": "~1.0.4",
-        "debug": "2.6.9",
-        "depd": "2.0.0",
-        "destroy": "1.2.0",
-        "http-errors": "2.0.0",
-        "iconv-lite": "0.4.24",
-        "on-finished": "2.4.1",
-        "qs": "6.11.0",
-        "raw-body": "2.5.1",
-        "type-is": "~1.6.18",
-        "unpipe": "1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8",
-        "npm": "1.2.8000 || >= 1.4.16"
-      }
-    },
-    "node_modules/@nestjs/platform-express/node_modules/ms": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
-      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
-      "dev": true
-    },
-    "node_modules/@nestjs/platform-express/node_modules/path-to-regexp": {
-      "version": "0.1.7",
-      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
-      "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
-      "dev": true
-    },
-    "node_modules/@nestjs/platform-express/node_modules/raw-body": {
-      "version": "2.5.1",
-      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
-      "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
-      "dev": true,
-      "dependencies": {
-        "bytes": "3.1.2",
-        "http-errors": "2.0.0",
-        "iconv-lite": "0.4.24",
-        "unpipe": "1.0.0"
-      },
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
     "node_modules/@nestjs/platform-fastify": {
       "version": "10.3.3",
       "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-10.3.3.tgz",
@@ -5731,14 +5629,14 @@
       }
     },
     "node_modules/express": {
-      "version": "4.18.3",
-      "resolved": "https://registry.npmjs.org/express/-/express-4.18.3.tgz",
-      "integrity": "sha512-6VyCijWQ+9O7WuVMTRBTl+cjNNIzD5cY5mQ1WM8r/LEkI2u8EYpOotESNwzNlyCn3g+dmjKYI6BmNneSr/FSRw==",
+      "version": "4.18.2",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
+      "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
       "dev": true,
       "dependencies": {
         "accepts": "~1.3.8",
         "array-flatten": "1.1.1",
-        "body-parser": "1.20.2",
+        "body-parser": "1.20.1",
         "content-disposition": "0.5.4",
         "content-type": "~1.0.4",
         "cookie": "0.5.0",
@@ -5772,6 +5670,30 @@
         "node": ">= 0.10.0"
       }
     },
+    "node_modules/express/node_modules/body-parser": {
+      "version": "1.20.1",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
+      "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==",
+      "dev": true,
+      "dependencies": {
+        "bytes": "3.1.2",
+        "content-type": "~1.0.4",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.4.24",
+        "on-finished": "2.4.1",
+        "qs": "6.11.0",
+        "raw-body": "2.5.1",
+        "type-is": "~1.6.18",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
     "node_modules/express/node_modules/debug": {
       "version": "2.6.9",
       "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -5793,6 +5715,21 @@
       "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==",
       "dev": true
     },
+    "node_modules/express/node_modules/raw-body": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz",
+      "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==",
+      "dev": true,
+      "dependencies": {
+        "bytes": "3.1.2",
+        "http-errors": "2.0.0",
+        "iconv-lite": "0.4.24",
+        "unpipe": "1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
     "node_modules/external-editor": {
       "version": "3.1.0",
       "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
diff --git a/package.json b/package.json
index 55f4ac7e..f994b263 100644
--- a/package.json
+++ b/package.json
@@ -50,7 +50,7 @@
     "eslint": "8.57.0",
     "eslint-config-prettier": "9.1.0",
     "eslint-plugin-import": "2.29.1",
-    "express": "4.18.3",
+    "express": "4.18.2",
     "husky": "9.0.11",
     "jest": "29.7.0",
     "lint-staged": "15.2.2",


Test patch:
diff --git a/test/type-helpers/partial-type.helper.spec.ts b/test/type-helpers/partial-type.helper.spec.ts
index 6ff6eb23..0865ee01 100644
--- a/test/type-helpers/partial-type.helper.spec.ts
+++ b/test/type-helpers/partial-type.helper.spec.ts
@@ -21,9 +21,44 @@ describe('PartialType', () => {
   describe('Validation metadata', () => {
     it('should apply @IsOptional to properties reflected by the plugin', async () => {
       const updateDto = new UpdateUserDto();
+      updateDto.firstName = null;
       const validationErrors = await validate(updateDto);
       expect(validationErrors).toHaveLength(0);
     });
+
+    it('should apply @IsOptional to properties reflected by the plugin if option `skipNullProperties` is true', async () => {
+      class UpdateUserWithNullableDto extends PartialType(CreateUserDto, {
+        skipNullProperties: true
+      }) {}
+      const updateDto = new UpdateUserWithNullableDto();
+      updateDto.firstName = null;
+      const validationErrors = await validate(updateDto);
+      expect(validationErrors).toHaveLength(0);
+    });
+
+    it('should apply @IsOptional to properties reflected by the plugin if option `skipNullProperties` is undefined', async () => {
+      class UpdateUserWithoutNullableDto extends PartialType(
+        CreateUserDto,
+        {}
+      ) {}
+      const updateDto = new UpdateUserWithoutNullableDto();
+      updateDto.firstName = null;
+      const validationErrors = await validate(updateDto);
+      expect(validationErrors).toHaveLength(0);
+    });
+
+    it('should apply @ValidateIf to properties reflected by the plugin if option `skipNullProperties` is false', async () => {
+      class UpdateUserWithoutNullableDto extends PartialType(CreateUserDto, {
+        skipNullProperties: false
+      }) {}
+      const updateDto = new UpdateUserWithoutNullableDto();
+      updateDto.firstName = null;
+      const validationErrors = await validate(updateDto);
+      expect(validationErrors).toHaveLength(1);
+      expect(validationErrors[0].constraints).toEqual({
+        isString: 'firstName must be a string'
+      });
+    });
   });
   describe('OpenAPI metadata', () => {
     it('should return partial class', async () => {