diff --git a/README.md b/README.md index 0333a0c..9f5494e 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,43 @@ const value = new Enumerated(Enumerated.VAL1); // ok const value = Enumerated.fromNative(Enumerated.VAL1); // ok ``` +## Composite Value Objects +The `CompositeValueObject` allows you to create value objects that are more complex and contain any number of other value objects (including nested `CompositeValueObject`s and Domain Objects). + +```typescript +class User extends CompositeValueObject<{ + name: StringScalar; + email: StringScalar; + isRegistered: BooleanScalar; +}> { + constructor(name: StringScalar, email: StrigScalar, isRegistered: BooleanScalar) { + super({ + name, + email, + isRegistered + }, User); + } + + public static fromNative(value: { name: string; email: string, isRegistered: boolean }): User { + return new this( + StringScalar.fromNative(value.name), + StringScalar.fromNative(value.email), + BooleanScalar.fromNative(value.isRegistered) + ); + } + + public getName = (): StringScalar => { + return this.value.name; + }; + + ... +} + +// immutability of the properties is still enforced: +const user = new User(...); +user.value.name = StringValue.fromNative('new name'); // -> this will throw a TypeError +``` + ## Domain Value Objects The above helpers can be combined with the `DomainObjectFrom()` mixin to allow you to easily create typesafe domain value objects that are more expressive of your domain language. For example: diff --git a/src/CompositeValueObject.ts b/src/CompositeValueObject.ts new file mode 100644 index 0000000..1fea7bb --- /dev/null +++ b/src/CompositeValueObject.ts @@ -0,0 +1,59 @@ +import { ValueObject, GenericObject } from "./ValueObject"; + +export type CompositeProperties = Record>; + +export class CompositeValueObject< + T extends CompositeProperties +> extends ValueObject { + constructor(args: T, type: unknown) { + super(Object.freeze(args), type); + } + + public isSame = (object: ValueObject): boolean => { + const isObject = (obj: unknown): boolean => { + return obj != null && typeof obj === "object"; + }; + + const isObjectEqual = ( + object1: GenericObject, + object2: GenericObject + ): boolean => { + const keys1 = Object.keys(object1); + const keys2 = Object.keys(object2); + + if (keys1.length !== keys2.length) { + return false; + } + + return keys1.reduce((result: boolean, key: string): boolean => { + if (result === false) { + return false; + } + + const val1 = object1[key]; + const val2 = object2[key]; + const areObjects = isObject(val1) && isObject(val2); + + if ( + (areObjects && + !isObjectEqual(val1 as GenericObject, val2 as GenericObject)) || + (!areObjects && val1 !== val2) + ) { + return false; + } + + return true; + }, true); + }; + + return isObjectEqual(this.toNative(), object.toNative() as GenericObject); + }; + + public toNative = (): GenericObject => { + return Object.keys(this.value).reduce((result: GenericObject, key) => { + // eslint-disable-next-line no-param-reassign + result[key] = this.value[key].toNative(); + return result; + }, {}); + }; +} diff --git a/src/ValueObject.ts b/src/ValueObject.ts index baa1a6e..4aad9e4 100644 --- a/src/ValueObject.ts +++ b/src/ValueObject.ts @@ -1,9 +1,19 @@ +export type Native = + | string + | number + | BigInt + | null + | boolean + | Record; + +export type GenericObject = Record; + export interface ValueObjectInterface extends Readonly<{ value: V; }> { isSame(object: ValueObjectInterface): boolean; - toNative(): V; + toNative(): Native; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,7 +49,7 @@ export abstract class ValueObject implements ValueObjectInterface { abstract isSame(object: ValueObjectInterface): boolean; - abstract toNative(): V; + abstract toNative(): Native; } export function DomainObjectFrom( diff --git a/src/__tests__/CompositeValueObject.test.ts b/src/__tests__/CompositeValueObject.test.ts new file mode 100644 index 0000000..de72359 --- /dev/null +++ b/src/__tests__/CompositeValueObject.test.ts @@ -0,0 +1,140 @@ +import { StringScalar } from "../Scalars/StringScalar"; +import { CompositeValueObject } from "../CompositeValueObject"; +import { FloatScalar } from "../Scalars/FloatScalar"; +import { getType } from "../ValueObject"; + +class Comp extends CompositeValueObject<{ + name: StringScalar; + number: FloatScalar; +}> { + constructor(name: StringScalar, number: FloatScalar) { + super( + { + name, + number + }, + Comp + ); + } + + public static fromNative(value: { name: string; number: number }): Comp { + return new this( + StringScalar.fromNative(value.name), + FloatScalar.fromNative(value.number) + ); + } + + public getName = (): StringScalar => { + return this.value.name; + }; +} + +class ComplexComp extends CompositeValueObject<{ + name: StringScalar; + composite: Comp; +}> { + constructor(name: StringScalar, composite: Comp) { + super( + { + name, + composite + }, + Comp + ); + } + + public static fromNative(value: { + name: string; + composite: { name: string; number: number }; + }): ComplexComp { + return new this( + StringScalar.fromNative(value.name), + Comp.fromNative(value.composite) + ); + } +} + +describe("Test CompositeValueObject", () => { + test("should maintain immutability of the properties of .value property", () => { + expect(() => { + const obj = new Comp( + StringScalar.fromNative("some name"), + FloatScalar.fromNative(20) + ); + obj.value.name = StringScalar.fromNative("new value"); + }).toThrowError(); + }); + + test("isSame() returns true when values match", () => { + const vo1 = new Comp( + StringScalar.fromNative("some name"), + FloatScalar.fromNative(20) + ); + + const vo2 = new Comp( + StringScalar.fromNative("some name"), + FloatScalar.fromNative(20) + ); + expect(vo1.isSame(vo2)).toBeTruthy(); + }); + + test("isSame() returns false when values don't match", () => { + const vo1 = new Comp( + StringScalar.fromNative("some name"), + FloatScalar.fromNative(20) + ); + + const vo2 = new Comp( + StringScalar.fromNative("some other value"), + FloatScalar.fromNative(20) + ); + expect(vo1.isSame(vo2)).not.toBeTruthy(); + }); + + test("fromNative() initialises object with correct type", () => { + const obj = Comp.fromNative({ + name: "some name", + number: 34 + }); + expect(getType(obj)).toBe("Comp"); + expect(obj.value.name.toNative()).toBe("some name"); + }); + + test("toNative() returns simple serialised object", () => { + const obj = new Comp( + StringScalar.fromNative("some name"), + FloatScalar.fromNative(34) + ); + + expect(obj.toNative()).toStrictEqual({ + name: "some name", + number: 34 + }); + }); + + test("toNative() returns complex serialised object", () => { + const obj = new ComplexComp( + StringScalar.fromNative("some name"), + Comp.fromNative({ + name: "other name", + number: 29 + }) + ); + + expect(obj.toNative()).toStrictEqual({ + name: "some name", + composite: { + name: "other name", + number: 29 + } + }); + }); + + test("internal .value property is correctly typed and sub properties can be accesed", () => { + const obj = new Comp( + StringScalar.fromNative("some name"), + FloatScalar.fromNative(20) + ); + expect(obj.getName().toNative()).toBe("some name"); + }); +}); diff --git a/src/index.ts b/src/index.ts index b6a1cc5..bd6a22d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from "./Scalars"; export * from "./ValueObject"; export * from "./EnumValueObject"; +export * from "./CompositeValueObject";