Since your entities are referencing themselves (User.manager: User)
, this will lead to infinite recursion and the only adequate solution is to limit the depth of recursion. You will need to evaluate this value depending on your use case, but for this solution, the limit will be 2
Let's start with the algorithm:
- Let
ObjWithNoReference
be a type that is the initial type without properties that are references to other tables
- If the depth limit is reached
- return
ObjWithNoReference
- Else
- Map through the keys of the initial type excluding keys of
ObjWithNoReference
to make sure that we are only looping through referenced fields.
- Call the utility type recursively for the inffered type of the table and increment the depth level.
- Prefix the result of the invoked utility type of the referenced table with the name of the key.
- Include
{keyName: sys_id | null}
.
- Return
ObjWithNoReference
and the referenced table data
Implementation:
Utilities:
Prettify
- makes the types shown by IDE more readable and linear(removes &
, .etc)
type Prettify<T> = T extends infer R
? {
[K in keyof R]: R[K];
}
: never;
ValueOf
- Returns the union of all values of the passed type
type ValueOf<T> = T[keyof T];
UnionToIntersection
- Turns the passed union into an intersection. Explained here:
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
k: infer I,
) => void
? I
: never;
DepthLimit
- number indicating the maximum depth of reference:
type DepthLimit = 2;
Depth checking mechanism explanation:
The type that we are going to invoke recursively will accept extra generic parameter of unknown[]
, which is defaulted to []
. On every recursive invocation we are going to push an element to the array to increase its length. If the length of the array is equal to DepthLimit
, we stop calling recursively.
Desired type:
First, let's pick the properties that are not a reference to another table using mapped type:
{
[K in keyof T as T[K] extends Reference<any> ? never : K]: T[K];
} extends infer ObjWithNoReference ? doSomething : neverWillBeReached
Check if we need to go deeper. If the limit is reached then return fields with no reference:
Depth['length'] extends DepthLimit
? ObjWithNoReference
: needToGoRecursively
To retrieve the fields that are references to other tables we will use the mapped type over the initial type with ommited keys of ObjWithNoReference
:
{
[K in Exclude<keyof T, keyof ObjWithNoReference>]: T[K] extends Reference<
infer TableName extends keyof Tables.List>
? callRecursively
: never // won't be reached
}
If T[K]
is a reference we retrieve the name of the able and put it in into infer
variable TableName
and call the DesiredType
for the table with that name, with depth level being increased. Put the result into ReferencedTable
:
DesiredType<Tables.List[TableName],[...Depth, unknown]> extends infer ReferencedTable
? // do something
: never // won't be reached
Expected shape of ReferencedTable
is Record<string, unknown>
where the keys are the keys of the table. However, we need to prefix those keys with the key of the current table and .
. Since we have to add sys_id
under the key of the current table we will add it manually with intersection:
{
[P in keyof ReferencedTable as `${K & string}.${P &
string}`]: ReferencedTable[P];
} & { [P in K]: sys_id | null }
The code so far will give something like this (only reference fields included):
type Test = {
asset: {
asset: sys_id | null;
} & {
"asset.name": string;
"asset.id": number;
};
manager: {
manager: sys_id | null;
} & {
"manager.name": string;
"manager.priority": number;
}
}
We need to get the value of asset
, manager
, not the whole object, since we want flat structure. For that we will use ValueOf
.
If we would pass the example result in the previous code block to ValueOf
we would get:
// ({
// asset: sys_id | null;
// } & {
// "asset.name": string;
// "asset.id": number;
// }) | ({
// manager: sys_id | null;
// } & {
// "manager.name": string;
// "manager.priority": number;
// })
ValueOf<Test>
This is not the expected one, since ValueOf
return union of the values and we will need to use UnionToIntersection
to make then into single object:
// {
// asset: sys_id | null;
// } & {
// 'asset.name': string;
// 'asset.id': number;
// } & {
// manager: sys_id | null;
// } & {
// 'manager.name': string;
// 'manager.priority': number;
// }
UnionToIntersection<ValueOf<Test>>
This is the needed structure, but it's hard to read. Let's improve that with Prettify
:
// {
// asset: sys_id | null;
// 'asset.name': string;
// 'asset.id': number;
// manager: sys_id | null;
// 'manager.name': string;
// 'manager.priority': number;
// }
Prettify<UnionToIntersection<ValueOf<Test>>>
Looks great! Now let's put this whole thing together:
type DesiredType<T, Depth extends unknown[] = []> = Prettify<
{
[K in keyof T as T[K] extends Reference<any> ? never : K]: T[K];
} extends infer ObjWithNoReference
? Depth['length'] extends DepthLimit
? ObjWithNoReference
: ObjWithNoReference &
UnionToIntersection<
ValueOf<{
[K in Exclude<
keyof T,
keyof ObjWithNoReference
>]: T[K] extends Reference<
infer TableName extends keyof Tables.List
>
? DesiredType<
Tables.List[TableName],
[...Depth, unknown]
> extends infer ReferencedTable
? { [P in K]: sys_id | null } & {
[P in keyof ReferencedTable as `${K & string}.${P &
string}`]: ReferencedTable[P];
}
: never
: never;
}>
>
: never
>;
Testing:
// {
// name: string;
// priority: number;
// asset: sys_id | null;
// "asset.name": string;
// "asset.id": number;
// manager: sys_id | null;
// "manager.name": string;
// "manager.priority": number;
// "manager.asset": sys_id | null;
// ... 4 more ...;
// "manager.manager.priority": number;
// }
type Test = DesiredType<Tables.User>
Now we need to call this type in Query.where
:
type Query<Table extends keyof Tables.List> = {
where<
PathObject extends DesiredType<Tables.List[Table]>,
Path extends keyof PathObject,
>(
fieldName: Path,
value: PathObject[Path],
): Query<Table>;
};
new Query('user')
.where('name', 'Josef')
.where('manager', null)
.where('manager.name', 'Yossarian')
.where('manger.priority', 5) // expected error
.where('unknownField', 'someValue') // expected error
.where('name', 5); // expected error
Link to Playground