This is a follow-up to TS strict paths with dot notation in array question, where I was trying to make the strict path notation work with arrays in MikroORM. Now I am trying to map this type to a Loaded
return type that marks the populated Collection
and Reference
properties to their Loaded*
variants recursively.
The problematic bit will be most probably in LoadedIfInKeyHint
subtype that is should resolve to either the original property type (if not populated/found in the P
populate hint), or to LoadedReference/LoadedCollection
(if it was populated based on P
).
It works fine if there is one item in the populate array, even for nested hints, but once there are more arrays, it fails to recurse.
The question is basically only about r022
and r023
variables where the return type is wrong, while r021 is correct. All expected errors are marked with @ts-expect-error
comments, the playground link now produces two errors that are not expected.
Full playground link that should ideally produce no errors.
Demonstration of the problem:
declare const user: User;
declare const book: Book;
declare function find<T, P extends string>(obj: T, options: FindOptions<T, P>): Loaded<T, P>;
const r010 = find(book, { populate: ['author'] })
const r011 = find(book, { populate: ['ref1'] })
const r021 = find(book, { populate: ['ref1.books'] })
console.log(r021.ref1.$.books.$[0].author);
// @ts-expect-error
console.log(r021.ref1.$.books.$[0].ref1.$);
// this fails as the type of `ref1` is resolved to `LoadedReference<User, User>` instead of
// `LoadedReference<User, User & {
// books: LoadedCollection<Book, Book>;
// friends: Collection<User>;
// }>;`
// as in `r021` that populates just `ref1.books` and not `ref1`
// (the `title` here should be irrelevant, as `r023` reproduces the same problem without it)
const r022 = find(book, { populate: ['ref1', 'ref1.books', 'title'] })
console.log(r022.ref1.$.books.$[0].author);
// @ts-expect-error
console.log(r022.ref1.$.books.$[0].ref1.$);
const r023 = find(book, { populate: ['ref1.books', 'ref1'] })
console.log(r023.ref1.$.books.$[0].author);
// @ts-expect-error
console.log(r023.ref1.$.books.$[0].ref1.$);
const r11 = find(user, { populate: ["friends", 'books'] })
const r21 = find(user, { populate: ["friends.books.author", 'books'] })
const r31 = find(user, { populate: ["books.author"] })
const r2 = find(user, { populate: ["friend.books", 'age'] })
const r3 = find(user, { populate: ["friend.books.ref1.age", 'friend.books.ref1.name'] })
const r4 = find(user, { populate: ["friend.books.ref1"] })
const r5 = find(user, { populate: ["friend.name", "books.author.name"] })
const r6 = find(user, { populate: ["friend.name", "books.author.friend.age", "age", 'books.title'] })
Code for AutoPath
and Loaded
types:
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>}`
: T extends object
? `${Exclude<keyof ExtractType<T>, symbol>}`
: never
type GetStringKey<T, K extends StringKeys<T>> = K extends keyof T ? ExtractType<T[K]> : never
type AutoPath<O, P extends string> =
P extends any ?
(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
: never
export interface FindOptions<T, P extends string> {
populate?: AutoPath<T, P>[] | boolean;
}
export type Populate<T, P extends string = string> = AutoPath<T, P>[] | boolean;
export interface LoadedReference<T, P = never> extends Reference<T> {
$: T & P;
get(): T & P;
}
export interface LoadedCollection<T, P = never> extends Collection<T> {
$: readonly (T & P)[];
get(): readonly (T & P)[];
}
export type ExpandProperty<T> = T extends Reference<infer U>
? NonNullable<U>
: T extends Collection<infer U>
? NonNullable<U>
: T extends (infer U)[]
? NonNullable<U>
: NonNullable<T>;
type MarkLoaded<T, P, H = unknown> = P extends Reference<infer U>
? LoadedReference<U, Loaded<U, H>>
: P extends Collection<infer U>
? LoadedCollection<U, Loaded<U, H>>
: T;
type LoadedIfInKeyHint<T, K extends keyof T, H> = [H] extends [`${infer A}.${infer B}`]
? A extends K
? MarkLoaded<T, T[A], B>
: never
: [K] extends [H]
? MarkLoaded<T, T[K]>
: T[K];
// https://medium.com/dailyjs/typescript-create-a-condition-based-subset-types-9d902cea5b8c
type SubType<T, C> = Pick<T, { [K in keyof T]: T[K] extends C ? K : never }[keyof T]>;
type RelationsIn<T> = SubType<T, Collection<any> | Reference<any> | undefined>;
export type Loaded<T, P = unknown> = unknown extends P ? T : T & {
[K in keyof RelationsIn<T>]: LoadedIfInKeyHint<T, K, P>;
}