Home > Blockchain >  Is there a better way to handle typescript Partial constructor
Is there a better way to handle typescript Partial constructor

Time:09-14

Setting up some type script classes and just wondering if there is a better way to structure the Partial constructor to accommodate for a couple of different input types.

In principle I want the TextBlock class to always output TextModel and a RichText or null. But for the constructor to take either a string, RichText or null. If the user passes a string into the constructor for it to convert it to a RichText via its constructor.

The best way I have found to deal with this is to create an interface that for this input can have any of the 3 types, but the class to only have the 2 types. This keeps the output type safe but allows me to pass multiple types into the constructor.

interface TextBlockModel
{
    textModel : RichText | string | null;
}

export class TextBlock
{
    textModel: RichText | null = null;

    constructor(source? : Partial<TextBlockModel> | null)
    {
        Object.assign(this, source);

        this.textModel = new RichText(source?.textModel);
        console.log("textblock", source)
    }
}

There may not be a nicer way to do this, but just wondering if there is. I have striped the code down to highlight the questions use case. If it was just this 1 variable would not use the partial constructor.

CodePudding user response:

I don't know if it's simpler, but since TextModelBlock is just derived from TextModel but without methods and with a slightly-adjusted type for textModel, I'd probably do that with a mapped type:

// (Utility type)
type WithoutFunctions<T> = {
    [Key in keyof T]: T[Key] extends Function ? never : T[Key];
};
type TextBlockOptions = WithoutFunctions<Omit<TextBlock, "textModel">> & {
    textModel?: TextBlock["textModel"] | string;
};

Then the constructor's parameter is source?: TextBlockOptions | null.

That way, changes to TextBlock automatically show up in the parameter's type (you don't have to edit them in parallel), and even if you add a further element to the union of types for textModel, it still comes through in the parameter's type.

I'd also tweak the constructor's implementation slightly to handle the case where textModel is already a RichText as discussed in the comments, and to avoid assigning textModel to this when it's still a string:

export class TextBlock {
    textModel: RichText | null = null;

    constructor(source?: TextBlockOptions | null) {
        const { textModel, ...rest } = source ?? {};
        // (I'm always leery of doing this Object.assign thing)
        Object.assign(this, rest);

        if (source?.textModel instanceof RichText) {
            this.textModel = source.textModel;
        } else {
            this.textModel = new RichText(source?.textModel);
        }
    }
}

Playground link


Re Object.assign(this, source) (or Object.assign(this, rest)): I don't like doing that because source can have properties that TextBlock and TypeScript don't know about:

const stuff = { textModel: "Nifty", unrelated: 42 };
const tb = new Textblock(stuff);
// Now `tb` has an `unrelated` property that TypeScript doesn't know about

This can have unintended consequences.

For that reason, I always passlist the things being assigned to this. There are various ways to do it. When you know the properties exist on the target, a utility function like this does it:

function assignValid<T>(target: T, ...sources: Partial<T>[]): T {
    for (const source of sources) {
        for (const key of Object.keys(source)) {
            if (key in target) {
                (target as any)[key] = (source as any)[key];
            }
        }
    }
    return target;
}

Yes, those any type assertions are ugly, but they're implied in Object.assign anyway.

Then use assignValid(this, rest); instead of Object.assign(this, rest);.

Playground link

  • Related