5

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)

Martin Adámek
  • 16,771
  • 5
  • 45
  • 64

1 Answers1

4

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

jcalz
  • 264,269
  • 27
  • 359
  • 360
  • Wow, thanks, I would have never though about something like that! Looks like it helps. If I may, I have one more small issue with that code - it also accepts internal methods on string/number/scalar prototypes, so `friend.name.charAt` is still valid - do you have an idea how to get around that? I guess I will need to blacklist types that I do not want to be expanded somewhere. – Martin Adámek Jul 08 '21 at 13:59
  • While I'm happy to help, that is outside the scope of the question (I don't see it mentioned anywhere; it was true of your original code and not just my solution; and adding it to this question now would be a fairly sizable scope creep) and comments are not a great place to answer separate questions. My guess is that you can only expand types that extend `object`, which would prohibit primitives like `string`/`number` without explicit blacklisting... – jcalz Jul 08 '21 at 14:10
  • Would this also work to type [lodash get](https://lodash.com/docs/#get)? – Vencovsky Jul 16 '21 at 11:39
  • @jcalz Hi there, added a follow up question about mapping this information to the return type, would be great if you could take a look, thanks in advance! https://stackoverflow.com/questions/68454737/mapping-of-strict-path-notation-array-type – Martin Adámek Jul 20 '21 at 12:16