I would like to create a fairly flexible class called Model, like:
export class Model {
_required_fields: Array<string> = [];
_optional_fields?: Array<string> = [];
constructor(params: Dictionary<string> = {}) {
// make sure all required fields are in the params obj
}
set(params: Dictionary<string>){
// make sure only required or optional fields are present
this.all_fields.forEach(key => {
this[key] = params[key];
});
}
get all_fields(): Array<string> {
return [...this._required_fields,...this._optional_fields];
}
get required_fields() {
return this._required_fields;
}
}
children of this will define the required and optional fields and I've kept this shorter because I had some error checking in the set
method. For example:
export class User extends Model {
static REQUIRED_FIELDS = ['username'];
static OPTIONAL_FIELDS = ['email'];
get required_fields() {
return (this._required_fields?.length==0) ? User.REQUIRED_FIELDS : [];
}
static get ALL_FIELDS() {
return [...User.REQUIRED_FIELDS, ...User.OPTIONAL_FIELDS];
}
constructor(params: Dictionary<string> = {}) {
super(params);
}
}
I have a version of User
with the fields:
username: string;
email: string;
but I'd like to be able to define the fields so the set
function can take a Dictionary
and fill in the fields as shown.
I'm getting the typescript error No index signature with a parameter of type 'string' was found on type 'Model'.
at the line:
this[key] = params[key];
and I realize this because I would need to define a field like: [key: string]: string;
inside of Model
.
There seems to be two possibilities with this:
Method 1: Inside of User
, define each of the fields and inside of set
(of User
) explicitly do
user.username = params.username;
user.email = params.email;
I have to repeat this for all children of Model, however, and I have some error checking for this that I'd like to automate a bit.
Method 2: Alternatively, I could keep Model having the generic field
[key: string]: string;
and then set
will work as is, but won't have the ability to do user.username
, but could do user['username']
.
summary
I have done method 1 so far and there is a ton of repeated code, because I need to do all of the fields explicitly for each of the children of Model
. (I realistically I have much more that 2 fields). This isn't satisfying it seems like I could write things much more compactly in the Model
class instead of each child.
Method 2 seems like it by bypasses much of the strong typing of typescript, so although the code is more compact, it doesn't seem great.
Question
Is there any way I could structure this to blend the strong typing of typescript with the flexibility of Method 1
CodePudding user response:
It looks like you want Model
to be keep track of the actual literal values in the requiredFields
and optionalFields
elements, which implies that Model
should maybe be generic in these types. So maybe something like Model<R, O>
where R
is a union of all the required keys and O
is the union of all the optional keys.
But you also want a Model<R, O>
to actually have keys of type R
and O
. And that's where we get into trouble with regular old class
statements. Classes and interfaces require that their keys be statically known to the compiler. They cannot be dynamic and filled in later:
class Foo<T extends object > implements T {} // error!
// -----------------------------------> ~
// A class can only implement an object type or intersection
// of object types with statically known members.
So we'll need to work around this.
It's easy enough to describe such a class constructor type. It should look something like this
type ModelConstructor = new <R extends string, O extends string>(
params: ModelObject<R, O>
) => ModelObject<R, O> & {
set(params: Partial<ModelObject<R, O>>): void,
all_fields: Array<R | O>;
required_fields: R[];
}
type ModelObject<R extends string, O extends string> =
Record<R, string> & Partial<Record<O, string>>;
So a ModelConstructor
has a construct signature which takes a params
of type ModelObject<R, O>
. The ModelObject<R, O>
is an object type with required keys R
and optional keys O
and whose values are all string
. The constructed instance is also a ModelObject<R, O>
which is intersected with a set of appropriately typed properties and methods that you have in your Model
class.
Before moving on with this, though, it might be useful to think about how you want to subclass Model
. Since you would like every single instance of, say, User
, to have the same required and optional fields, it might make sense to make Model
a class factory function instead of a class
constructor itself. Otherwise you need to redundantly specify R
and O
:
class AnnoyingUser extends Model<"username", "email"> {
_required_fields = ["username"] // redundant
_optional_fields = ["email"] // redundant
}
It might be a lot nicer to just write
class User extends Model(["username"], ["email"]) { }
where Model(["username"], ["email"])
produces a class constructor with those fields already in place. It's up to you whether or not you want to do it this way. I'm going to assume that a factory function is acceptable and keep going with it.
So here's the factory function:
export const Model = <R extends string, O extends string>(
requiredFields: R[], optionalFields: O[]
) => {
type ModelObject =
Record<R, string> & Partial<Record<O, string>> extends
infer T ? { [K in keyof T]: T[K] } : never;
class _Model {
_required_fields: Array<R> = requiredFields;
_optional_fields?: Array<O> = optionalFields;
constructor(params: ModelObject) {
this.set(params);
}
set(params: Partial<ModelObject>) {
this.all_fields.forEach(key => {
(this as any)[key] = (params as any)[key];
});
}
get all_fields(): Array<R | O> {
return [...this._required_fields,
...this._optional_fields ?? []];
}
get required_fields() {
return this._required_fields;
}
}
return _Model as any as new (params: ModelObject) =>
ModelObject & {
set(params: Partial<ModelObject>): void,
all_fields: Array<R | O>;
required_fields: R[];
} extends infer T ? { [K in keyof T]: T[K] } : never;
}
Note that the class constructor returned from Model
closes over the requiredFields
and optionalFields
variables passed into it.
The typings are similar to before, except that R
and O
are now generic type parameters on the factory function and not the resulting class. The scope is a little different, so (for example) ModelObject
doesn't need to be generic in R
and O
since R
and O
are known in that scope. Also there are some extends infer T ? {[K in keyof T]: T[K]}: never
types thrown in there, which are there just to ask the compiler to expand out things like Record<"username", string> & Partial<Record<"email", string>>
into a nicer looking {username: string; email?: string}
type.
Also note that since the compiler cannot represent a class
as having dynamic properties, I need to use a type assertion to tell the compiler that it can treat the returned constructor as the right type.
Once you use a factory function you could decide to refactor further; maybe you want the required/optional fields to only be static
properties of the returned class constructor, since they should be the same for every instance of the class. I'm not going to bother, though. Just a thought.
Let's test it out. First let's see what comes out when we call Model
:
const UserModel = Model(["username"], ["email"]);
/* const UserModel: new (params: {
username: string;
email?: string | undefined;
}) => {
username: string;
email?: string | undefined;
set: (params: Partial<{
username: string;
email?: string | undefined;
}>) => void;
all_fields: ("username" | "email")[];
required_fields: "username"[];
} */
Seems reasonable, and what you want, I think. Now we can define User
:
export class User extends Model(["username"], ["email"]) {
// static REQUIRED_FIELDS = ['username'];
// static OPTIONAL_FIELDS = ['email'];
}
Those static
properties I commented out were from your example, but they are not necessary for my solution. If you want them, feel free to add them, but I don't know what purpose they serve.
Let's test it out:
const u = new User({ username: "alice" });
console.log(u.required_fields) // ["username"]
console.log(u.all_fields) // ["username", "email"]
u.set({ email: "[email protected]" })
console.log(u.email) // [email protected]
Looks good!