Class-transformer: ES6 Maps are not constructed properly

Created on 31 Jul 2019  路  9Comments  路  Source: typestack/class-transformer

This is related to https://github.com/typestack/class-transformer/issues/282, where they realized they were missing the emitDecoratorMetadata compiler option...

According to the docs, it should be possible to work with an ES6 map like Map by annotating them with @Type(() => V).

When trying to use the plainToClass function, it does not correctly transform to a Map, but rather to V itself with the map values as garbled additional properties.

Here is a sample codesandbox that illustrates the issue...

https://codesandbox.io/s/parsing-mu0c5

I do have the emitDecoratorMetadata option enabled, and still see the issue...

Most helpful comment

To complete workaround of @jamesmikesell, you need to provide another transformation for toPlainOnly:

import {classToPlain, plainToClass, Transform} from 'class-transformer';
import {IsNotEmpty, ValidateNested} from 'class-validator';

export class MyObject {
  // @Type(() => Train)
  @ValidateNested({each: true})
  @IsNotEmpty()
  @Transform(value => MyObject.trainsAttributeTransformToClass(value), {toClassOnly: true})
  @Transform(value => MyObject.trainsAttributeTransformToPlain(value), {toPlainOnly: true})
  readonly trains: Map<string, Train> = new Map();

  static trainsAttributeTransformToClass(value: any): Map<string, Train> {
    const map: Map<string, Train> = new Map();

    if (value && values instanceof Object) {
      for (const entry of Object.entries(value)) {
        map.set(entry[0], plainToClass(Train, entry[1]));
      }
    }

    return map;
  }

  static trainsAttributeTransformToPlain(value: any): object {
    const trains: {[key: string]: object} = {};

    if (value && value instanceof Map) {
      for (const entry of value.entries()) {
        trains[entry[0]] = classToPlain(entry[1]);
      }
    }

    return trains;
  }
}

Also added static functions into class for easy testing.

All 9 comments

The tests in this project do not test using a class that has properties that are Typed (or exposed).

Here is an example test that depicts the undesired behavior...

it("using Map with custom objects", () => {
    class BiggerWeapon {
      @Expose({ name: "size" })
      size?: string;

      @Expose({ name: "model" })
      model?: string;

      @Expose({ name: "range" })
      range?: number;
    }

    class BiggerUser {
      id?: number;
      name?: string;
      @Type(() => BiggerWeapon)
      weapons: Map<string, BiggerWeapon> = new Map();
    }

    let biggerWeaponDef = {
      id: 1,
      name: "Max Pain",
      weapons: {
        firstWeapon: {
          model: "knife",
          range: 1,
          size: "extraLarge"
        }
      }
    };

    const biggerWeaponUser = plainToClass(BiggerUser, biggerWeaponDef);

    console.log(`biggerWeaponUser`, biggerWeaponUser);

    expect(biggerWeaponUser).toBeInstanceOf(BiggerUser);
    expect(biggerWeaponUser.weapons).toBeInstanceOf(Map);
    expect(biggerWeaponUser.weapons.get("firstWeapon")).toMatchObject({
      model: "knife",
      range: 1,
      size: "extraLarge"
    });
    expect(biggerWeaponUser.weapons.size).toEqual(1);
  });

outputs the following...

expect(received).toEqual(expected) // deep equality

    Expected: 1
    Received: 3

      269 |       size: "extraLarge"
      270 |     });
    > 271 |     expect(biggerWeaponUser.weapons.size).toEqual(1);
          |                                           ^
      272 |   });
      273 | });
      274 | 

      at Object.<anonymous> (tests/__tests__/models/parse.test.tsx:271:43)

  console.log tests/__tests__/models/parse.test.tsx:262
    biggerWeaponUser BiggerUser {
      weapons: Map {
        'firstWeapon' => BiggerWeapon { model: 'knife', range: 1, size: 'extraLarge' },
        'model' => undefined,
        'range' => undefined
      },
      id: 1,
      name: 'Max Pain'
    }

The following keys should NOT be in the weapons map...

'model' => undefined,
'range' => undefined

I had a similar issue with classes with Map fields not getting properly reconstructed, although my issue was resolved by noticing that the library only seems to support maps with string keys (I was using a Map<number, T> which would not work).

Also, I seem to have no luck getting the annotations to work for converting field which is a Map of Set objects, like this:

import {serialize, deserialize, Transform, Type} from "class-transformer";

class Foo {
  // @Transform((value, obj, type) => {
  //   console.log('Passed as "value"', value); // Logs plain object
  //   console.log('Passed inside "obj"', obj); // Logs class
  // }, {toClassOnly: true})
  @Type(() => Map)
  bar: Map<string, Set<number>> = new Map([['baz', new Set([3, 5])]]);
}

const foo = new Foo();
const fooStr = serialize(foo);
const plainFoo = deserialize(Foo, fooStr);
plainFoo.bar.forEach((value, key, map) => {
  console.log(value);
  map.set(key, new Set(value));
});

console.log('Original class:', foo);
console.log('Original class.bar:', foo.bar); // Map with Set entries
console.log('Serialized class:', fooStr);
console.log('Reconstructed object:', plainFoo);
console.log('Reconstructed object.bar:', plainFoo.bar); // Map with Array entries

No use of the Transform annotation seemed to get me the desired result. Would appreciate if this issue got fixed / or if there was a more clear way to specify nested types using this library.

I have same issue, need support urgently :(

This is a workaround

export class MyObject {

 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value))
      map.set(entry[0], plainToClass(Train, entry[1]));
    return map;
  }, { toClassOnly: true })
  trainMap: Map<string, Train>;

}

NOTE: Do NOT add a @Type(()=> Train) on the field, as this causes the @Transform to fail.

@Chriscbr the above might work for you.

Can confirm the issue is still unresolved, but all my thanks to @jamesmikesell , his workaround works perfectly.

To complete workaround of @jamesmikesell, you need to provide another transformation for toPlainOnly:

import {classToPlain, plainToClass, Transform} from 'class-transformer';
import {IsNotEmpty, ValidateNested} from 'class-validator';

export class MyObject {
  // @Type(() => Train)
  @ValidateNested({each: true})
  @IsNotEmpty()
  @Transform(value => MyObject.trainsAttributeTransformToClass(value), {toClassOnly: true})
  @Transform(value => MyObject.trainsAttributeTransformToPlain(value), {toPlainOnly: true})
  readonly trains: Map<string, Train> = new Map();

  static trainsAttributeTransformToClass(value: any): Map<string, Train> {
    const map: Map<string, Train> = new Map();

    if (value && values instanceof Object) {
      for (const entry of Object.entries(value)) {
        map.set(entry[0], plainToClass(Train, entry[1]));
      }
    }

    return map;
  }

  static trainsAttributeTransformToPlain(value: any): object {
    const trains: {[key: string]: object} = {};

    if (value && value instanceof Map) {
      for (const entry of value.entries()) {
        trains[entry[0]] = classToPlain(entry[1]);
      }
    }

    return trains;
  }
}

Also added static functions into class for easy testing.

@marcalj I haven't had any problem when using when using classToPlain with a map, but the completeness is appreciable

@marcalj I haven't had any problem when using when using classToPlain with a map, but the completeness is appreciable

The toPlainOnly It's required if you want to use classToPlain method correctly.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

SC30SC picture SC30SC  路  4Comments

patricknazar picture patricknazar  路  4Comments

gregsolo-intent picture gregsolo-intent  路  5Comments

lazarljubenovic picture lazarljubenovic  路  6Comments

NathanHazout picture NathanHazout  路  5Comments