2

I have to currently work on a servienow project, which apperently does not focus to much on DX. This is why I wanted to add some typedefinitions to make it much less prone to errors.

Basically I have tables of the database which can be retrived using a class. The syntax looks more or less like new Query("table").where("field", fieldValue). The type of fieldValue depends on the type of the field. I am extracting the table information and store them inside typescript files. This works so far. But there is the possibility to also access fields related tables like this: new Query("table").where("ref.otherField", fieldValue)

The types can be infinit nested, because for example the user table might have a reference to the manager, which points again to the user table. This is way I only want to support up to two related fields and if the developer needs more I want to just accept any.

My goal is to basically create a type containing all possible strings and the corresponding values and then use this type for the where function. For this I have to add the name of the reference field to the fields of the reference table:

type AppendPrefix<prefix extends string, Object extends Record<string, any>> = {
    [key in `${prefix}.${keyof Object}`]: key extends `${prefix}.${infer Field}`
    ? Object[Field] extends Reference<any>
    ? sys_id | null
    : Object[Field]
    : never;
};

This also works so far for a given prefix and a table. But I do not get it to work if I have two fields with two different tables. Then only the overlap of those tables is taken, for example if both tables have a field called name i get something like this:

{
  ...
  "prefix1.name": string;
  "prefix2.name": string;
}

I am trying to get all the prefixed types like this:

type AddReferenceTable<Table extends keyof Tables.List, Field extends keyof Tables.List[Table]> = AppendPrefix<
    Field,
    GetReferenceTable<Table, Field>
>;

type GetReferenceTable<
    Table extends keyof Tables.List,
    Field extends keyof Tables.List[Table]
> = Tables.List[Table][Field] extends Reference<infer T>
    ? Tables.List[T]
    : never;

Here is a whole code example: Playground

Can somebody tell what I am doing wrong? Or is the way I am trying to solve the problem bad and there is a much better way?

Thank you!

  • COuld you please provide an example of expected result? I am not sure what went wrong. I fixed some type errors [here](https://tsplay.dev/mqxGZN) but I am not sure whether it meets your expectations or not – captain-yossarian from Ukraine Jun 16 '23 at 08:32
  • Well to be honest it seems like your fixes resolved the problem with the merge itself, which makes me feel quite stupid now :D. But it appears that my aproach is not working as I would expect: In line 137 (127 in the link bellow) I should be able to write something like `.where("manager.name", "Yossarian")` but it wont work. If I check the type it contains all the keys, but it seems it wont retrieve them using keyof in line 108 [here](https://tsplay.dev/w29DxW) – QuantumZero Jun 16 '23 at 08:52
  • not sure I understand `.where("name", "Josef")` is working, there is an error in this line `.where("manger.priority"` – captain-yossarian from Ukraine Jun 16 '23 at 09:05
  • Yes `.where("name", "Josef")` works, but `.where("manger.priority", 5)` should also work. It seems the typing of `where` wont allow the prefixed keys for reasons I do not understand. – QuantumZero Jun 16 '23 at 09:10
  • could you please provide a very simple example of a type which you want to implement. You already created a lot of types, hard to track all changes. I mean please create input data type and an expected type. Meanwhile I will try to understand the logic – captain-yossarian from Ukraine Jun 16 '23 at 09:13
  • [Here](https://tsplay.dev/wO5Mzm) is a minimal example. Thank you for your help! – QuantumZero Jun 16 '23 at 10:52
  • @QuantumZero please include `Query` or replace it with some mock – wonderflame Jun 16 '23 at 11:25
  • Well `Query` is just some Class and I want to define its method `where`. I am sorry if I failed to explain it proper. [Here](https://tsplay.dev/mLv74w) it is hopefuilly better explained. – QuantumZero Jun 16 '23 at 11:37
  • 1
    @QuantumZero does [this approach](https://tsplay.dev/WJxZ5N) work for you? If yes, I'll write an answer explaining; If not, what am I missing? – wonderflame Jun 16 '23 at 12:24
  • [This](https://tsplay.dev/wj8z1m) might even better explain my problem. I changed some name and added more structure and comments. I do not understand why `keyof` is not returning all my keys. – QuantumZero Jun 16 '23 at 12:24
  • @wonderflame the output is exactly what I need. But I do not understand how you came up with this solution, am I having a wrong understanding of TS? Thank you so much! Could I maybe ask you for some literature or other material so I can learn how to do it proper? – QuantumZero Jun 16 '23 at 12:30
  • 1
    @QuantumZero do you want me to explain it in the answer? – wonderflame Jun 16 '23 at 12:32
  • Yes please I am having a hard time understanding why I need Depth etc. – QuantumZero Jun 16 '23 at 12:33
  • Okay, will do when I have a chance – wonderflame Jun 16 '23 at 12:33
  • Depth is needed to limit how deep can we go in the types, since without it it would infinitely do this: User->User->User – wonderflame Jun 16 '23 at 12:34
  • Thank you so much. This I understand, but I do not understand how this is actually limiting its depth. Is there a way to buy you a coffee or similar? – QuantumZero Jun 16 '23 at 12:36
  • @QuantumZero I'll explain the details in the answer. Here is the [buymeacoffee](https://bmc.link/wonderflame) – wonderflame Jun 16 '23 at 12:40

1 Answers1

2

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

wonderflame
  • 6,759
  • 1
  • 1
  • 17