2

Is it possible to generate generic types based on a string array input with a recursive lookup.

I would like something like the examples below:

type Author = {
  id: number;
  name: string;
  articlesAbout?: Person[];
  friends: Author[];
};

type Person = {
  id: number;
  name: string;
  children?: Person[];
  author?: Author;
}

type TheRoot = {
  username: string;
  persons: Person[];
};

// I want the return type of this to be a calculated type by the input strings.
function myFactory(inputs: string[]) {
  return {};
}

// This should give a type that looks like this:
// {
//   username: TheRoot['username'];
//   persons: {
//     id: TheRoot['persons']['0']['id'];
//     name: TheRoot['persons']['0']['name'];
//   }[],
// }
const calculated = myFactory(['username', 'persons.id', 'persons.name']);


// Fail as 'id' doesn't exist
const shouldGiveError = myFactory(['id', 'username']); 


// Anything inside parenthesis should just be ignored when doing the lookup
// This would return
// {
//   id: TheRoot['id'];
//   username: TheRoot['username']; 
// }
const ignoreParenthesis = myFactory(['id', 'username(asLowerCase: true)']);

I'm guessing I'm looking for some solution that will use the infer keyword and the myFactory function will not actually take in string[], but rather some generic type that then will be used to build up this calculated return type.

Jeggy
  • 1,474
  • 1
  • 19
  • 35
  • Can you elaborate a bit? The output type looks the same as the regular `TheRoot` type. What part of the return type is calculated from the input strings? What part is recursive? – Henry Woody Aug 23 '22 at 08:57
  • I want it to be the same as the `TheRoot`, but only with the properties as defined in the `myFactory`. So I guess the `inputs: string[]`, actually needs to be a generic type and then use that type via something like `infer` and dot splitting the strings. – Jeggy Aug 23 '22 at 09:15
  • Are you looking for something like [this](https://catchts.com/deep-pick), [this](https://stackoverflow.com/questions/68668055/eliminate-nevers-to-make-union-possible/68672512?noredirect=1#comment121362429_68672512), [this](https://stackoverflow.com/questions/69126879/typescript-deep-keyof-of-a-nested-object-with-related-type#answer-69129328) ? – captain-yossarian from Ukraine Aug 23 '22 at 12:55
  • 1
    @captain-yossarianfromUkraine I think its more like [this](https://stackoverflow.com/questions/71946569/how-can-i-populate-an-object-types-optional-nested-relations-using-string-union) question – kelsny Aug 23 '22 at 13:52
  • @kelly, This looks very close to what I'm trying to do. But rather than giving me all properties, I would like it to be filtered to only include the ones that are defined within my array input. – Jeggy Aug 24 '22 at 09:06
  • I ll build you this, but one information is missing to do so. How do you determine which type to use you can't replace a type with a string. You have to provide a lookupmap for every possible type – Filly Aug 25 '22 at 13:48
  • I want everything to come from the `TheRoot` type, from here it takes all types recursively by the properties it has properties and based on the string inputs. – Jeggy Aug 26 '22 at 13:07
  • What happens if it's just `myFactory(["persons"])`? – kelsny Aug 26 '22 at 15:38
  • Does it error if you provide an invalid *nested* key like `myFactory(["persons.doesntExist"])`? – kelsny Aug 26 '22 at 18:18
  • Your last example has an invalid key `id` as well. Is that meant to be there? I would love to attempt a claim at the bounty but you need to thoroughly describe the behavior and expectations of such a request. – kelsny Aug 26 '22 at 18:33
  • 1
    [Is this sufficient?](https://tsplay.dev/W4xjvW). – kelsny Aug 26 '22 at 19:41
  • Yes, this is exactly what I'm looking for. A thousands thanks. You are welcome to put it into an answer, so I then can give you the reputation bonus :) – Jeggy Aug 29 '22 at 07:25

2 Answers2

2

Well let's start off with the function signature.

type Expand<T> =
    T extends ((...args: any[]) => any) | Map<any, any> | Set<any> | Date | RegExp
        ? T
        : T extends ReadonlyArray<unknown>
            ? `${bigint}` extends `${keyof T & any}`
                ? { [K in keyof T]: Expand<T[K]>; }
                : Expand<T[number]>[]
            : T extends object
                ? { [K in keyof T]: Expand<T[K]> }
                : T;

type Narrow<T> =
    | (T extends infer U ? U : never)
    | Extract<T, number | string | boolean | bigint | symbol | null | undefined | []>
    | ([T] extends [[]] ? [] : { [K in keyof T]: Narrow<T[K]> });

function myFactory<Inputs extends string[]>(inputs: Valid<Narrow<Inputs>>): Expand<Construct<Inputs>> { ... }

And already, it's quite a mess, but really it's simple to understand. I'll get to the Valid type later, but right now all it's doing is getting the input through the generic parameter Inputs, narrowing it to the exact value instead of string[] (so there isn't any need for as const), then returning the construction of the inputs.

Since the construction will give us a really long intersection of types, we use Expand to simplify it into just one object type.

Narrow is quite hard to grasp, but you can get a bit of how it works with this answer by none other than jcalz.

After we have our input, we need to split it by . (and ignore parentheses).

type SplitPath<Key, Path extends ReadonlyArray<unknown> = []> =
    Key extends `${infer A}.${infer B}`
        ? A extends `${infer A}(${string}`
            ? SplitPath<B, [...Path, A]>
            : SplitPath<B, [...Path, A]>
        : Key extends `${infer Key}(${string}` // if parenthesis ignore after it
            ? [...Path, Key]
            : [...Path, Key];

// "undo" splitting
type JoinPath<P, S extends string = ""> = P extends [infer First, ...infer Rest] ? JoinPath<Rest, `${S}${S extends "" ? "" : "."}${First & string}`> : S;

type KeyPaths<Inputs extends string[]> = {
    [K in keyof Inputs]: SplitPath<Inputs[K]>;
};

These types do exactly what they sound like. KeyPaths is just a utility for splitting all the paths in a tuple (could be named better).

Are you ready for the Construct type now?

type Construct<Inputs extends string[], T = TheRoot> = {
    [K in KeyPaths<Inputs>[number][0] as K extends keyof T ? T[K] extends NonNullable<T[K]> ? K : never : never]:
        K extends keyof T
            ? OmitFirstLevel<KeyPaths<Inputs>, K> extends []
                ? T[K] extends object
                    ? T[K] extends ReadonlyArray<unknown>
                        ? never[]
                        : Record<never, never>
                    : T[K]
                : T[K] extends ReadonlyArray<unknown>
                    ? Construct<OmitFirstLevel<KeyPaths<Inputs>, K>, T[K][number]>[]
                    : Construct<OmitFirstLevel<KeyPaths<Inputs>, K>, T[K]>
            : never;
} & {
    [K in KeyPaths<Inputs>[number][0] as K extends keyof T ? T[K] extends NonNullable<T[K]> ? never : K : never]?:
        K extends keyof T
            ? OmitFirstLevel<KeyPaths<Inputs>, K> extends []
                ? NonNullable<T[K]> extends object
                    ? NonNullable<T[K]> extends ReadonlyArray<unknown>
                        ? never[]
                        : Record<never, never>
                    : NonNullable<T[K]>
                : NonNullable<T[K]> extends ReadonlyArray<unknown>
                    ? Construct<OmitFirstLevel<KeyPaths<Inputs>, K>, NonNullable<T[K]>[number]>[]
                    : Construct<OmitFirstLevel<KeyPaths<Inputs>, K>, NonNullable<T[K]>>
            : never;
};

I wasn't either. But we only need to focus on the first half:

{
    [K in KeyPaths<Inputs>[number][0] as K extends keyof T ? T[K] extends NonNullable<T[K]> ? K : never : never]:
        K extends keyof T
            ? OmitFirstLevel<KeyPaths<Inputs>, K> extends []
                ? T[K] extends object
                    ? T[K] extends ReadonlyArray<unknown>
                        ? never[]
                        : Record<never, never>
                    : T[K]
                : T[K] extends ReadonlyArray<unknown>
                    ? Construct<OmitFirstLevel<KeyPaths<Inputs>, K>, T[K][number]>[]
                    : Construct<OmitFirstLevel<KeyPaths<Inputs>, K>, T[K]>
            : never;
}

Essentially, we are only getting the keys that are required, and then constructing the type for those keys, recursively. If there are no further keys provided for an array or object type, it's outputted as never[] or Record<never, never>. You never said what you expected, but if you want the entire type instead, replace never[] and Record<never, never> with T[K].

If the type to be constructed is an array, we get the type of the elements and construct those, then wrap it back into an array (unwrapping, modifying, then re-wrapping).

The second half of this type is doing the same thing, but for the optional keys. This way, the "optionality" of the keys are preserved.

Finally, here is OmitFirstLevel:

type OmitFirstLevel<
    Keys,
    Target extends string,
    R extends ReadonlyArray<unknown> = [],
> = Keys extends readonly [infer First, ...infer Rest]
    ? First extends readonly [infer T, ...infer Path]
        ? T extends Target
            ? Path extends []
                ? OmitFirstLevel<Rest, Target, R>
                : OmitFirstLevel<Rest, Target, [...R, JoinPath<Path>]>
            : OmitFirstLevel<Rest, Target, R>
        : OmitFirstLevel<Rest, Target, R>
    : R;

It is doing the exact same thing as the type in the question/answer I linked in the comments, with just some small changes to the types to work with our use case.

We can't forget about validating our input, though.

type Valid<Inputs extends string[], O = TheRoot, T = Required<O>> = KeyPaths<Inputs>[number][0] extends keyof T ? Inputs : {
    [K in keyof Inputs]:
        SplitPath<Inputs[K]>[0] extends keyof T
            ? Inputs[K]
            : { error: `'${Inputs[K]}' is not a valid key.` };
};

Unfortunately I could not find a way to validate the nested keys, only the first layer, without removing the convenience of Narrow (because it can't infer the input properly if it gets "too complex").

However it's cool because it tells you exactly which key is invalid. The error appears only under the invalid key.

const shouldGiveError = myFactory(['id', 'username']); 
//                                 ~~~~ 'id' is not a valid key

It still works for complicated valid inputs too:

const veryComplicated = myFactory([
    "username",
    "persons.id",
    "persons.name",
    "persons.children.id",
    "persons.children.name",
    "persons.author.id",
    "persons.author.name",
    "persons.author.friends.id",
]);

const unrealistic = myFactory([ // get their great-great-great-great-great-grandchildren
    "persons.children.children.children.children.children.children"
]);

If something about this solution has undesirable behavior, I am obligated to come back and patch it up, and now to part ways, a playground for you to tinker with.

P.S. I am particularly annoyed that I could not get the validation working for all levels, but I think I can make it work later after examining @Filly's answer.

kelsny
  • 23,009
  • 3
  • 19
  • 48
  • Got this implemented into our company framework, and it works like a charm! Thanks a bunch for the help. Linking this to GraphQL and auto generating graphql queries, makes this super userful! – Jeggy Aug 29 '22 at 19:05
1

There are some type constraints missing, but this should be close enough. I added some comments, if you need more information about how this works, just let me know.

type Author = {
  id: number;
  name: string;
  articlesAbout: Person[];
  friends: Author[];
};

type Person = {
  id: number;
  name: string;
  children: Person[];
  author?: Author;
}

type TheRoot = {
  username: string;
  persons: Person[];
};




// Check if a tuple of valid keys is able to resolve the lookup
type CheckInput<T, LookUp> =
  T extends [infer Key extends keyof LookUp, ...infer Tail extends string[]]
  ? undefined extends LookUp[Key] ? CheckInput<Tail, LookUp[Key] extends any[] ? Exclude<LookUp[Key][number], undefined> : Exclude<LookUp[Key], undefined>> : CheckInput<Tail, LookUp[Key] extends any[] ? LookUp[Key][number] : LookUp[Key]>
  : T extends [] ? true : false

// checks every keypath in the input and if all are valid the keyspaths, they are returned. 
// Otherwise we create an tuple with an error msg

type CheckAllInputs<T, LookUp, Initial = T> =
  T extends [infer Head extends string, ...infer Tail extends string[]]
  ? CheckInput<Split<Sanatize<Head>, ".">, LookUp> extends false ?
  `${Head} doesn't exist on root` : CheckAllInputs<Tail, LookUp, Initial> : Initial



// https://stackoverflow.com/questions/71889103/infer-exact-value-typescript
export type Narrowable = string | number | bigint | boolean;
export type Narrow<A> =
  | (A extends Narrowable ? A : never)
  | (A extends [] ? [] : never)
  | {
    [K in keyof A]: A[K] extends Function ? A[K] : Narrow<A[K]>;
  };

// splits a string by its delimiter
type Split<
  T extends string,
  TSplit extends string,
  TAgg extends string[] = [],
  > = T extends `${infer Head}${TSplit}${infer Tail}`
  ? Split<Tail, TSplit, [...TAgg, Head]>
  : [...TAgg, T];

// remove all brackets from string
type Sanatize<T extends string, TAgg extends string = ""> =
  T extends `${infer KeyPath}(${string})${infer Rest}`
  ? Sanatize<Rest, `${TAgg}${KeyPath}`> : `${TAgg}${T}`


// looks up a tuple chain of keys of a specific object
type ResolveKeyPath<TKeyPath extends string[], LookUp> =
  TKeyPath extends [infer Key extends keyof LookUp, ...infer Tail extends string[]]
  ? undefined extends LookUp[Key] ? {
    [K in Key]?:
    LookUp[Key] extends any[]
    ? ResolveKeyPath<Tail, Exclude<LookUp[Key][number], undefined>>[]
    : ResolveKeyPath<Tail, Exclude<LookUp[Key], undefined>>
  } : {
    [K in Key]:
    LookUp[Key] extends any[]
    ? ResolveKeyPath<Tail, LookUp[Key][number]>[]
    : ResolveKeyPath<Tail, LookUp[Key]>
  }
  : LookUp



// maps over all keys and joins everything together
type TypeFactory<T extends string[], Lookup, TAgg = {}> = T extends [infer Head extends string, ...infer Tail extends string[]]
  ? TypeFactory<Tail, Lookup, TAgg & ResolveKeyPath<Split<Sanatize<Head>, ".">, Lookup>> : TAgg


function myFactory<T>(inputs: CheckAllInputs<Narrow<T>, TheRoot>): T extends string[] ? TypeFactory<T, TheRoot> : never {
  throw new Error("Not implemented")
}


const ignoreParenthesis = myFactory(['id', 'username(asLowerCase: true)']); // "id doesn't exist on root"
const shouldGiveError = myFactory(['id', 'username']); // id doesn't esxit in root
const shouldGiveError2 = myFactory(['persons.author.name.some', 'username']); // persons.author.name.some doesn't exit in root
const calculated = myFactory(['username', 'persons.id', 'persons.name']); // valid

playground

Filly
  • 411
  • 2
  • 8