class-transformer icon indicating copy to clipboard operation
class-transformer copied to clipboard

ES6 Maps are not constructed properly

Open cpetzel opened this issue 5 years ago • 18 comments

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<K, V> by annotating them with @Type(() => V).

When trying to use the plainToClass function, it does not correctly transform to a Map<K, V>, 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...

cpetzel avatar Jul 31 '19 19:07 cpetzel

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

cpetzel avatar Jul 31 '19 20:07 cpetzel

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).

Chriscbr avatar Aug 27 '19 00:08 Chriscbr

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.

Chriscbr avatar Aug 27 '19 03:08 Chriscbr

I have same issue, need support urgently :(

suats avatar Oct 22 '19 18:10 suats

This is a workaround

export class MyObject {
 
 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value.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.

jamesmikesell avatar Apr 16 '20 16:04 jamesmikesell

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

lgarczyn avatar May 22 '20 16:05 lgarczyn

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 avatar Jun 04 '20 11:06 marcalj

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

lgarczyn avatar Jun 04 '20 15:06 lgarczyn

@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.

marcalj avatar Jun 04 '20 16:06 marcalj

I came here for this issue. I noticed there are no real commits, only dependabot updates.

Please write this project is not maintained in the readme so people don't waste time trying to use it, only to find out the simple things haven't been working for years (this issue is 2 years old).

gufoe avatar Jul 13 '21 08:07 gufoe

I made a generic utility function to solve this issue.

import { ClassConstructor, instanceToPlain, plainToInstance, TransformationType, TransformFnParams } from "class-transformer";

function createMapTransformFn<T>(mapValueClass: ClassConstructor<T>) {
  return ({ type, value, options }: TransformFnParams): any => {
    const isPrimitiveClass = [String, Number, Boolean].includes(mapValueClass as any);

    switch (type) {
      case TransformationType.PLAIN_TO_CLASS: {
        if (value instanceof Object === false) {
          const emptyMap = new Map();
          return emptyMap;
        }
        const transformedEntries = Object.entries(value)
          .filter(([, v]) => {
            return isPrimitiveClass || typeof v === "object";
          })
          .map(([k, v]) => {
            return [k, isPrimitiveClass ? (mapValueClass as any)(v) : plainToInstance(mapValueClass, v, options)];
          }) as [string, T][];
        const transformedMap = new Map(transformedEntries);
        return transformedMap;
      }

      case TransformationType.CLASS_TO_PLAIN: {
        if (value instanceof Map === false) {
          const emptyObject = {};
          return emptyObject;
        }
        const transformedEntries = Array.from((value as Map<string, T>).entries())
          .filter(([k, v]) => {
            return typeof k === "string" && (isPrimitiveClass || v instanceof mapValueClass);
          })
          .map(([k, v]) => {
            return [k, isPrimitiveClass ? (mapValueClass as any)(v) : instanceToPlain(v, options)];
          });
        const transformedObject = Object.fromEntries(transformedEntries);
        return transformedObject;
      }

      default:
        return value;
    }
  };
}

You can use it like below:

class Foo {
  @Transform(createMapTransformFn(Number))
  bar: Map<string, number> = new Map();
}

class Order {
  @Transform(createMapTransformFn(Item)
  items: Map<string, Item> = new Map();
}
  • 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.

I also made a installable gist. You can just install it with this command:

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

jeongtae avatar Dec 06 '21 13:12 jeongtae

This is a workaround

export class MyObject {
 
 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value.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.

It's working well ! Thanks

Simon-GHI avatar Jan 06 '22 10:01 Simon-GHI

This is a workaround

export class MyObject {
 
 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value.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.

That solves my problem. You're awesome!

ChoGathK avatar Apr 24 '22 09:04 ChoGathK

This is a workaround

export class MyObject {
 
 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value.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.

You just saved my life man!!! ❤️

Walnussbaer avatar Dec 01 '22 17:12 Walnussbaer

This is a workaround

export class MyObject {
 
 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value.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.

Not sure why forEach is not available in trainMap after transformation. any luck for it?

mamun-prospect avatar Jun 20 '23 04:06 mamun-prospect

This is a workaround

export class MyObject {
 
 @Transform(value => {
    let map = new Map<string, Train>();
    for (let entry of Object.entries(value.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.

Not sure why forEach is not available in trainMap after transformation. any luck for it?

I may be wrong, but value.value may be null

lgarczyn avatar Jun 20 '23 05:06 lgarczyn

Do you think it is hard to implement?

daflodedeing avatar Sep 07 '23 09:09 daflodedeing

https://github.com/typestack/class-transformer/issues/288#issuecomment-986769110

refactored version + tests

ruscon avatar Sep 12 '23 23:09 ruscon