For context, in my database I have one-to-one and one-to-many relationships, which I'm trying to express in typescript. Instead of creating several variations of a type, I would like to create one type that allows me to expand relationships using utility types I'm creating (e.g. ExpandOne<>
, ExpandAll<>
).
Spec
Everything I would like to have
Is this even possible?
// UTILITIES
type ID = string
type Expandable<Entity> = ID | Entity
type ExpandableArray<Entity> = undefined | Entity[]
type Car<L extends any[]> = L extends [infer Head, ...infer _] ? Head : never
type Cdr<L extends any[]> = L extends [infer _, ...infer Rest] ? Rest : never
type ExpandOne<T extends Record<string, any>, Expand extends any[] | []> = {
[Key in keyof T]: T[Key] extends Expandable<infer Entity extends Record<string, any>>
? Key extends Car<Expand>
? ExpandOne<Entity, Cdr<Expand>>
: string
: T[Key]
}
// TODO: Call ExpandOne on multiple properties
// type ExpandAll
// DATABASE TYPES
interface PostCategory {
description: string
}
interface PostComment {
content: string
}
interface Post {
content: string
comments: ExpandableArray<PostComment>
category: Expandable<PostCategory>
}
interface User {
username: string
posts: Expandable<Post[]>
comments: ExpandableArray<PostComment>
}
// USAGE
// expand one level deep
const expandedUserPost: ExpandOne<User, ['posts']> = {} as any
expandedUserPost.posts
// expand two levels deep
const expandedUserPostComments: ExpandOne<User, ['posts', 'comments']> = {} as any
expandedUserPostComments.posts[0].comments
// expand multiple
const expandMultiple: ExpandAll<User, [['posts', 'comments'], ['comments']]> = {} as any
expandMultiple.posts[0].comments
expandMultiple.comments
// bonus!
// would be nice if ExpandAll<> assumed that any Expandable<> that hasn't been expanded is a string (database id) and any ExpandableArray<> that hasn't been expanded is undefined
const expandNonePost: ExpandAll<Post, []> = {} as any
expandNonePost.category // type should be string
// NOTE
// Expandable<> is similar to ExpandableArray<>
// except when something is not exapnded instead of being undefined its a database id (string)
const expandPost: ExpandOne<Post, ['category']> = {} as any
expandPost.category.description
What I want to avoid
interface Posts {
content: string
}
interface User {
username: string
posts: undefined
}
// I would prefer not to create multiple variations of each type
interface UserExpandedPosts {
username: string
posts: Posts[]
}
The big picture
// Using this system I can create a JavaScript function that tells
// my backend to expand relationships and my types at the same time
function fetchData(configFunction, expansions) {
return fetch()
}
const data = await fetchData(getUser(), [['posts'], ['somethingElse']])
// Assuming the function getUser() has an expandable database type
// associated with it, fetchData would know how to both ask my
// backend to expand the entity as well as expanding matching TypeScript type
// In other words as long as my expansion doesn't fail, the return type of
// fetchData would always match the data (including expansions I asked for)
CodePudding user response:
// UTILITIES
declare const explandBrand: unique symbol
type Expandable<Entity> = string & { [explandBrand]: Entity }
//
type Expand1<T, E extends string[]> =
| T extends Expandable<infer X extends any[]> ? Expand1<X[number], E>[]
: T extends Expandable<infer X> ? Expand1<X, E>
: E extends [infer F extends keyof T, ...infer L extends string[]] ?
| {
[K in keyof T]: F extends K ? Expand1<T[K], L> : T[K]
}
: T;
type x1 = Expand1<User, ['posts']>
// ^?
type x3 = Expand1<x1, ['comments']>
// ^?
type x4 = Expand1<User, ['posts', 'comments']>
// ^?
type Expand2<T, A extends string[][]> =
| A extends [infer F extends string[], ...infer L extends string[][]] ? Expand2<Expand1<T, F>, L>
: T
type x5 = Expand2<User, [['posts', 'comments'], ['comments']]>
// ^?
It is string
for ids, for arrays it's also a string
id, modify if you need that