Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

TS strict paths with dot notation in array

Tags:

typescript

Trying to make the populate parameter in MikroORM strict for the variant with string paths. I managed to implement (more like adjust the one I found on TS discord) the AuthPath type that works based on my needs. It works ok when used with a single parameter, but when used with array, it won't validate correctly if one of the array items is valid.

So the question is, how can I make it work with arrays? Is it even possible? I was trying to use tuple types to get around this, but failed to make it work. I kinda understand the problem is the shared generic type P - I am planning to leverage it in the return type so I need something to bare the actual (inferred) types in the signature from params to return type.

The full playground in here.

Here is the demonstration of the problem:

declare const user: User;
declare function get1<O, P extends string>(obj: O, path: AutoPath<O, P>): void;
declare function get2<O, P extends string>(obj: O, path: AutoPath<O, P>[]): void;

// works fine with single item
get1(user, "friend.books.title")
get1(user, "friend.books.ref1.age")
get1(user, "friend.friend.name")
// @ts-expect-error
get1(user, "friend.friend.www")
// @ts-expect-error
get1(user, "friend.books.www")
// @ts-expect-error
get1(user, "friend.books.ref1.www")

// works fine with array when there is just one item
get2(user, ["friend.name"])
get2(user, ["friend.books.ref1.age"])
// @ts-expect-error
get2(user, ["friend.books.ref1.www"])

// if there are more items it works only sometimes
// @ts-expect-error
get2(user, ["friend.name", "books.author.www"])

// if we add one more item that is valid and on the root level, it will make it pass
get2(user, ["friend.name", "books.author.www", "age"])

Here is the code for AutoPath and the entity type definitions:

class Collection<T> { items?: T[] }
class Reference<T> { item?: T }

type Book = {
  id: string,
  title: string,
  author: User,
  ref1: Reference<User>,
}

type User = {
  id: string,
  name: string,
  age: number,
  friend: User,
  friends: Collection<User>,
  books: Collection<Book>,
}

type ExtractType<T> = T extends Collection<infer U> ? U : (T extends Reference<infer U> ? U : T)
type StringKeys<T> = T extends Collection<any> 
  ? `${Exclude<keyof ExtractType<T>, symbol>}` 
  : T extends Reference<any>
    ? `${Exclude<keyof ExtractType<T>, symbol>}` 
    : `${Exclude<keyof T, symbol>}`
type GetStringKey<T, K extends StringKeys<T>> = K extends keyof T ? ExtractType<T[K]> : never

type AutoPath<O, P extends string> =
  (P & `${string}.` extends never ? P : P & `${string}.`) extends infer Q
    ? Q extends `${infer A}.${infer B}`
      ? A extends StringKeys<O>
        ? `${A}.${AutoPath<GetStringKey<O, A>, B>}`
        : never
      : Q extends StringKeys<O>
        ? (GetStringKey<O, Q> extends unknown ? Exclude<P, `${string}.`> : never) | (StringKeys<GetStringKey<O, Q>> extends never ? never : `${Q}.`)
        : StringKeys<O>
    : never

(the AutoPath type still has some issues, but that is not really important - this question is about how to use it with array of strings instead of a single string parameter)

like image 528
Martin Adámek Avatar asked Jul 08 '21 13:07

Martin Adámek


1 Answers

I think the issue here is that you want AutoPath<O, P> to distribute over unions in P. That is, you want AutoPath<O, P1 | P2> to be equivalent to AutoPath<O, P1> | AutoPath<O, P2>. And sometimes it does not seem to work out that way.

If so, then you can use distributive conditional types to get this behavior. All you need to do is wrap your original definition with P extends any ? ... : never:

type AutoPath<O, P extends string> =
  P extends any ?
  /* ORIGINAL IMPLEMENTATION */
  (P & `${string}.` extends never ? P : P & `${string}.`) extends infer Q
  ? Q extends `${infer A}.${infer B}`
  ? A extends StringKeys<O>
  ? `${A}.${AutoPath<GetStringKey<O, A>, B>}`
  : never
  : Q extends StringKeys<O>
  ? (GetStringKey<O, Q> extends unknown ? Exclude<P, `${string}.`> : never) | (StringKeys<GetStringKey<O, Q>> extends never ? never : `${Q}.`)
  : StringKeys<O>
  : never
  /* END ORIGINAL IMPLEMENTATION */
  : never

And you should hopefully get the behavior you want:

get2(user, ["friend.name", "books.author.www", "age"]); // error!
// ----------------------> ~~~~~~~~~~~~~~~~~~

It's always possible that some inference or other behavior you were relying on will be altered by this change, but because the particular implementation of AutoPath and what it's being used for is out of scope for the question, I'll leave it up to you to deal with any such issues.

Playground link to code

like image 78
jcalz Avatar answered Oct 24 '22 07:10

jcalz