class-transformer
class-transformer copied to clipboard
ES6 Maps are not constructed properly
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...
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.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.
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).
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 bestring
. (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 thevalue
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
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
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!
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!!! ❤️
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?
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 intrainMap
after transformation. any luck for it?
I may be wrong, but value.value may be null
Do you think it is hard to implement?
https://github.com/typestack/class-transformer/issues/288#issuecomment-986769110