Search code examples
typescriptdomain-driven-designtransformation

Generic type of flatten nested value objects


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

Playground

//// 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

Solution

  • 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.

    Playground link to code