Skip to content

Instantly share code, notes, and snippets.

@jeongtae
Last active January 14, 2024 17:39
Show Gist options
  • Select an option

  • Save jeongtae/f65ddd8f17f8c388659aab76890f194b to your computer and use it in GitHub Desktop.

Select an option

Save jeongtae/f65ddd8f17f8c388659aab76890f194b to your computer and use it in GitHub Desktop.
create-map-transform-fn

create-map-transform-fn

Utility function to solve issue#288 of class-transformer: ES6 Maps are not constructed properly

๐Ÿ“ฆ Install

npm i gist:f65ddd8f17f8c388659aab76890f194b
# or
yarn add gist:f65ddd8f17f8c388659aab76890f194b

๐Ÿ˜‹ Usage

import { ToMap } from "create-map-transform-fn";

class Item {
  title: string;

  @ToMap({ mapValueClass: Number })
  options: Map<string, number> = new Map();
}

class Order {
  @ToMap({ mapValueClass: Item })
  items: Map<string, Item> = new Map();
}

โœ‹ Notes

  • The type of Map's key must be string. (e.g. Map<string, Foo>)
  • If the type of Map's value is primitive types(String, Number, Boolean), the corresponding wrapper function(String(value), Number(value), Boolean(value)) will be called with the value as a parameter.
