Riexn
Riexn

Reputation: 123

Adding additional properties that can be inferred with a class that is using a generic type?

I am working on creating a simple ORM for a database that runs in memory for testing purposes.

It'd require you to define the models first, then the database would use those models. the database would then have an additional property in the constructor to define the relations for those models.

what I am trying to do is to get the relational properties to be inferred. How could I do that?

interface BaseModel {
  id: string
}

class Model<ModelProps extends BaseModel>{
  data: ModelProps[] = []
  constructor(object?: ModelProps) {
  }
}

class Database<
  T extends BaseModel,
  ModelsIndex extends { [key: string]: Model<T> },
  ModelsIndexKeys extends keyof ModelsIndex,
  Relations extends Partial<{ [key in ModelsIndexKeys]: any }>
  > {
  models: ModelsIndex
  constructor(models: ModelsIndex, relations?: Relations) {
    this.models = models
  }
}

interface IUserModel {
  id: string,
  name: string
}

interface IRoleModel {
  id: string,
  title: string
}

interface IPostModel {
  id: string,
  title: string,
  body: string,
}



const userModel = new Model<IUserModel>()
const roleModel = new Model<IRoleModel>()
const postModel = new Model<IPostModel>()

// adds more properties and functionalities to the model
const HasOne = (model: any) => {

}

// adds more properties and functionalities to the model
const HasMany = (model: any) => {

}

type role = "role"
type pluralRole = `${role}s`


const db = new Database(
  { user: userModel, role: roleModel, post: postModel },
  {
    user:
    {
      userRole: HasOne(roleModel),
      post: HasMany(postModel)
    }
  })

/**
 * Goal
 */

// since we defined "userRole" as the property name
// I'd want to have 'userRole' and 'userRoleId' as additional properties for the model that can be inferred

// expected infers:
// db.models.user.data[0].userRoleId
// db.models.user.data[0].userRole

// expected infers:
// db.models.user.data[0].posts    -- notice that we defined it as 'post', but it was changed to the plural 'posts' thorugh template literal types
// db.models.user.data[0].postIds  -- same idea here


Typescript Playground

Upvotes: 0

Views: 484

Answers (1)

jcalz
jcalz

Reputation: 329598

In order for this to work at all, the output types of HasOne and HasMany need to encode enough information for the Database class to give the right type to its models property. Here is one possible example, where I just give each function's output a distinguishable interface which keeps track of the particular model:

const HasOne = <T extends BaseModel>(model: Model<T>): HasOne<T> => null!
interface HasOne<T> {
  type: "HasOne",
  model: T
}

const HasMany = <T extends BaseModel>(model: Model<T>): HasMany<T> => null!
interface HasMany<T> {
  type: "HasMany"
  model: T
}

Then the Databaes class should need only two generic types: M, corresponding to the type of the models constructor parameter, and R corresponding to the type of the relations constructor parameter:

class Database<
  M extends Record<keyof M, Model<any>>,
  R extends { [K in keyof M]?: { [P in keyof R[K]]: HasOne<any> | HasMany<any> } }
  > {
  models: Relationify<M, R>
  constructor(models: M, relations?: R) {
    this.models = models as any;
  }
}

I have constrained M to types whose property values are Models of some kind, and R to types whose properties at the same keys of M must themselves have types whose property values are either the output of HasOne or HasMany.

I haven't yet defined Relationify<M, R>, but the goal is to take the information in both M and R and come up with a variant of M whose Models have been augmented in the way you'd like:

type Relationify<M, R extends { [K in keyof M]?: any }> =
  { [K in keyof M]: M[K] extends Model<infer P> ?
    Model<P & AddProps<R[K]>> : never };

We map each property in M at key K, whose original type is Model<P> for some P, to a new property whose type is Model<P & AddProps<R[K]>>. So the P part is the same. I haven't defined AppProps yet, but the goal here is to take the relation from R corresponding to the key K, and turn it into the right extra model properties.

Here comes AddProps:

type AddProps<T> = (
  { [K in keyof T as T[K] extends HasOne<any> ? K : never]:
    T[K] extends HasOne<infer M> ? M : never } &
  { [K in keyof T as T[K] extends HasOne<any> ? `${Extract<K, string>}Id` : never]:
    T[K] extends HasOne<infer M> ? Extract<M, BaseModel>["id"] : never } &
  { [K in keyof T as T[K] extends HasMany<any> ? `${Extract<K, string>}s` : never]:
    T[K] extends HasMany<infer M> ? M[] : never } &
  { [K in keyof T as T[K] extends HasMany<any> ? `${Extract<K, string>}Ids` : never]:
    T[K] extends HasMany<infer M> ? Extract<M, BaseModel>["id"][] : never }
) extends infer O ? {} extends O ? unknown : { [K in keyof O]: O[K] } : never;

Oof, that's not pretty. It could probably be refactored if necessary, but the gist here is that for each property key in the relation T, we look at its key-value pair K and T[K]. This is going to be something like "userRole"-and-HasOne<IRoleModel> or "post"-and-HasMany<IPostModel>.

Since we are taking the key K and possibly modifying it, we will need to use key remapping in mapped types as well as template literal types to do the actual modification.

Each line that starts [K in keyof T as T[K] extends ...] handles a different added property. The first one checks to see if the relation is HasOne<M> for some model type M, and if so, adds a property with the same key and whose value is type M. The second one also checks for HasOne<M>, and if it's a match, adds a property whose name has "Id" appended to it, and whose value is the type of the id property of M. The last two lines do the analogous thing for HasMany<M>, where we are suffixing s and Ids to the keys, and returning an array of the model type or model id type.

Finally, since big intersections of multiple object types are not pretty, I repackage them into a single object type with the extends infer O ? ... clause, and if it happens that there are no added properties, I change it to unknown so that the above Model<P & AddProps<R[K]>> will become Model<P> instead of Model<P & {}>.


Whew! Let's see if it works:

const db = new Database(
  { user: userModel, role: roleModel, post: postModel },
  {
    user:
    {
      userRole: HasOne(roleModel),
      post: HasMany(postModel)
    }
  })

/* const db: Database<{
    user: Model<IUserModel>;
    role: Model<IRoleModel>;
    post: Model<IPostModel>;
}, {
    user: {
        userRole: HasOne<IRoleModel>;
        post: HasMany<IPostModel>;
    };
}> */

The value db is known to the compiler as being a Database<M, R> for the appropriate M and R types. Now we examine its models property:

db.models
/* models: Relationify<{
    user: Model<IUserModel>;
    role: Model<IRoleModel>;
    post: Model<IPostModel>;
}, {
    user: {
        userRole: HasOne<IRoleModel>;
        post: HasMany<IPostModel>;
    };
}> */

That's correct, but what type is that? Let's examine each property in turn:

db.models.post // (property) post: Model<IPostModel>
db.models.role // (property) role: Model<IRoleModel>
db.models.user /* (property) user: Model<IUserModel & {
    userRole: IRoleModel;
    userRoleId: string;
    posts: IPostModel[];
    postIds: string[];
}> */

Looks good; the db.models.post and db.models.role types have not been touched, but db.models.user's type has been augmented. Let's just probe it a bit further:

db.models.user.data[0].id // string
db.models.user.data[0].name // string
db.models.user.data[0].userRole // IRoleModel
db.models.user.data[0].userRoleId // string
db.models.user.data[0].posts // IPostModel[]
db.models.user.data[0].postIds // string[]

That's what you expect, hooray!


Of course this all only deals with the type system and has very little to do with actual implementation. Your implementation will probably drive the particulars of the HasOne and HasMany interfaces, and I fully expect that inside DataBase's class body you will need to rely heavily on type assertions or similar in order to convince the compiler that your implementation conforms to your type signatures. But that's outside the scope of this question.

Playground link to code

Upvotes: 2

Related Questions