'IntersectionType doesn't work with class-transformer decorators _when_ bundled via esbuild, even when esbuild plugins are used

So this question is related to IntersectionType doesn't work with class-transformer. In there the solution was to upgrade from nestjs 7 to 8 (effectively bumps up @nestjs/swagger from 4.5.12-next.1 to 5.1.2). Alas, that's not the end of the story. What I am seeing is that the decorators from class-transformer are not inherited by the IntersectionType when bundled with esbuild (even with recommended plugins). But the class-validate decorators are inherited as expected.

To reproduce, clone https://github.com/adrian-grajdeanu/nestjs-issue0 Next I'm including and discussing code from above repo.

Here is a demo code:

import "reflect-metadata"
import { ValidationError, BadRequestException, ValidationPipe } from '@nestjs/common';
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsInt, IsString, Max, MaxLength, Min } from "class-validator";
import { IntersectionType } from '@nestjs/mapped-types';

const optionalBooleanMapper = new Map([
  ['1', true],
  ['0', false],
]);
function parseOptionalBoolean({ value }: any) {
  console.log(`In parseOptionalBoolean: parsing ${value} (${typeof value})`);
  const bValue = optionalBooleanMapper.get(value.toLowerCase());
  console.log(`In parseOptionalBoolean: parsed ${bValue} (${typeof bValue})`);
  return bValue === undefined ? value : bValue;
}

export class Props {
  @IsInt()
  @Type(() => {
    console.log('In @Type');
    return Number;
  })
  @Min(1)
  @Max(1024)
  count?: number;

  @IsBoolean()
  @Transform(parseOptionalBoolean)
  flag?: boolean;
}
export class Category {
  @IsString()
  @MaxLength(1024)
  category!: String;
}

class Frankenstein extends IntersectionType(Category, Props) {}

const pipe = new ValidationPipe({
  transform: true,
  whitelist: true,
  forbidNonWhitelisted: true,
  exceptionFactory: (errors: ValidationError[]) => new BadRequestException({
    message: 'Validation failed',
    payload: errors.map(error => Object.values(error.constraints || {})).reduce((prev, curr) => [...prev, ...curr], []),
  }),
});


async function validate(arg: any) {
  try {
    const obj = await pipe.transform(arg, {
      type: 'query',
      metatype: Frankenstein,
    });
    console.log(obj);
  } catch (e) {
    console.log(e);
  }
}
validate({
  count: '10',
  category: 'foo',
  flag: '0',
});

When running the above via ts-node, all is well:

$ pnpx ts-node cc.ts
In @Type
In parseOptionalBoolean: parsing 0 (string)
In parseOptionalBoolean: parsed false (boolean)
Frankenstein { count: 10, category: 'foo', flag: false }
$

Note that code inside @Type and @Transform is run and validation/transform succeeds.

But bundle this cc.ts with esbuild. Here is the bundling script:

import { build } from 'esbuild';
import { esbuildDecorators } from '@anatine/esbuild-decorators';

build({
  "plugins": [
    esbuildDecorators({
      tsconfig: './tsconfig.json',
      cwd: process.cwd(),
    })
  ],
  "entryPoints": [
    "cc.ts"
  ],
  "external": [
    "@nestjs/microservices",
    "@nestjs/websockets",
    "class-transformer/storage"
  ],
  "minify": true,
  "bundle": true,
  "target": "node14",
  "platform": "node",
  "mainFields": [
    "module",
    "main"
  ],
  "outfile": "c.js"
})
.catch((e) => {
  console.error('Failed to bundle');
  console.error(e);
  process.exit(1);
});

Run this

$ pnpx ts-node bundle.ts
$

and it produces c.js bundle file. If I run this one

$ pnpx node c.js
uA: Validation failed
    at d2.exceptionFactory (.../c.js:17:199649)
    at d2.transform (.../c.js:17:177741)
    at async M1e (.../c.js:17:199822) {
  response: {
    message: 'Validation failed',
    payload: [
      'count must not be greater than 1024',
      'count must not be less than 1',
      'count must be an integer number',
      'flag must be a boolean value'
    ]
  },
  status: 400
}
$

Note that code in @Type and @Transform decorators is not run and validation fails. The class is not transformed at all (I placed a shim in front of plainToClass function from class-transform, and inspected the before and after). But the validation fails, because the class-validate decorators are inherited by the IntersectionType.

It is a matter of IntersectionType! I have tried without it and all works as expected. I know there are issues with esbuild and decorators, and I have specifically used the esbuildDecorators plugin from @anatine/esbuild-decorators.

Here is my tsconfig.json

{
  "compilerOptions": {
    "target": "ES2018",
    "module": "commonjs",
    "lib": [
      "es2018"
    ],
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "noImplicitThis": true,
    "alwaysStrict": true,
    "noUnusedParameters": false,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "typeRoots": [
      "./node_modules/@types"
    ],
    "moduleResolution": "node",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "exclude": [
    "node_modules"
  ],
  "include": ["bin/**/*.ts", "lib/**/*.ts"]
}

and package.json

{
  "name": "main",
  "version": "0.1.0",
  "bin": {
    "main": "bin/main.js"
  },
  "devDependencies": {
    "@anatine/esbuild-decorators": "^0.2.18",
    "@types/node": "10.17.27",
    "esbuild": "^0.14.21",
    "ts-node": "^9.1.1",
    "typescript": "^4.5.5"
  },
  "dependencies": {
    "@nestjs/common": "^8.2.6",
    "@nestjs/core": "^8.2.6",
    "@nestjs/mapped-types": "^1.0.1",
    "@nestjs/platform-express": "^8.2.6",
    "@nestjs/swagger": "^5.2.0",
    "ajv": "^8.8.2",
    "cache-manager": "^3.4.1",
    "class-transformer": "^0.5.1",
    "class-validator": "^0.13.2",
    "express": "^4.17.2",
    "reflect-metadata": "^0.1.13",
    "regenerator-runtime": "^0.13.9",
    "rxjs": "^7.5.2",
    "source-map-support": "^0.5.21",
    "swagger-ui-express": "^4.3.0"
  }
}

Am I missing something or is this finally a dead-end for this approach I took in the project?



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source