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.

12
  • 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? Commented Aug 23, 2022 at 8: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. Commented Aug 23, 2022 at 9:15
  • Are you looking for something like this, this, this ? Commented Aug 23, 2022 at 12:55
  • 1
    @captain-yossarianfromUkraine I think its more like this question Commented Aug 23, 2022 at 13:52
  • 1
    Is this sufficient?. Commented Aug 26, 2022 at 19:41

2 Answers 2

2
+250

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.

Sign up to request clarification or add additional context in comments.

1 Comment

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!
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

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.