Home > front end >  creating a class using typescript with specific fields
creating a class using typescript with specific fields

Time:10-17

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!

Playground link to code

  • Related