Skip to content

Commit bdf9423

Browse files
author
Janis Köhr
committed
Different approach with discriminator property like in Jackson (Java).
1 parent 29ef5e9 commit bdf9423

File tree

6 files changed

+440
-208
lines changed

6 files changed

+440
-208
lines changed

README.md

Lines changed: 33 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -269,68 +269,30 @@ let album = plainToClass(Album, albumJson);
269269

270270
### Providing more than one type option
271271

272-
In case the nested object can be of different types, you can provide an array of
273-
discriminator functions instead of a single type definition. These functions are then used
274-
to determine the correct target type for the nested object.
272+
In case the nested object can be of different types, you can provide an additional options object,
273+
that specifies a discriminator. The discriminator option must define a `property` that holds the sub
274+
type name for the object and the possible `subTypes`, the nested object can converted to. A sub type
275+
has a `value`, that holds the constructor of the Type and the `name`, that can match with the `property`
276+
of the discriminator.
275277

276278
Lets say we have an album that has a top photo. But this photo can be of certain different types.
277-
And we are trying to convert album plain object to class object:
278-
279-
```typescript
280-
import {Type, plainToClass} from "class-transformer";
281-
282-
export abstract class Photo {
283-
id: number;
284-
filename: string;
285-
}
286-
287-
export class Landscape extends Photo {
288-
panorama: boolean;
289-
}
290-
291-
export class Portrait extends Photo {
292-
person: Person;
293-
}
294-
295-
export class UnderWater extends Photo {
296-
depth: number;
297-
}
298-
299-
function isLandscape(value: any): Function | false {
300-
return value.panorama !== undefined ? Landscape : false;
301-
}
302-
303-
function isPortrait(value: any): Function | false {
304-
return value.person !== undefined ? Portrait : false;
305-
}
306-
307-
function isUnderWater(value: any): Function | false {
308-
return value.depth !== undefined ? UnderWater : false;
309-
}
310-
311-
export class Album {
312-
313-
id: number;
314-
name: string;
315-
316-
@Type(() => [ isLandscape, isPortrait, isUnderWater ])
317-
topPhoto: Landscape | Portrait | UnderWater;
279+
And we are trying to convert album plain object to class object. The plain object input has to define
280+
the additional property `__type`. This property is removed during transformation by default:
318281

282+
**JSON input**:
283+
```json
284+
{
285+
"id": 1,
286+
"name": "foo",
287+
"topPhoto": {
288+
"id": 9,
289+
"filename": "cool_wale.jpg",
290+
"depth": 1245,
291+
"__type": "underwater"
292+
}
319293
}
320-
321-
let album = plainToClass(Album, albumJson);
322-
// now album is Album object with Landscape, Portrait and UnderWater objects inside
323294
```
324295

325-
### Working with an array that holds more than one nested object type
326-
327-
In case you have to deal with an array that holds different kinds of nested objects,
328-
the `@Type` decorator can be used, too. Here you just give an array of discriminator functions
329-
instead of a single type definition, in order to be able to determine which type each object has.
330-
331-
Lets stick to the photo album example from above, but here we got different photo types.
332-
And we are trying to convert the whole album plain object to its class.
333-
334296
```typescript
335297
import {Type, plainToClass} from "class-transformer";
336298

@@ -351,33 +313,31 @@ export class UnderWater extends Photo {
351313
depth: number;
352314
}
353315

354-
function isLandscape(value: any): Function | false {
355-
return value.panorama !== undefined ? Landscape : false;
356-
}
357-
358-
function isPortrait(value: any): Function | false {
359-
return value.person !== undefined ? Portrait : false;
360-
}
361-
362-
function isUnderWater(value: any): Function | false {
363-
return value.depth !== undefined ? UnderWater : false;
364-
}
365-
366316
export class Album {
367317

368318
id: number;
369319
name: string;
370320

371-
@Type(() => [ isLandscape, isPortrait, isUnderWater ])
372-
photos: (Landscape | Portrait | UnderWater)[];
321+
@Type(() => Photo, {
322+
discriminator: {
323+
property: "__type",
324+
subTypes: [
325+
{ value: Landscape, name: "landscape" },
326+
{ value: Portrait, name: "portrait" },
327+
{ value: UnderWater, name: "underwater" }
328+
]
329+
}
330+
})
331+
topPhoto: Landscape | Portrait | UnderWater;
332+
373333
}
374334

