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);
}
}
}
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);
.