Michiel
Michiel

Reputation: 1843

How to make a group of decorators easily reusable in Typescript

TL;DR

How to group decorators (from a library) together into 1 re-usable decorator

The Problem

Every time my REST API receives a request, it will validate the provided body properties (using the class-validator library). Every route has its own dedicated validation class (in the code they are called Dtos) (see example)

Every provided body property has a couple of validation rules, these can sometimes get really complex, other engineers should be able to re-use these validation rules easily.

Example

Route 1: Company Creation

POST - /api/company
     >> Parameters: name, domain, size, contact
class CreateCompanyDto implements Dto {
    @IsString({message: 'Must be text format'})
    @MinLength(2, { message: "Must have at least 2 characters" })
    @MaxLength(20, { message: "Can't be longer than 20 characters" })
    @IsDefined({ message: 'Must specify a receiver' })
    public name!: string;

    @MaxLength(253, { message: "Can't be longer than 253 characters" })
    @IsFQDN({}, {message: 'Must be a valid domain name'})
    @IsDefined({ message: 'Must specify a domain' })
    public domain!: string;

    @MaxLength(30, { message: "Can't be longer than 30 characters" })
    @IsString({message: 'Must be text format'})
    @IsDefined({ message: 'Must specify a company size' })
    public size!: string;

    @IsPhoneNumber(null, {message: 'Must be a valid phone number'})
    @IsDefined({ message: 'Must specify a phone number' })
    public contact!: string;
}

Route 2: Company Update

PUT - /api/company
    >> Parameters: id, name, domain, size, contact
class UpdateCompanyDto implements Dto {

    @IsUUID()
    @IsDefined({ message: 'Must be defined' })
    public id!: string;

    @IsString({ message: 'Must be text format' })
    @MinLength(2, { message: "Must have at least 2 characters" })
    @MaxLength(20, { message: "Can't be longer than 20 characters" })
    @IsOptional()
    public name!: string;

    @MaxLength(253, { message: "Can't be longer than 253 characters" })
    @IsFQDN({}, { message: 'Must be a valid domain name' })
    @IsOptional()
    public domain!: string;

    @MaxLength(30, { message: "Can't be longer than 30 characters" })
    @IsString({ message: 'Must be text format' })
    @IsOptional()
    public size!: string;

    @IsPhoneNumber(null, { message: 'Must be a valid phone number' })
    @IsOptional()
    public contact!: string;
}

What I'm searching for

Like you can see in the example, it's not uncommon that one validation class need to use properties from another validation class.

The problem is that if an engineer adds 1 validation rule to a property inside a random validation class, the other validation classes won't dynamically update.

Question: What is the best way to make sure that once a decorator gets changed/added other validation classes know about the update.

Is there some way to group them together into a variable/decorator? Any help from any Typescript guru is appreciated!

Acceptable outcome:

class CreateCompanyDto implements Dto {
    @IsCompanyName({required: true})
    public name!: string;

    @IsCompanyDomain({required: true})
    public domain!: string;

    @isCompanySize({required: true})
    public size!: string;

    @isCompanyContact({required: true})
    public contact!: string;
}

class UpdateCompanyDto implements Dto {

    @IsCompanyId({required: true})
    public id!: string;

    @IsCompanyName({required: false})
    public name!: string;

    @IsCompanyDomain({required: false})
    public domain!: string;

    @isCompanySize({required: false})
    public size!: string;

    @isCompanyContact({required: false})
    public contact!: string;
}

Upvotes: 7

Views: 1846

Answers (2)

Sherif Elmetainy
Sherif Elmetainy

Reputation: 4472

You can chain the decorators by defining your own decorators. Note that I didn't use the class-validator library before, so you need to test this. But this is how it can look like:

Here I defined a Decorator called IsCompanyName and had it call the validation decorators in your example. I defined ValidationOptions interface to pass to the decorator. It is up to you if you make this opts parameter required or not. Also it is up to you to define the default behavior if the option is not specified. In my example I make it optional and made it behave that it is required by default. I call IsOptional decorator only if opts isdefined and required === false. Otherwise I don't call it.

interface ValidationOptions {
    required?: boolean
}

function IsCompanyName(opts?: ValidationOptions) {

    return function (target: any, propertyKey: string) {
        IsString({ message: 'Must be text format' })(target, propertyKey);
        MinLength(2, { message: "Must have at least 2 characters" })(target, propertyKey);
        MaxLength(20, { message: "Can't be longer than 20 characters" })(target, propertyKey);
        IsDefined({ message: 'Must specify a receiver' })(target, propertyKey);
        if (opts && opts.required === false) {
            IsOptional()(target, propertyKey);
        }
    }
}

Upvotes: 3

Elias Schablowski
Elias Schablowski

Reputation: 2812

Due to the function nature of decorators, you can easily define your own decorator factory to just call all the required validators:

export function IsCompanyName({ required }: { required: boolean }): PropertyDecorator {
  return function (target: any,
    propertyKey: string | symbol): void {
    IsString({ message: 'Must be text format' })(target, propertyKey);
    MinLength(2, { message: "Must have at least 2 characters" })(target, propertyKey);
    MaxLength(20, { message: "Can't be longer than 20 characters" })(target, propertyKey);
    if (required)
      IsDefined({ message: 'Must specify a receiver' })(target, propertyKey);
    else
      IsOptional()(target, propertyKey);
  }
}

Playground

A small decorator factory factory

export function ValidatorComposer(validators: PropertyDecorator[], name: string): (options: { required: boolean }) => PropertyDecorator {
  return function ({ required }: { required: boolean }) {
    return function (target: any,
      propertyKey: string | symbol): void {
      validators.forEach((validator) => validator(target, propertyKey));
      if (required)
        IsDefined({ message: 'Must specify a ' + name })(target, propertyKey);
      else
        IsOptional()(target, propertyKey);
    }
  }
}

Playground

Upvotes: 8

Related Questions