I'm trying to define generic types for domain/aggregate and entities that would enable me to build aggregates that include a number of domains. All in all this would lead to nested value object classes.
In order to simplify API response I would like to provide a toObject() function that would return a somewhat flatten object with strings (or better yet; ValueType).
In the example below I would like order.toObject() to return the type and object as specified in the end.
I would also like to be able to define a recursive/deep generic type for BaseDomain.toObject()
Any ideas? I welcome feedback and thoughts
//// What I would like to solve /////
// Define a generic type that flatten a object by removing all class specific and only keeping the properties
type FlattenProperties<T> = any;
// My poor attempt
/*
type FlattenProperties<T> = T extends IBaseEntity<infer U> ? U
: T extends IBaseList<infer U>
? T['items'] extends IBaseDomain<infer Z>[] ? [...T['items']]: FlattenRecursiveEntityProperties<Z> //Not sure of how to recurse iterate a array
: U[]
: T extends object ? {[K in keyof T]: FlattenRecursiveEntityProperties<T[K]>}
: T;
*/
//// Helpers //////
interface IValue<ValueType> {
value: ValueType
}
interface IBaseEntity<ValueType> {
value: ValueType
}
abstract class BaseEntity<ValueType> implements IBaseEntity<ValueType> {
readonly properties: IValue<ValueType>;
constructor(value?: ValueType) {
this.properties = { value: value ?? {} as ValueType };
}
get value(): ValueType {
return this.properties.value;
}
}
class ObjectId extends BaseEntity<string> {
constructor(id?: string) {
super()
this.properties.value = id ?? 'GeneratedID';
}
}
interface IBaseList<ItemType> {
items: ItemType[]
}
abstract class BaseList<ItemType> {
readonly items: ItemType[];
constructor(items?: ItemType[]) {
this.items = items ?? [];
}
getItems() {
return this.items;
}
add(item: ItemType) {
this.items.push(item);
}
}
interface IBaseDomain<Interface> {
readonly properties: Interface;
readonly _id: ObjectId;
toObject(): FlattenProperties<Interface>
}
abstract class BaseDomain<Interface> implements IBaseDomain<Interface> {
readonly properties: Interface;
readonly _id: ObjectId;
constructor(object?: Interface, id?: ObjectId) {
this._id = id ?? new ObjectId('#a-generated-value#');
this.properties = object ?? {} as Interface;
}
get id() {
return this._id
}
toObject(): FlattenProperties<Interface> {
return {} as FlattenProperties<Interface>;
}
}
///// Definition of domains ///////
interface IProduct {
name: string;
description: string
}
class Product extends BaseDomain<IProduct> {
constructor(name: string, description: string) {
super({ name: name, description: description })
}
}
interface IOrderLine {
product: Product;
qty: number;
}
class OrderLine extends BaseDomain<IOrderLine> {
constructor(product: Product, qty: number) {
super({ product: product, qty: qty })
}
}
class OrderLines extends BaseList<OrderLine> { }
interface IOrder {
orderComment: string;
orderLines: OrderLines
}
class Order extends BaseDomain<IOrder> {
}
///// Example run /////
const toyTruck = new Product('Toy truck', 'Yellow plastic toy truck');
const toyDuck = new Product('Duck', 'Made famous by Duck Sauce');
export const order = new Order({
orderComment: 'Some new shiny toys',
orderLines: new OrderLines([new OrderLine(toyTruck, 1), new OrderLine(toyDuck, 100)])
});
const htmlResponse: Expected = order.toObject(); // Type = Expected
htmlResponse.orderLines[0].product.name; // Type = string
type Expected = {
orderComment: string,
orderLines: {
product: {
name: string,
description: string
},
qty: number
}[]
}
type Expected2 = {
orderComment: string,
orderLines: {
product: IProduct,
qty: number
}[]
}
declare var x: Expected;
declare var x: Expected2;
//declare var x: FlattenProperties<IOrder>; //TODO: Make valid
It looks FlattenProperties<T>
should be a recursive conditional type, where the base cases are if T
is a primitive type or if T extends IBaseEntity<U>
for some U
. If T extends IBaseDomain<U>
or IBaseList<U>
for some U
then you can recurse into U
. Or if T
is an object type, you can map its properties recursively. Something like this:
type FlattenProperties<T> =
T extends IBaseEntity<infer U> ? U :
T extends IBaseDomain<infer U> ? FlattenProperties<U> :
T extends IBaseList<infer U> ? FlattenProperties<U>[] :
T extends object ? { [K in keyof T]: FlattenProperties<T[K]> } : T;
You mentioned in comments that you might want the primitive or IBaseEntity
leaf nodes to be string
instead of some other type, so in that case it would look like:
type FlattenProperties<T> =
T extends IBaseEntity<infer U> ? string :
T extends IBaseDomain<infer U> ? FlattenProperties<U> :
T extends IBaseList<infer U> ? FlattenProperties<U>[] :
T extends object ? { [K in keyof T]: FlattenProperties<T[K]> } : string;
But for now I'm going to use the first definition since it's more general. Let's see if it works:
type FPOrder = FlattenProperties<Order>
/* type FPOrder = {
orderComment: string;
orderLines: {
product: {
name: string;
description: string;
};
qty: number;
}[];
} */
That's the type you expected, so it looks good!
Just to be painfully clear about what's happening, let's partially evaluate FlattenProperties<Order>
"by hand":
FlattenProperties<Order>
becomes
Order extends IBaseEntity<infer U> ? U :
Order extends IBaseDomain<infer U> ? FlattenProperties<U> :
Order extends IBaseList<infer U> ? FlattenProperties<U>[] :
Order extends object ? { [K in keyof Order]: FlattenProperties<Order[K]> } : T;
Since Order
is not an IBaseEntity
we go to the second line. Order
is an IBaseDomain<IOrder>
, so U
is inferred as IOrder
and the type becomes
FlattenProperties<IOrder>
Now we have
IOrder extends IBaseEntity<infer U> ? U :
IOrder extends IBaseDomain<infer U> ? FlattenProperties<U> :
IOrder extends IBaseList<infer U> ? FlattenProperties<U>[] :
IOrder extends object ? { [K in keyof IOrder]: FlattenProperties<IOrder[K]> } : T;
IOrder
is neither an IBaseEntity
, nor an IBaseDomain
, nor an IBaseList
. It is an object
, so we now have
{ [K in keyof IOrder]: FlattenProperties<IOrder[K]> }
which becomes
{
orderComment: FlattenProperties<string>;
orderLines: FlattenProperties<OrderLines>
}
Well FlattenProperties<string>
is just string
because string
is a primitive that does not extend any of your IBase*
types. And FlattenProperties<OrderLines>
becomes FlattenProperties<OrderLine>[]
because OrderLines extends IBaseList<OrderLine>
. So now we have
{
orderComment: string;
orderLines: FlattenProperties<OrderLine>[]
}
We could keep going, but hopefully you can see how this will eventually evaluate to the desired type.