4

This is a typescript question, i will try to explain as good as possible. Find a link to a typescript playground with an illustration of the problem at the bottom.

It is about react-hook-form, which has a type Path<TFieldValues> (see https://github.com/react-hook-form/react-hook-form/blob/274d8fb950f9944547921849fb6b3ee6e879e358/src/types/utils.ts#L86), which is a string identifier for a possible nested field in dot-notation (i.e. path, path.subpath, path.subpath.subpath etc.) for a given structure TFieldValues, which are the form values. Its hooks require this Path<TFieldValues> when specifying a fieldName, which is desired and nice.

But i have a problem deriving such a Path<TFieldValues> variable, when i have a structure definition which i recursively iterate. What i am doing is, to traverse a structure definition (that conforms to the TFieldValues structure) with information on the ui. Then i traverse it and generate the UI for that, using useController. However i cannot find a way to have a strongly typed Path<TFieldValues>. You can see in the complete example in the link below, the gist:

// Simplified from react-hook-form, we only care about fieldName typing
const useController = <TFieldValues extends Record<string, any>>(fieldName: Path<TFieldValues>) => {};

// Could be something arbitrary but a fixed structure is given
const myValues = {
    number: 1,
    string: 'string',
    sub: {
        number: 2,
        sub: {
            string: 'string',
        },
    },
};

// This schema will be recursively walked to generate a UI, it is strongly typed to conform to myValues but that would exceed this post
const schema = {
    number: 'Number Field',
    string: 'String Field',
    sub: {
        number: 'Sub Number Field',
        sub: {
            string: 'Sub Sub String Field',
        },
    },
};

// This walk function illustrates the problem, as i cannot determine how to derive a valid path when recursive iterating
const walk = (node: Record<string, any>, path?: Path<typeof myValues>) => {
    const keys = Object.keys(node);
    for (let key in keys) {
        if (typeof node[key] === 'string') {
            useController<typeof myValues>(path ? `${path}.${key}` : key);
            // Show UI etc...
        } else {
            walk(node[key], path ? `${path}.${key}` : key);
        }
    }
}

// would be called
walk(schema);

Here is the problem illustrated in the typescript playground: TS Playground

P.S: Please do not suggest using as, any or something like that. I know i could cast it to make it work. But i want strong typing.

EDIT

A little edit here, as i was doing some more research. I think the problem can be simplified for better understanding. The main point is appending a new key to Path<X> and still get a valid Path<X> in return. If we can solve that, typing the recursive iteration should not be the problem. This essentially can be expressed in a simple function

declare function appendToPath<T>(path: Path<T>, appendKey: ???): Path<T>; 

I tried to improve that, and have at least validly typed function parameters that behave correctly:

// Helper to determine valid keys for a nested object value
type NestedObjectKey<
    T extends FieldPathValue<TFieldValues, TFieldPath>,
    TFieldValues extends FieldValues = FieldValues,
    TFieldPath extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = T extends Record<string, any> ? keyof T : never;

// Append
function appendToPath<
    TFieldValues extends FieldValues = FieldValues,
    TFieldPath extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>(path: TFieldPath, keyToAppend: NestedObjectKey<FieldPathValue<TFieldValues, TFieldPath>>): Path<TFieldValues> {
    return `${path}.${keyToAppend}`; // this still raises an error
}

So from a human standpoint this should be enough logically and works as far using the function and its function signature is concerned. However unfortunately TS cannot infer this information as a human can when assembling the path and i have not found a way to do so so far. Last resort would be a custom type assertion, but that would add unnecessary code.

Another way to look at it from a human standpoint is a ${Path<A>}.${Path<B>} is still a Path<A> variable if FieldValue<A, Path<A>> = B. I don't know if that could be translated to a working typescript implementation

patman
  • 2,780
  • 4
  • 30
  • 54
  • AFAIK, you are not allowed to call react hook inside `if condition` – captain-yossarian from Ukraine Nov 22 '21 at 15:30
  • 1
    I know. But this is unrelated. This is just an illustration. `useController` might not even be a hook. Maybe i could have chosen this better for the example. – patman Nov 22 '21 at 16:08
  • 1
    This feels like a similar problem to typing "safe-get" operations: https://dev.to/tipsy_dev/advanced-typescript-reinventing-lodash-get-4fhe – ProdigySim Nov 26 '21 at 16:49
  • well, thanks for your input. It provides a good explaination on the used types. However this is already implemented in the types provided by `react-hook-form` (and also the reason why you need specify the `Path`). In the end the problem really is the recursive iteration and building the path dynamically within not the safe getting of it's value. In the end the iteration in this post using `.reduce` is typed as `(value as any)?.[key],` – patman Nov 26 '21 at 18:01
  • [Here](https://stackoverflow.com/questions/69126879/typescript-deep-keyof-of-a-nested-object-with-related-type#answer-69129328) you can find alternative implementation of Path util type – captain-yossarian from Ukraine Nov 29 '21 at 07:35

1 Answers1

4

The fundamental problem is that your walk function has a lot of variables which are just typed as string instead of as a specific string literal:

  1. Templated strings like ${path}.${key} get type string.
  2. Object.keys() always returns string[].
  3. node: Record<string, any> means that the keys of node are any string.

Getting this strictly typed requires a combination of generic type parameters and as assertions. There are many pieces to the puzzle.

To resolve #1, we can define a function that joins two string and returns the correct literal string value.

const joinPath = <Path extends string, Key extends string>(path: Path, key: Key) =>
  `${path}.${key}` as `${Path}.${Key}`

const myPath = joinPath('sub', 'number');

Now the type for myPath is "sub.number" instead of string.

#2 can be fixed with a simple as assertion const keys = Object.keys(node) as (keyof typeof node)[], but it won't give us any useful information until we resolve #3, because the type for the specific keys of node is still just string.

In order for this to be strongly-typed for multiple levels of depth we need an association between the types of node and path. We would need to know that the type of the current node matches the type from accessing the path on the root object. But that's going to make this really complicated. Assigning a variable to a conditional type like Path or PathImpl always requires an assertion. It's going to be a real headache, so you might consider something less strict.


Vaguely Type-Safe

type RecursiveSchema = {
  [K: string]: RecursiveSchema | string;
}

We can define a somewhat loose definition for your schema object. The RecursiveSchema is an object with string keys. The value for each key is either a string or another level of RecursiveSchema.

Because of how loose this is, Path<RecursiveSchema> is just string. So it's easy to assign to it and our issues #1-#3 above don't matter.

We need to assign node[key] to a variable in the walk function in order for the if statement to properly refine the type. But that's basically it. We don't need any generics or any as assertions. This function passes all type checks:

const walk = (node: RecursiveSchema, path?: string) => {
  const keys = Object.keys(node);
  for (let key in keys) {
    const value = node[key];
    if (typeof value === 'string') {
      useController<RecursiveSchema>(path ? `${path}.${key}` : key);
    } else {
      walk(value, path ? `${path}.${key}` : key);
    }
  }
}
Linda Paiste
  • 38,446
  • 6
  • 64
  • 102
  • Well thank you Linda. I tried to address all the problems you mentioned myself. But even then i had no luck so far. Anyway, all valid points. While your code is valid, it does not 100% solve my problem. The types i am using are given by the library mentioned. As far as i can see as soon as `RecursiveSchema` is not an abstract unlimit deep thing but a concrete fixed implementation it does not work, with the same problems. Still some interesting points that might help and might improve my problem. – patman Nov 27 '21 at 00:18
  • @patman yes `RecursiveSchema` is a fixed implementation, but it does have infinite depth. – Linda Paiste Nov 27 '21 at 21:00