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.