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);
Advertisement
Answer
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<>
.