"use strict";
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
exports.__esModule = true;
exports.ToMap = void 0;
var class_transformer_1 = require("class-transformer");
function isPrimitiveType(mapValueClass) {
return [String, Number, Boolean].includes(mapValueClass);
}
/**
* Create a transformation function for `Transform` decorator which decorates `Map<string, any>` type property.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
* @deprecated Use `ToMap` instead.
*/
function createMapTransformFn(mapValueClass) {
return function (_a) {
var type = _a.type, value = _a.value, options = _a.options;
var isPrimitiveValueClass = isPrimitiveType(mapValueClass);
if (type === class_transformer_1.TransformationType.PLAIN_TO_CLASS) {
if (value instanceof Object === false) {
return new Map();
}
var transformedEntries = Object.entries(value)
.filter(function (_a) {
var v = _a[1];
return isPrimitiveValueClass || typeof v === "object";
})
.map(function (_a) {
var k = _a[0], v = _a[1];
return [k, isPrimitiveValueClass ? mapValueClass(v) : (0, class_transformer_1.plainToInstance)(mapValueClass, v, options)];
});
return new Map(transformedEntries);
}
if (type === class_transformer_1.TransformationType.CLASS_TO_PLAIN) {
if (value instanceof Map === false) {
return {};
}
var transformedEntries = Array.from(value.entries())
.filter(function (_a) {
var k = _a[0], v = _a[1];
return typeof k === "string" && (isPrimitiveValueClass || v instanceof mapValueClass);
})
.map(function (_a) {
var k = _a[0], v = _a[1];
return [k, isPrimitiveValueClass ? mapValueClass(v) : (0, class_transformer_1.instanceToPlain)(v, options)];
});
return Object.fromEntries(transformedEntries);
}
return value;
};
}
exports["default"] = createMapTransformFn;
/**
* Defines that it will be converted to a Map.
* Can be applied to properties only.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
* @deprecated Use `ToMap` instead.
*/
function ToMap(_a, transformOptions) {
var mapValueClass = _a.mapValueClass;
return (0, class_transformer_1.Transform)(createMapTransformFn(mapValueClass), __assign(__assign({}, transformOptions), { toClassOnly: true, toPlainOnly: false }));
}
exports.ToMap = ToMap;
var __assign = (this && this.__assign) || function () {
__assign = Object.assign || function(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
import { instanceToPlain, plainToInstance, Transform, TransformationType } from "class-transformer";
function isPrimitiveType(mapValueClass) {
return [String, Number, Boolean].includes(mapValueClass);
}
/**
* Create a transformation function for `Transform` decorator which decorates `Map<string, any>` type property.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
* @deprecated Use `ToMap` instead.
*/
export default function createMapTransformFn(mapValueClass) {
return function (_a) {
var type = _a.type, value = _a.value, options = _a.options;
var isPrimitiveValueClass = isPrimitiveType(mapValueClass);
if (type === TransformationType.PLAIN_TO_CLASS) {
if (value instanceof Object === false) {
return new Map();
}
var transformedEntries = Object.entries(value)
.filter(function (_a) {
var v = _a[1];
return isPrimitiveValueClass || typeof v === "object";
})
.map(function (_a) {
var k = _a[0], v = _a[1];
return [k, isPrimitiveValueClass ? mapValueClass(v) : plainToInstance(mapValueClass, v, options)];
});
return new Map(transformedEntries);
}
if (type === TransformationType.CLASS_TO_PLAIN) {
if (value instanceof Map === false) {
return {};
}
var transformedEntries = Array.from(value.entries())
.filter(function (_a) {
var k = _a[0], v = _a[1];
return typeof k === "string" && (isPrimitiveValueClass || v instanceof mapValueClass);
})
.map(function (_a) {
var k = _a[0], v = _a[1];
return [k, isPrimitiveValueClass ? mapValueClass(v) : instanceToPlain(v, options)];
});
return Object.fromEntries(transformedEntries);
}
return value;
};
}
/**
* Defines that it will be converted to a Map.
* Can be applied to properties only.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
* @deprecated Use `ToMap` instead.
*/
export function ToMap(_a, transformOptions) {
var mapValueClass = _a.mapValueClass;
return Transform(createMapTransformFn(mapValueClass), __assign(__assign({}, transformOptions), { toClassOnly: true, toPlainOnly: false }));
}
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
};
{
"name": "create-map-transform-fn",
"version": "0.3.0",
"repository": "https://gist.github.com/jeongtae/f65ddd8f17f8c388659aab76890f194b",
"author": {
"name": "Jeongtae Kim",
"url": "https://github.com/jeongtae"
},
"contributors": [
{
"name": "Coroliov Oleg",
"url": "https://github.com/ruscon"
}
],
"types": "./types.d.ts",
"main": "./cjs.js",
"module": "./esm.js",
"source": "./src.ts",
"scripts": {
"build:esm": "npx tsc -m esnext --outDir . && mv src.js esm.js",
"build:cjs": "npx tsc -m commonjs --outDir . && mv src.js cjs.js",
"build:types": "npx tsc -d --emitDeclarationOnly --outDir . && mv src.d.ts types.d.ts",
"build": "npm run build:esm && npm run build:cjs && npm run build:types",
"test": "jest"
},
"devDependencies": {
"@types/jest": "^29.5.5",
"ts-jest": "^29.1.1",
"typescript": "^4.9.5"
},
"peerDependencies": {
"class-transformer": "^0.5.1"
}
}
import { Expose, instanceToPlain, plainToInstance } from "class-transformer";
import { ToMap } from "./src";
class Item {
constructor(prop: number) {
this.prop = prop;
}
@Expose()
prop!: number;
}
class TestNumberModel {
@Expose()
@ToMap({ mapValueClass: Number })
prop!: Map<string, number>;
}
class TestStringModel {
@Expose()
@ToMap({ mapValueClass: String })
prop!: Map<string, string>;
}
class TestBooleanModel {
@Expose()
@ToMap({ mapValueClass: Boolean })
prop!: Map<string, boolean>;
}
class TestItemModel {
@Expose()
@ToMap({ mapValueClass: Item })
prop!: Map<string, Item>;
}
describe(ToMap.name, () => {
describe("should work with Number value class", () => {
const plain = {
prop: {
"1": 11,
"2": 22,
},
};
it("should transform Record<string, number> to Map<string, number>", () => {
return expect(plainToInstance(TestNumberModel, plain).prop).toEqual(
(new TestNumberModel().prop = new Map([
["1", 11],
["2", 22],
]))
);
});
it("should transform Map<string, number> to Record<string, number>", () => {
return expect(instanceToPlain(plainToInstance(TestNumberModel, plain))).toEqual(plain);
});
});
describe("should work with String value class", () => {
const plain = {
prop: {
"1": "11",
"2": "22",
},
};
it("should transform Record<string, string> to Map<string, string>", () => {
return expect(plainToInstance(TestStringModel, plain).prop).toEqual(
(new TestStringModel().prop = new Map([
["1", "11"],
["2", "22"],
]))
);
});
it("should transform Map<string, string> to Record<string, string>", () => {
return expect(instanceToPlain(plainToInstance(TestStringModel, plain))).toEqual(plain);
});
});
describe("should work with Boolean value class", () => {
const plain = {
prop: {
"1": true,
"2": false,
},
};
it("should transform Record<string, boolean> to Map<string, boolean>", () => {
return expect(plainToInstance(TestBooleanModel, plain).prop).toEqual(
(new TestBooleanModel().prop = new Map([
["1", true],
["2", false],
]))
);
});
it("should transform Map<string, boolean> to Record<string, boolean>", () => {
return expect(instanceToPlain(plainToInstance(TestBooleanModel, plain))).toEqual(plain);
});
});
describe("should work with custom value class", () => {
const plain = {
prop: {
"1": { prop: 11 },
"2": { prop: 22 },
},
};
it("should transform Record<string, Item> to Map<string, Item>", () => {
return expect(plainToInstance(TestItemModel, plain).prop).toEqual(
(new TestItemModel().prop = new Map([
["1", new Item(11)],
["2", new Item(22)],
]))
);
});
it("should transform Map<string, Item> to Record<string, Item>", () => {
return expect(instanceToPlain(plainToInstance(TestItemModel, plain))).toEqual(plain);
});
});
});
import type { ClassConstructor, TransformFnParams, TransformOptions } from "class-transformer";
import { instanceToPlain, plainToInstance, Transform, TransformationType } from "class-transformer";
type PrimitiveType = StringConstructor | NumberConstructor | BooleanConstructor;
export interface IToMapOptions<T> {
/**
* @default comma
*/
mapValueClass: PrimitiveType | ClassConstructor<T>;
}
function isPrimitiveType(mapValueClass: unknown): mapValueClass is PrimitiveType {
return [String, Number, Boolean].includes(mapValueClass as PrimitiveType);
}
/**
* Create a transformation function for `Transform` decorator which decorates `Map<string, any>` type property.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
* @deprecated Use `ToMap` instead.
*/
export default function createMapTransformFn<T>(mapValueClass: PrimitiveType | ClassConstructor<T>) {
return ({ type, value, options }: TransformFnParams): any => {
const isPrimitiveValueClass = isPrimitiveType(mapValueClass);
if (type === TransformationType.PLAIN_TO_CLASS) {
if (value instanceof Object === false) {
return new Map();
}
const transformedEntries = Object.entries(value)
.filter(([, v]) => {
return isPrimitiveValueClass || typeof v === "object";
})
.map(([k, v]) => {
return [k, isPrimitiveValueClass ? (mapValueClass as any)(v) : plainToInstance(mapValueClass, v, options)];
}) as [string, T][];
return new Map(transformedEntries);
}
if (type === TransformationType.CLASS_TO_PLAIN) {
if (value instanceof Map === false) {
return {};
}
const transformedEntries = Array.from((value as Map<string, T>).entries())
.filter(([k, v]) => {
return typeof k === "string" && (isPrimitiveValueClass || v instanceof mapValueClass);
})
.map(([k, v]) => {
return [k, isPrimitiveValueClass ? (mapValueClass as any)(v) : instanceToPlain(v, options)];
});
return Object.fromEntries(transformedEntries);
}
return value;
};
}
/**
* Defines that it will be converted to a Map.
* Can be applied to properties only.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
*/
export function ToMap<T>(
{ mapValueClass }: IToMapOptions<T>,
transformOptions?: Omit<TransformOptions, "toClassOnly" | "toPlainOnly">
): PropertyDecorator {
return Transform(createMapTransformFn(mapValueClass), {
...transformOptions,
toClassOnly: true,
toPlainOnly: false,
});
}
{
"files": ["src.ts"],
"compilerOptions": {
"moduleResolution": "Node",
"lib": ["ESNext"],
"experimentalDecorators": true,
"outDir": "./dist",
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true
}
}
import type { ClassConstructor, TransformFnParams, TransformOptions } from "class-transformer";
type PrimitiveType = StringConstructor | NumberConstructor | BooleanConstructor;
export interface IToMapOptions<T> {
/**
* @default comma
*/
mapValueClass: PrimitiveType | ClassConstructor<T>;
}
/**
* Create a transformation function for `Transform` decorator which decorates `Map<string, any>` type property.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
* @deprecated Use `ToMap` instead.
*/
export default function createMapTransformFn<T>(mapValueClass: PrimitiveType | ClassConstructor<T>): ({ type, value, options }: TransformFnParams) => any;
/**
* Defines that it will be converted to a Map.
* Can be applied to properties only.
* @param mapValueClass Type of value. (e.g. `MyClass`, `Number`, `String`, `Boolean` ...)
*/
export declare function ToMap<T>({ mapValueClass }: IToMapOptions<T>, transformOptions?: Omit<TransformOptions, "toClassOnly" | "toPlainOnly">): PropertyDecorator;
export {};
@TiMESPLiNTER
Copy link

TiMESPLiNTER commented May 21, 2022

Thanks for this gist. I tried your gist but unfortunately it doesn't work in the CLASS_TO_PLAIN case. It only converts the map to string property keys with an empty object as property value. transformedObject containts the correct object structure though but it seems it doesn't end up correctly in the resulting JSON.

The JSON looks like:

{"myMap":{"5533034343435190C072":{}}}

The PLAIN_TO_CLASS case works perfectly.

EDIT

It was because I set the { strategy: 'excludeAll' } option.

@ruscon
Copy link

ruscon commented Sep 12, 2023

refactored version

import type { ClassConstructor, TransformOptions } from 'class-transformer';
import { instanceToPlain, plainToInstance, TransformationType, Transform } from 'class-transformer';

type PrimitiveType = StringConstructor | NumberConstructor | BooleanConstructor;

export interface IToMapOptions<T> {
  /**
   * @default comma
   */
  mapValueClass: PrimitiveType | ClassConstructor<T>;
}

function isPrimitiveType(mapValueClass: unknown): mapValueClass is PrimitiveType {
  return [String, Number, Boolean].includes(mapValueClass as PrimitiveType);
}

export function ToMap<T>(
  { mapValueClass }: IToMapOptions<T>,
  transformOptions?: Omit<TransformOptions, 'toClassOnly' | 'toPlainOnly'>,
): PropertyDecorator {
  return Transform(
    ({ type, value, options }) => {
      const isPrimitiveValueClass = isPrimitiveType(mapValueClass);

      switch (type) {
        case TransformationType.PLAIN_TO_CLASS: {
          console.log({
            valueInstanceofObject: value instanceof Object,
          });

          if (!(value instanceof Object)) {
            return new Map();
          }

          // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
          const transformedEntries = Object.entries<T>(value)
            .filter(([, v]) => {
              return isPrimitiveValueClass || typeof v === 'object';
            })
            .map(([k, v]) => {
              return [k, isPrimitiveValueClass ? mapValueClass(v) : plainToInstance(mapValueClass, v, options)];
            });

          // @ts-expect-error TS2769: No overload matches this call.
          return new Map(transformedEntries);
        }

        case TransformationType.CLASS_TO_PLAIN: {
          if (!(value instanceof Map)) {
            return {};
          }

          const transformedEntries = Array.from((value as Map<string, T>).entries())
            .filter(([k, v]) => {
              return typeof k === 'string' && (isPrimitiveValueClass || v instanceof mapValueClass);
            })
            .map(([k, v]) => {
              return [k, isPrimitiveValueClass ? new mapValueClass(v) : instanceToPlain(v, options)];
            });

          // eslint-disable-next-line @typescript-eslint/no-unsafe-return
          return Object.fromEntries(transformedEntries);
        }

        default:
          // eslint-disable-next-line @typescript-eslint/no-unsafe-return
          return value;
      }
    },
    { ...transformOptions, toClassOnly: true, toPlainOnly: false },
  );
}

jest tests

import { Expose, instanceToPlain, plainToInstance } from 'class-transformer';
import { ToMap } from './ToMap';

class Item {
  constructor(prop: number) {
    this.prop = prop;
  }

  @Expose()
  prop!: number;
}

class TestNumberModel {
  @Expose()
  @ToMap({ mapValueClass: Number })
  prop!: Map<string, number>;
}

class TestStringModel {
  @Expose()
  @ToMap({ mapValueClass: String })
  prop!: Map<string, string>;
}

class TestBooleanModel {
  @Expose()
  @ToMap({ mapValueClass: Boolean })
  prop!: Map<string, boolean>;
}

class TestItemModel {
  @Expose()
  @ToMap({ mapValueClass: Item })
  prop!: Map<string, Item>;
}

describe(ToMap.name, () => {
  describe('should work with Number value class', () => {
    const plain = {
      prop: {
        '1': 11,
        '2': 22,
      },
    };

    it('should transform Record<string, number> to Map<string, number>', () => {
      return expect(plainToInstance(TestNumberModel, plain).prop).toEqual(
        (new TestNumberModel().prop = new Map([
          ['1', 11],
          ['2', 22],
        ])),
      );
    });

    it('should transform Map<string, number> to Record<string, number>', () => {
      return expect(instanceToPlain(plainToInstance(TestNumberModel, plain))).toEqual(plain);
    });
  });

  describe('should work with String value class', () => {
    const plain = {
      prop: {
        '1': '11',
        '2': '22',
      },
    };

    it('should transform Record<string, string> to Map<string, string>', () => {
      return expect(plainToInstance(TestStringModel, plain).prop).toEqual(
        (new TestStringModel().prop = new Map([
          ['1', '11'],
          ['2', '22'],
        ])),
      );
    });

    it('should transform Map<string, string> to Record<string, string>', () => {
      return expect(instanceToPlain(plainToInstance(TestStringModel, plain))).toEqual(plain);
    });
  });

  describe('should work with Boolean value class', () => {
    const plain = {
      prop: {
        '1': true,
        '2': false,
      },
    };

    it('should transform Record<string, boolean> to Map<string, boolean>', () => {
      return expect(plainToInstance(TestBooleanModel, plain).prop).toEqual(
        (new TestBooleanModel().prop = new Map([
          ['1', true],
          ['2', false],
        ])),
      );
    });

    it('should transform Map<string, boolean> to Record<string, boolean>', () => {
      return expect(instanceToPlain(plainToInstance(TestBooleanModel, plain))).toEqual(plain);
    });
  });

  describe('should work with custom value class', () => {
    const plain = {
      prop: {
        '1': { prop: 11 },
        '2': { prop: 22 },
      },
    };

    it('should transform Record<string, Item> to Map<string, Item>', () => {
      return expect(plainToInstance(TestItemModel, plain).prop).toEqual(
        (new TestItemModel().prop = new Map([
          ['1', new Item(11)],
          ['2', new Item(22)],
        ])),
      );
    });

    it('should transform Map<string, Item> to Record<string, Item>', () => {
      return expect(instanceToPlain(plainToInstance(TestItemModel, plain))).toEqual(plain);
    });
  });
});

@jeongtae
Copy link
Author

@ruscon

Thank you for your comment. ๐Ÿ™‚
I've created a new revision based on the refactored version you wrote.

And I also put your name in the contributors section of package.json.

Besides I'm realizing that it's better to maintain this in npm rather than gist. ๐Ÿ˜†

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment