I'm trying to write an ORM for a graph database in TypeScript. Specifically, the "find" method, which will return a list of a specific entity. Now it should also be possible to pass to this function a structure relating to the joins that should be done on the database level. Ideally, the function should automatically type these additional fields so that the client can access them. With only a single level of nesting I have been able to accomplish this, though it would be awesome to make it work with multiple levels.
My (already working) solution for only one nesting level is as follows:
interface IDocumentModel {
_id?: string;
}
type JoinParams<T extends Record<string, IDocumentModel>> = {
[K in keyof T]: {
model: DocumentModel<T[K]>;
};
};
type JoinResult<T, U> = (U & {
[K in keyof T]: T[K][];
})[];
class DocumentModel<T extends IDocumentModel> {
async find<X extends Record<string, IDocumentModel>>(
filter?: Partial<T>,
hydrate?: JoinParams<X>,
): Promise<JoinResult<X, T>> {
// TODO: implementation
}
}
const ParentModel = new DocumentModel<{ _id?: string, parentField: string }>();
const ChildModel = new DocumentModel<{ _id?: string, childField: string }>();
const results = await ParentModel.find(
{ _id: 'abc' },
{
children: {
model: ChildModel,
},
},
);
console.log(results[0].parentField);
console.log(results[0].children[0].childField);
Now the challenge is to extend it to two levels, or even better an arbitrary amount of levels. This is my work in progress for two levels of nesting:
Here is my current solution surrounding the problem.
interface IDocumentModel {
_id?: string;
}
type JoinParams<
T extends
| Record<string, IDocumentModel>
| Record<string, Record<string, IDocumentModel>>,
> = {
[K in keyof T]: {
model: T extends Record<string, Record<string, IDocumentModel>>
? DocumentModel<T[K]['parent']>
: T extends Record<string, IDocumentModel>
? DocumentModel<T[K]>
: never;
hydrate?: T extends Record<string, Record<string, IDocumentModel>>
? JoinParams<Omit<T[K], 'parent'>>
: never;
};
};
type JoinResult<
T extends
| Record<string, IDocumentModel>
| Record<string, Record<string, IDocumentModel>>,
U,
> = (U & {
[K in keyof T]: T extends Record<string, Record<string, IDocumentModel>>
? JoinResult<Omit<T[K], 'parent'>, T[K]['parent']>
: T extends Record<string, IDocumentModel>
? T[K][]
: never;
})[];
class DocumentModel<T extends IDocumentModel> {
async find<X extends Record<string, Record<string, IDocumentModel>>>(
filter?: Partial<T>,
hydrate?: JoinParams<X>,
): Promise<JoinResult<X, T>> {
// TODO: implementation
}
}
const ParentModel = new DocumentModel<{ _id?: string, parentField: string }>();
const ChildModel = new DocumentModel<{ _id?: string, childField: string }>();
const GrandChildModel = new DocumentModel<{ _id?: string, grandChildField: string }>();
const results = await ParentModel.find(
{ _id: 'abc' },
{
children: {
model: ChildModel,
hydrate: {
grandchildren: {
model: GrandChildModel,
},
},
},
},
);
console.log(results[0].parentField);
console.log(results[0].children[0].childField);
console.log(results[0].children[0].grandchildren[0].grandChildField);
When I try my test cases
console.log(results[0].parentField);
console.log(results[0].children[0].childField);
console.log(results[0].children[0].grandchildren[0].grandChildField);
I do not get any autocomplete beyond results[0].parentField. This means, the IDE does not suggest results[0].children as being a valid field anymore.
I hope this is enough information, though I'd be happy to clarify more if unclear.
TypeScript isn't clever enough to infer the generic type argument
Tfrom ahydratevalue of typeJoinParams<T>whenJoinParamsis a recursive conditional type. The more complicated a type functionF<T>is, the less likely the compiler can inferTfrom it. Instead of even trying that, you should refactor so that thehydrateparameter is of a type very simply related to the generic type parameter you're trying to infer. The simplest relationship is identity: if you're trying to inferHfromhydrate, then makehydrateof typeHdirectly. Then you can compute your other types fromH.One approach looks like:
Here we are constraining
HtoHydrate, a type that should hopefully allow valid values and disallow invalid ones.Hydrateis written in terms ofSubHydrate, which is itself generic in the model typeMand a nestedHydratetypeH. SoHshould be easily inferred from the call tofind().Then the return type of
find()isFind<M, H>, which does the work of convertinghydrate's andmodel's types to the expected output type. It recursively descends throughH, inferring the nestedMandHtypes from it.Let's see it in action:
Looks good. Everything behaves as desired.
Playground link to code