From 981c4cad0291fcdfe72c2535791d6bbdea5b3b3a Mon Sep 17 00:00:00 2001 From: Thomas Foerster Date: Thu, 11 Jan 2024 11:36:37 -0500 Subject: [PATCH] feat(mapped-types): add skip null properties option to partial type With this change, you can create a partial class which disallows null values, but allows undefined values. Previously, every class created with PartialType allowed null values for every property, which may be undesired if you are defining the DTO for a PATCH endpoint. If the option is not defined, the behaviour is unchanged from the previous behaviour. --- lib/index.ts | 1 + lib/partial-type.helper.ts | 20 ++++++++++++++-- lib/type-helpers.utils.ts | 14 +++++++++++ tests/partial-type.helper.spec.ts | 40 +++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index e15beac8..e0cbee88 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -5,6 +5,7 @@ export * from './partial-type.helper'; export * from './pick-type.helper'; export { applyIsOptionalDecorator, + applyValidateIfDefinedDecorator, inheritPropertyInitializers, inheritTransformationMetadata, inheritValidationMetadata, diff --git a/lib/partial-type.helper.ts b/lib/partial-type.helper.ts index 44bee529..b734bff6 100644 --- a/lib/partial-type.helper.ts +++ b/lib/partial-type.helper.ts @@ -2,13 +2,27 @@ import { Type } from '@nestjs/common'; import { MappedType } from './mapped-type.interface'; import { applyIsOptionalDecorator, + applyValidateIfDefinedDecorator, inheritPropertyInitializers, inheritTransformationMetadata, inheritValidationMetadata, } from './type-helpers.utils'; import { RemoveFieldsWithType } from './types/remove-fields-with-type.type'; -export function PartialType(classRef: Type) { +export function PartialType( + classRef: Type, + /** + * 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; + } = {}, +) { abstract class PartialClassType { constructor() { inheritPropertyInitializers(this, classRef); @@ -20,7 +34,9 @@ export function PartialType(classRef: Type) { if (propertyKeys) { propertyKeys.forEach((key) => { - applyIsOptionalDecorator(PartialClassType, key); + options.skipNullProperties === false + ? applyValidateIfDefinedDecorator(PartialClassType, key) + : applyIsOptionalDecorator(PartialClassType, key); }); } diff --git a/lib/type-helpers.utils.ts b/lib/type-helpers.utils.ts index 01f55ae2..ff119a6c 100644 --- a/lib/type-helpers.utils.ts +++ b/lib/type-helpers.utils.ts @@ -15,6 +15,20 @@ export function applyIsOptionalDecorator( decoratorFactory(targetClass.prototype, propertyKey); } +export function applyValidateIfDefinedDecorator( + targetClass: Function, + propertyKey: string, +) { + if (!isClassValidatorAvailable()) { + return; + } + const classValidator: typeof import('class-validator') = require('class-validator'); + const decoratorFactory = classValidator.ValidateIf( + (_, value) => value !== undefined, + ); + decoratorFactory(targetClass.prototype, propertyKey); +} + export function inheritValidationMetadata( parentClass: Type, targetClass: Function, diff --git a/tests/partial-type.helper.spec.ts b/tests/partial-type.helper.spec.ts index abd3462f..cd97c778 100644 --- a/tests/partial-type.helper.spec.ts +++ b/tests/partial-type.helper.spec.ts @@ -81,4 +81,44 @@ describe('PartialType', () => { expect(updateUserDto.login).toEqual('defaultLogin'); }); }); + + describe('Configuration options', () => { + it('should not ignore validations for null properties when `skipNullProperties` is false', async () => { + class UpdateUserDtoDisallowNull extends PartialType(CreateUserDto, { + skipNullProperties: false, + }) {} + + const updateDto = new UpdateUserDtoDisallowNull(); + updateDto.password = null as any; + + const validationErrors = await validate(updateDto); + + expect(validationErrors.length).toBe(1); + expect(validationErrors[0].constraints).toEqual({ + isString: 'password must be a string', + }); + }); + + it('should ignore validations on null properties when `skipNullProperties` is undefined', async () => { + const updateDto = new UpdateUserDto(); + updateDto.password = null as any; + + const validationErrors = await validate(updateDto); + + expect(validationErrors.length).toBe(0); + }); + + it('should ignore validations on null properties when `skipNullProperties` is true', async () => { + class UpdateUserDtoAllowNull extends PartialType(CreateUserDto, { + skipNullProperties: true, + }) {} + + const updateDto = new UpdateUserDtoAllowNull(); + updateDto.password = null as any; + + const validationErrors = await validate(updateDto); + + expect(validationErrors.length).toBe(0); + }); + }); });