375335
let album = plainToClass(Album, albumJson);
376-
// now album is Album object with Landscape, Portrait and UnderWater objects inside
336+
// now album is Album object with a UnderWater object without `__type` property.
377337
```
378338

379-
Hint: You can also provide a special discriminator function, which returns a standard type,
380-
as the last element of the type array such that it gets a type for sure.
339+
Hint: The same applies for arrays with different sub types. Moreover you can specify `keepDiscriminatorProperty: true`
340+
in the options to keep the discriminator property also inside your resulting class.
381341

382342
## Exposing getters and method return values
383343

src/TransformOperationExecutor.ts

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ClassTransformOptions } from "./ClassTransformOptions";
22
import { defaultMetadataStorage } from "./storage";
3-
import { TypeOptions } from "./metadata/ExposeExcludeOptions";
4-
import { DiscrimnatorFunction } from "./metadata/TypeMetadata";
3+
import { TypeHelpOptions, Discriminator, TypeOptions } from "./metadata/ExposeExcludeOptions";
4+
import { TypeMetadata } from "./metadata/TypeMetadata";
55

66
export enum TransformationType {
77
PLAIN_TO_CLASS,
@@ -31,7 +31,7 @@ export class TransformOperationExecutor {
3131

3232
transform(source: Object | Object[] | any,
3333
value: Object | Object[] | any,
34-
targetType: Function | DiscrimnatorFunction[],
34+
targetType: Function | TypeMetadata,
3535
arrayType: Function,
3636
isMap: boolean,
3737
level: number = 0) {
@@ -42,19 +42,24 @@ export class TransformOperationExecutor {
4242
const subSource = source ? source[index] : undefined;
4343
if (!this.options.enableCircularCheck || !this.isCircular(subValue, level)) {
4444
let realTargetType;
45-
if (Array.isArray(targetType)) {
46-
for (const func of targetType) {
47-
const potentialType = func(subValue);
48-
if (potentialType) {
49-
realTargetType = potentialType;
50-
break;
51-
}
45+
if (typeof targetType !== "function" && targetType && targetType.options && targetType.options.discriminator && targetType.options.discriminator.property && targetType.options.discriminator.subTypes) {
46+
if (this.transformationType === TransformationType.PLAIN_TO_CLASS) {
47+
realTargetType = targetType.options.discriminator.subTypes.find((subType) => subType.name === subValue[(targetType as { options: TypeOptions }).options.discriminator.property]);
48+
const options: TypeHelpOptions = { newObject: newValue, object: subValue, property: undefined };
49+
const newType = targetType.typeFunction(options);
50+
realTargetType === undefined ? realTargetType = newType : realTargetType = realTargetType.value;
51+
if (!targetType.options.keepDiscriminatorProperty) delete subValue[targetType.options.discriminator.property];
52+
}
53+
if (this.transformationType === TransformationType.CLASS_TO_CLASS) {
54+
realTargetType = subValue.constructor;
55+
}
56+
if (this.transformationType === TransformationType.CLASS_TO_PLAIN) {
57+
subValue[targetType.options.discriminator.property] = targetType.options.discriminator.subTypes.find((subType) => subType.value === subValue.constructor).name;
5258
}
53-
if (typeof realTargetType !== "function") throw Error("None of the given discriminator functions did return a constructor for a type.");
54-
value = this.transform(subSource, subValue, realTargetType, undefined, subValue instanceof Map, level + 1);
5559
} else {
56-
value = this.transform(subSource, subValue, targetType, undefined, subValue instanceof Map, level + 1);
60+
realTargetType = targetType;
5761
}
62+
const value = this.transform(subSource, subValue, realTargetType, undefined, subValue instanceof Map, level + 1);
5863
if (newValue instanceof Set) {
5964
newValue.add(value);
6065
} else {
@@ -89,22 +94,10 @@ export class TransformOperationExecutor {
8994

9095
} else if (value instanceof Object) {
9196

92-
if (Array.isArray(targetType)) {
93-
for (const func of targetType) {
94-
const potentialType = func(value);
95-
if (potentialType) {
96-
targetType = potentialType;
97-
break;
98-
}
99-
}
100-
if (typeof targetType !== "function") throw Error("None of the given discriminator functions did return a constructor for a type.");
101-
}
102-
10397
// try to guess the type
10498
if (!targetType && value.constructor !== Object/* && TransformationType === TransformationType.CLASS_TO_PLAIN*/) targetType = value.constructor;
10599
if (!targetType && source) targetType = source.constructor;
106100

107-
108101
if (this.options.enableCircularCheck) {
109102
// add transformed type to prevent circular references
110103
this.transformedTypesMap.set(value, { level: level, object: value });
@@ -136,8 +129,9 @@ export class TransformOperationExecutor {
136129

137130
} else if (this.transformationType === TransformationType.CLASS_TO_PLAIN || this.transformationType === TransformationType.CLASS_TO_CLASS) {
138131
const exposeMetadata = defaultMetadataStorage.findExposeMetadata((targetType as Function), key);
139-
if (exposeMetadata && exposeMetadata.options && exposeMetadata.options.name)
132+
if (exposeMetadata && exposeMetadata.options && exposeMetadata.options.name) {
140133
newValueKey = exposeMetadata.options.name;
134+
}
141135
}
142136
}
143137

@@ -157,10 +151,30 @@ export class TransformOperationExecutor {
157151
type = targetType;
158152

159153
} else if (targetType) {
154+
160155
const metadata = defaultMetadataStorage.findTypeMetadata((targetType as Function), propertyName);
161156
if (metadata) {
162-
const options: TypeOptions = { newObject: newValue, object: value, property: propertyName };
163-
type = metadata.typeFunction(options);
157+
const options: TypeHelpOptions = { newObject: newValue, object: value, property: propertyName };
158+
const newType = metadata.typeFunction(options);
159+
if (metadata.options && metadata.options.discriminator && metadata.options.discriminator.property && metadata.options.discriminator.subTypes) {
160+
if (!(value[valueKey] instanceof Array)) {
161+
if (this.transformationType === TransformationType.PLAIN_TO_CLASS) {
162+
type = metadata.options.discriminator.subTypes.find((subType) => subType.name === subValue[metadata.options.discriminator.property]);
163+
type === undefined ? type = newType : type = type.value;
164+
if (!metadata.options.keepDiscriminatorProperty) delete subValue[metadata.options.discriminator.property];
165+
}
166+
if (this.transformationType === TransformationType.CLASS_TO_CLASS) {
167+
type = subValue.constructor;
168+
}
169+
if (this.transformationType === TransformationType.CLASS_TO_PLAIN) {
170+
subValue[metadata.options.discriminator.property] = metadata.options.discriminator.subTypes.find((subType) => subType.value === subValue.constructor).name;
171+
}
172+
} else {
173+
type = metadata;
174+
}
175+
} else {
176+
type = newType;
177+
}
164178
isSubValueMap = isSubValueMap || metadata.reflectedType === Map;
165179
} else if (this.options.targetMaps) { // try to find a type in target maps
166180
this.options.targetMaps
@@ -171,6 +185,7 @@ export class TransformOperationExecutor {
171185

172186
// if value is an array try to get its custom array type
173187
const arrayType = value[valueKey] instanceof Array ? this.getReflectedType((targetType as Function), propertyName) : undefined;
188+
174189
// const subValueKey = TransformationType === TransformationType.PLAIN_TO_CLASS && newKeyName ? newKeyName : key;
175190
const subSource = source ? source[valueKey] : undefined;
176191

src/decorators.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import {ClassTransformer} from "./ClassTransformer";
22
import {defaultMetadataStorage} from "./storage";
3-
import {TypeMetadata, DiscrimnatorFunction} from "./metadata/TypeMetadata";
3+
import {TypeMetadata} from "./metadata/TypeMetadata";
44
import {ExposeMetadata} from "./metadata/ExposeMetadata";
5-
import {ExposeOptions, ExcludeOptions, TypeOptions, TransformOptions} from "./metadata/ExposeExcludeOptions";
5+
import {ExposeOptions, ExcludeOptions, TypeHelpOptions, TransformOptions, Discriminator, TypeOptions} from "./metadata/ExposeExcludeOptions";
66
import {ExcludeMetadata} from "./metadata/ExcludeMetadata";
77
import {TransformMetadata} from "./metadata/TransformMetadata";
88
import {ClassTransformOptions} from "./ClassTransformOptions";
@@ -20,13 +20,12 @@ export function Transform(transformFn: (value: any, obj: any, transformationType
2020

2121
/**
2222
* Specifies a type of the property.
23-
* The given TypeFunction can return a constructor or in case of an array of different types
24-
* an array of discriminator functions can be provided.
23+
* The given TypeFunction can return a constructor. A discriminator can be given in the options.
2524
*/
26-
export function Type(typeFunction?: (type?: TypeOptions) => Function | DiscrimnatorFunction[]) {
25+
export function Type(typeFunction: (type?: TypeHelpOptions) => Function, options?: TypeOptions) {
2726
return function(target: any, key: string) {
2827
const type = (Reflect as any).getMetadata("design:type", target, key);
29-
const metadata = new TypeMetadata(target.constructor, key, type, typeFunction);
28+
const metadata = new TypeMetadata(target.constructor, key, type, typeFunction, options);
3029
defaultMetadataStorage.addTypeMetadata(metadata);
3130
};
3231
}

src/metadata/ExposeExcludeOptions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ export interface TransformOptions {
88
}
99

1010
export interface TypeOptions {
11+
discriminator?: Discriminator;
12+
/**
13+
* Is false by default.
14+
*/
15+
keepDiscriminatorProperty?: boolean;
16+
}
17+
18+
export interface TypeHelpOptions {
1119
newObject: any;
1220
object: Object;
1321
property: string;
@@ -26,3 +34,13 @@ export interface ExcludeOptions {
2634
toClassOnly?: boolean;
2735
toPlainOnly?: boolean;
2836
}
37+
38+
export interface Discriminator {
39+
property: string;
40+
subTypes: JsonSubType[];
41+
}
42+
43+
export interface JsonSubType {
44+
value: new (...args: any[]) => any;
45+
name: string;
46+
}

src/metadata/TypeMetadata.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import {TypeOptions} from "./ExposeExcludeOptions";
1+
import {TypeHelpOptions, Discriminator, TypeOptions} from "./ExposeExcludeOptions";
22

33
export class TypeMetadata {
44

55
constructor(public target: Function,
66
public propertyName: string,
77
public reflectedType: any,
8-
public typeFunction: (options?: TypeOptions) => Function | DiscrimnatorFunction[]) {
8+
public typeFunction: (options?: TypeHelpOptions) => Function,
9+
public options: TypeOptions) {
910
}
1011

1112
}
12-
13-
export type DiscrimnatorFunction = (value: any) => Function | false;

0 commit comments

Comments
 (0)