Skip to content

Commit

Permalink
feat: add CompositeValueObject to the available helpers
Browse files Browse the repository at this point in the history
allows creating value objects with much more complex structure easily
  • Loading branch information
kevbaldwyn committed Dec 22, 2020
1 parent 25eb133 commit 3e76d87
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 2 deletions.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
59 changes: 59 additions & 0 deletions src/CompositeValueObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { ValueObject, GenericObject } from "./ValueObject";

export type CompositeProperties = Record<string, ValueObject<unknown>>;

export class CompositeValueObject<
T extends CompositeProperties
> extends ValueObject<T> {
constructor(args: T, type: unknown) {
super(Object.freeze(args), type);
}

public isSame = (object: ValueObject<CompositeProperties>): 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;
}, {});
};
}
14 changes: 12 additions & 2 deletions src/ValueObject.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
export type Native =
| string
| number
| BigInt
| null
| boolean
| Record<string, unknown>;

export type GenericObject = Record<string, unknown>;

export interface ValueObjectInterface<V>
extends Readonly<{
value: V;
}> {
isSame(object: ValueObjectInterface<V>): boolean;
toNative(): V;
toNative(): Native;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -39,7 +49,7 @@ export abstract class ValueObject<V> implements ValueObjectInterface<V> {

abstract isSame(object: ValueObjectInterface<V>): boolean;

abstract toNative(): V;
abstract toNative(): Native;
}

export function DomainObjectFrom<TBase extends ValueObjectConstructor, V>(
Expand Down
140 changes: 140 additions & 0 deletions src/__tests__/CompositeValueObject.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./Scalars";
export * from "./ValueObject";
export * from "./EnumValueObject";
export * from "./CompositeValueObject";

0 comments on commit 3e76d87

Please sign in to comment.