typescriptnestjs

NestJS cross-DTO class validator


I'm trying to build a validator/decorator to validate one DTO attribute based on another DTO attribute value. I need to validate if availability.saleDate is before details.date.

The problem I'm having is that the decorator at the top level in ItemDto isn't called. Is there a way to achieve this cross-DTO validation using class-validator? Should I be validating this differently? Aný thoughts?

Validator code:

@ValidatorConstraint({ name: 'IsSaleBeforeEventDate', async: false })
export class IsSaleBeforeEventDateConstraint
  implements ValidatorConstraintInterface
{
  validate(_: any, args: ValidationArguments) {
    const item = args.object as any;
    const saleDate = item.availability?.saleDateTime;
    const eventDate = item.details?.date;
    if (saleDate == null || eventDate == null) {
      return true; // let other decorators handle required-ness
    }

    return new Date(saleDate) < new Date(eventDate);
  }

  defaultMessage(args: ValidationArguments) {
    return `availability.saleDateTime must be before details.date`;
  }
}

export function IsSaleBeforeEventDate(validationOptions?: ValidationOptions) {
  return function (object: object) {
    registerDecorator({
      name: 'IsSaleBeforeEventDate',
      target: object.constructor,
      propertyName: '',
      constraints: [],
      options: validationOptions,
      validator: IsSaleBeforeEventDateConstraint,
    });
  };
}

Usage in the DTO:

@IsSaleBeforeEventDate()
export class ItemDto {
  readonly details?: DetailsTabDto;
  readonly availability?: AvailabilityTabDto;
}

export class DetailsTabDto {
  readonly date?: string;
}

export class AvailabilityTabDto {
  readonly saleDateTime?: string;
}

Solution

  • To solve your cross-DTO validation issue, you need to work around class-validator's limitation with class-level decorators (see GitHub Issue #182).

    Here's a working solution using a getter method with the @Validate decorator:

    import { Validate, ValidateNested } from 'class-validator';
    import { Type } from 'class-transformer';
    
    export class ItemDto {
      @ValidateNested()
      @Type(() => DetailsTabDto)
      readonly details?: DetailsTabDto;
    
      @ValidateNested()
      @Type(() => AvailabilityTabDto)
      readonly availability?: AvailabilityTabDto;
    
      @Validate(IsSaleBeforeEventDateConstraint)
      get validateDates() {
        return {
          saleDate: this.availability?.saleDateTime,
          eventDate: this.details?.date
        };
      }
    }
    

    This works because:

    1. The getter property creates a target for the validator decorator
    2. When validation runs, the getter is called and provides access to both fields
    3. You can keep your validation constraint class mostly as-is, just modify to validate the getter's return value

    In your IsSaleBeforeEventDateConstraint, update the validate method:

    validate(value: any, args: ValidationArguments) {
      const { saleDate, eventDate } = value;
      
      if (saleDate == null || eventDate == null) {
        return true; // let other decorators handle required-ness
      }
    
      return new Date(saleDate) < new Date(eventDate);
    }
    

    Don't forget to enable transformation in your NestJS ValidationPipe:

    app.useGlobalPipes(
      new ValidationPipe({
        transform: true,
        whitelist: true,
      }),
    );
    

    This approach keeps your validation logic clean and contained within the DTO itself.