Search code examples
typescriptconditional-typesrecursive-type

TypeScript: automatically infer function output type based on recursive input type


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.


Solution

  • TypeScript isn't clever enough to infer the generic type argument T from a hydrate value of type JoinParams<T> when JoinParams is a recursive conditional type. The more complicated a type function F<T> is, the less likely the compiler can infer T from it. Instead of even trying that, you should refactor so that the hydrate parameter 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 infer H from hydrate, then make hydrate of type H directly. Then you can compute your other types from H.

    One approach looks like:

    class DocumentModel<M extends IDocumentModel> {
      declare t: M;
      async find<H extends Hydrate>(
        filter?: Partial<M>,
        hydrate?: H,
      ): Promise<Find<M, H>> {
        hydrate?.children.hydrate?.grandchildren.model;
        return null!
      }
    }
    
    type Hydrate = {
      [k: string]: SubHydrate<IDocumentModel, Hydrate>
    }
    
    interface SubHydrate<M extends IDocumentModel, H extends Hydrate> {
      model: DocumentModel<M>,
      hydrate?: H
    }    
    
    type Find<M extends IDocumentModel, H extends Hydrate> = Array<M & {
      [K in keyof H]: H[K] extends 
        SubHydrate<infer M extends IDocumentModel, infer H extends Hydrate> ? Find<M, H> : never
    }>
    

    Here we are constraining H to Hydrate, a type that should hopefully allow valid values and disallow invalid ones. Hydrate is written in terms of SubHydrate, which is itself generic in the model type M and a nested Hydrate type H. So H should be easily inferred from the call to find().

    Then the return type of find() is Find<M, H>, which does the work of converting hydrate's and model's types to the expected output type. It recursively descends through H, inferring the nested M and H types from it.

    Let's see it in action:

    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);
    

    Looks good. Everything behaves as desired.

    Playground link to code