Home > Software design >  Best way to get dynamic type definition in typescript from a configuration object
Best way to get dynamic type definition in typescript from a configuration object

Time:01-03

I have a system in normal javascript where a model can be defined with types as a method override and are casted when the data comes in, but want to get the typescript benefits purely on the assignment (not the instantiation, or at least not as important).

the typescript should be a product of a configuration dictionary / plain object. is there a way to define the following pattern dynamically? I did see some examples of defining a dict but i want to do it for any model not specifically one (which would still require the code duplication)

i saw maybe can make a typescript plugin or do interface code generation. after looking around i didnt see any similar answers that did this. just wondering if anyone has any thoughts on best way to accomplish or if its just not possible.

Dynamically produce the typings from attributes?

User uses model . model is our base model class that used public attributes with types to cast and set things like readonly or any. need to add typescript but basically repeats myself from what should be easily derived from the source of truth definePublicAttributes

export class User extends Model {
  // can i automate the process of generating this below from
  // definePublicAttributes
  firstName?: string;
  lastName?: string;
  email?: string;
  state?: string;
  countryCode?: string;
  lookupCreditBalance?: number;
  creditTime?: Date;

  // this will pull in andd assign to the instance when data dict
  // is passed..can this be read or provided some way 
  definePublicAttributes(any) {
    return {
      first_name: String,
      last_name: String,
      email: String,
      state: String,
      country_code: String,
      lookup_credit_balance: Number,
      create_time: Date,
      is_anonymous: any.readOnly,
      profile_url: any.readOnly,
    }
  }

for a complete minimum reproducable example see a contrived simplified version of what im doing

(p.s. please note outside of initial cast the model system is used throughout the lifecycle of the app for other things like changes but ommitted for simplicity)

Minimum reproducable example

Two models (Pet and Person) extends base Model, instantiated from a nested plain object and model type definitions. i just want typescript to prevent bad type assignments and docs while editing, but without maybe needing to explictly write the type definitions, instead, getting it from a property / method / source like the get publicAttributes().

class Model {
  get publicAttributes() {
    return {}
  }
  
  cast(value, type=null) {
    if (type === Date) {
      return new Date(value);
    } else if (type === Number) {
      return Number(value);
    } else if (type === String) {
      return value.toString();
    } else if (Model.isModel(type)) {
        const model = new type(value);
        return model;
    } else if (Array.isArray(type)) {
        return value.map((item) => {
          return this.cast(item, type[0]);
        });
    } else if (typeof type === 'function') {
      // if function, invoke and pass in
      return this.cast(value, type(value))
    }
    return value;
  }
  
  static isModel(val) {
    return (
      val?.prototype instanceof Model ||
      val?.constructor?.prototype instanceof Model
    );
  }
  
  constructor(data={}) {
    for (let [key, value] of Object.entries(data)) {
      this[key] = this.cast(value, this.publicAttributes[key] || null);
    }
  }
}

class Pet extends Model {
  // can below be progammatically derived (?)
  name?: string
  type?: string
  
  get publicAttributes() {
   return {
      name: String,
      type: String,
    }
  }
}

class Person extends Model {
   // can below be progammatically derived (?)
  joined?: Date
  name?: string
  age?: number
  skills?: Array<string>
  locale?: any
  pets?: Array<Pet>
  
  get publicAttributes() {
    return {
      joined: Date, // primative
      name: String, // primative
      age: Number, // primative
      skills: [String], // primative arr
      locale: (v) => v === 'vacation' ? null : String, // derived type via fn
      pets: [Pet], // model arr   
    }
  }
}


const bill = new Person({
   joined: '2021-02-10',
   name: 'bill',
   age: '26',
   skills: ['python', 'dogwalking', 500],
   locale: 'vacation',
   pets: [
      {
        name: 'rufus',
        type: 'dog',
      },
   ]
});

console.log(bill);

CodePudding user response:

The only way I can envision this working is if you write a generic class factory function (let's call it ModelFromAttributes()) that takes the publicAttributes value as its input of generic type T and produces an appropriate subclass of Model as its output, whose type is a determined by T. The relationship between T (the type of publicAttributes) and the output class's instance type is somewhat complex, so we will need to define a helper type (let's also call it type ModelFromAttributes<T>) to capture that.

So we will need something like:

declare function ModelFromAttributes<T extends object>(
    atts: T): new (args?: any) => ModelFromAttributes<T>;

type ModelFromAttributes<T extends object> = /* impl here */;

And we will use it like

class Pet extends ModelFromAttributes(
    {
        name: String,
        type: String
    }
) { }


class Person extends ModelFromAttributes({
    joined: Date, 
    name: String, 
    age: Number, 
    skills: [String], 
    locale: (v: any) => v === 'vacation' ? null : String, 
    pets: [Pet],
}) { }

So ModelFromAttributes({...}) takes an input of type T and produces an output with a construct signature whose instance type is ModelFromAttributes<T>. So what type is that?

Well, we want a ModelFromAttributes<T> to have a publicAttributes property of type T, and it should also be a Model since it will inherit from Model, and it should also have a bunch of optional properties whose names and types are determined by T in some way. So it should look like:

type ModelFromAttributes<T extends object> =
    { publicAttributes: T } & Model & Partial<MFA<T>>;

where we are using the Partial<X> helper type to turn all the properties into optional ones, and where MFA<T> is an as-yet undefined type that needs to represent the conversion of the type T of the attributes to the type MFA<T> of the corresponding class properties. The intersection operator (&) can be read as "and"; a ModelFromAttributes<T> is an object with a publicAttributes property of type T, and it is a Model, and it is an all-optional version of MFA<T>, whatever that is.


Okay, now we have to define MFA<T>. Here's one possible definition:

type MFA<T> =
    T extends typeof Date ? Date :
    T extends typeof String ? string :
    T extends typeof Number ? number :
    T extends typeof Boolean ? boolean :
    T extends abstract new (...args: any) => infer R ? R :
    T extends (...args: any) => infer R ? MFA<R> :
    T extends object ? { [K in keyof T]: MFA<T[K]> } :
    T; 

This is a conditional type that checks T and evaluates to different things based on the check.

So, if T is assignable to typeof Date (using the type-level typeof type operator to describe the type of the value named Date), then MFA<T> should be Date. If T is the type of the String constructor, then MFA<T> should be string. And the same for number and boolean.

If T is some other class constructor type, then MFA<T> should be the instance type of that class (using conditional type inference to extract that class type).

If T is some other function type, then MFA<T> should start with the return type of that function, but because the return type might itself be something like the String or Date constructor, we need to apply MFA<> to that type recursively. That is, MFA<T> is a recursive conditional type.

Finally, if T is some other object type, then MFA<T> is a mapped type, for each property in T with key K, we take the property value type T[K] and apply MFA<> to it itself. So this is another recursive step. Also note that mapped types on arrays/tuples produce arrays/tuples so it should automatically work as desired on arrays without extra work.


Before moving on, let's make sure it acts as expected on your Pet and Person attribute types:

const petAttributes = {
    name: String,
    type: String
};
type PetModelProps = MFA<typeof petAttributes>
/* type PetModelProps = {
    name: string;
    type: string;
} */

That looks good so far. We don't have a Pet class constructor yet so let's just describe one with a hand-crafted interface that should be the same thing:

interface PetModel extends Partial<PetModelProps>, Model {
    publicAttributes: PetModelProps
}
const personAttributes = {
    joined: Date,
    name: String,
    age: Number,
    skills: [String],
    locale: (v: any) => v === 'vacation' ? null : String,
    pets: [null! as new () => PetModel], // just for typing purposes
}

type PersonModelProps = MFA<typeof personAttributes>
/* type PersonModelProps = {
    joined: Date;
    name: string;
    age: number;
    skills: string[];
    locale: string | null;
    pets: PetModel[];
} */

Also looks good.


Okay, all we have left to do is actually implement ModelFromAttributes(). Like this:

function ModelFromAttributes<T extends object>(atts: T) {
    return class extends Model {
        get publicAttributes() { return atts }
    } as any as new (args?: any) => ModelFromAttributes<T>;
}

I used type assertions in there to suppress compiler errors. The compiler has no good way to understand that the class expression actually constructs instances of ModelFromAttributes<T>; a generic conditional type like ModelFromAttributes<T> where T is not specified yet is essentially opaque to the compiler. Generally speaking the compiler won't be able to look at a class declaration or expression and infer dynamic property keys; classes generate interface types, and interfaces can only have statically known property names. So something like a type assertion will be necessary.

All that means is we need to be careful that our implementation does what we say it does.


So let's try it:

class Pet extends ModelFromAttributes(
    {
        name: String,
        type: String
    }
) { }

class Person extends ModelFromAttributes({
    joined: Date,
    name: String,
    age: Number,
    skills: [String],
    locale: (v: any) => v === 'vacation' ? null : String,
    pets: [Pet],
}) { }    

const bill = new Person({
    joined: '2021-02-10',
    name: 'bill',
    age: '26',
    skills: ['python', 'dogwalking', 500],
    locale: 'vacation',
    pets: [
        {
            name: 'rufus',
            type: 'dog',
        },
    ]
});

That all compiles without error. Let's use IntelliSense to examine the types of bill's properties:

bill.age // (property) age?: number | undefined
bill.cast // (method) Model.cast(value: any, type?: any): any
bill.joined // (property) joined?: Date | undefined
bill.locale // (property) locale?: string | null | undefined
bill.name // (property) name?: string | undefined
bill.pets // (property) pets?: Pet[] | undefined
bill.publicAttributes /* (property) publicAttributes: {
    joined: DateConstructor;
    name: StringConstructor;
    age: NumberConstructor;
    skills: StringConstructor[];
    locale: (v: any) => StringConstructor | null;
    pets: (typeof Pet)[];
} & {} */
bill.skills // (property) skills?: string[] | undefined

console.log(bill.pets?.[0].type?.toUpperCase()) // "DOG"

Looks good! The properties of bill are all the properties of Model, plus a strongly-typed publicAttributes type, plus all optional properties corresponding to MFA<>.

Playground link to code

  • Related