Home > Back-end >  How to make TypeScript display this type properly? (recursive type with generics)
How to make TypeScript display this type properly? (recursive type with generics)

Time:03-09

Given a box:

type A = {readonly id: 'A'}
type B = {readonly id: 'B'}
type Value = A | B

class Box<T extends Value | {[x: string]: Value}> {
  constructor(public value: T) {}
}

I would like to create a function merge:

let a = {id: 'A'} as A
let b = {id: 'B'} as B

let merged = merge(
  new Box({position: a}),
  {velocity: new Box(a), color: new Box(b)},
  {mass: new Box(b)}
)

Such that the type of merged is Box<{position: A, velocity: A, color: B, mass: B}>

Here's what I came up with:

// {position: Box<A>} => {position: A}
// Box<{position: A}> => {position: A}
type InferBoxValue<B extends {[x: string]: Box<Value>} | Box<{[x: string]: Value}>> =
  B extends {[x: string]: Box<Value>} ? {[K in keyof B]: B[K] extends Box<infer F> ? F : unknown} :
  B extends Box<infer F> ? F :
  unknown

// [{color: Box<A>}, Box<{mass: B}>] => [{color: A}, {mass: B}]
type InferMultipleBoxValues<A extends any[]> = {
  [I in keyof A]: A[I] extends {[x: string]: Box<Value>} | Box<{[x: string]: Value}>
    ? InferBoxValue<A[I]>
    : unknown
}

// MergeTwo<{color: A}, {mass: B}> => {color: A, mass: B}
type MergeTwo<A, B> = {
  [K in keyof (A & B)]: K extends keyof B ? B[K] : K extends keyof A ? A[K] : unknown
}

// Merge<[{color: A}, {mass: B}, ...]> => {color: A, mass: B, ...}
type Merge<A extends readonly {[x: string]: Value}[]> = A extends [infer L, ...infer R] ?
  R extends {[x: string]: Value}[] ? MergeTwo<L, Merge<R>> : unknown : unknown

function merge<
  B extends ({[x: string]: Box<Value>} | Box<{[x: string]: Value}>)[],
  M extends Merge<InferMultipleBoxValues<B>>
>(...boxes: B): M extends {[x: string]: Value} ? Box<M> : unknown {
  return null as any // implementation is trivial
}

This works to a degree. When I hover over merged within VSCode I see this type:

Box<MergeTwo<{position: A}, MergeTwo<{velocity: A; color: B}, MergeTwo<{mass: B}, unknown>>>>

I can tell that's equivalent to the type I want, since this works:

let test: Box<{position: A, velocity: A, color: B, mass: B}> = null as unknown as (
  Box<MergeTwo<{position: A}, MergeTwo<{velocity: A; color: B}, MergeTwo<{mass: B}, unknown>>>>
)

But that's not quite what I'm looking for. I do not want users of Box to see any of InferBoxValue, InferMultipleBoxValues, MergeTwo or Merge. How can I adjust my implementation of merge so TypeScript displays the results correctly?

Thanks for taking a look at this!

Using TypeScript v4.6.2 (Playground Link)

CodePudding user response:

Actually @ashtonsix, can do miles better than that!

With this interesting type:

type Expand<T> = T extends {} ? { [K in keyof T]: Expand<T[K]> } & {} : T;

The way it works is that it explicitly extracts everything from the type into a mapped type which means it displays as an "object literal". If the type is a primitive it doesn't do anything, and this type recursively expands too.

And let's test it:

type Target = Box<MergeTwo<{position: A}, MergeTwo<{velocity: A; color: B}, MergeTwo<{mass: B}, unknown>>>>;

type Expansion = Expand<Target>;

EDIT: Oh I just noticed that you want it to be like Box<...>, which is understandable considering it's just an object with a key. I guess you could remedy that easily with:

type ExpandBox<T extends Box<{}>> = Box<Expand<T>["value"]>;

type Expansion = ExpandBox<Target>;

Then expanding Target above would result

Playground

Much of the credit goes to Cass

CodePudding user response:

I found a solution. Since we don't want MergeTwo to show up in our type definition we can simply inline it. Here is our new implemention of Merge:

type Merge<Q extends readonly {[x: string]: Value}[]> =
  Q extends [] ? {} :
  Q extends [infer A] ? A :
  Q extends [infer A, infer B] ? {[K in keyof (A & B)]: K extends keyof B ? B[K] : K extends keyof A ? A[K] : unknown} :
  Q extends [infer A, infer B, infer C] ? {[K in keyof (A & B & C)]: K extends keyof C ? C[K] : K extends keyof B ? B[K] : K extends keyof A ? A[K] : unknown} :
  Q extends [infer A, infer B, infer C, infer D] ? {[K in keyof (A & B & C & D)]: K extends keyof D ? D[K] : K extends keyof C ? C[K] : K extends keyof B ? B[K] : K extends keyof A ? A[K] : unknown} :
  Q extends [infer A, infer B, infer C, infer D, infer E] ? {[K in keyof (A & B & C & D & E)]: K extends keyof E ? E[K] : K extends keyof D ? D[K] : K extends keyof C ? C[K] : K extends keyof B ? B[K] : K extends keyof A ? A[K] : unknown} :
  Q extends [infer A, infer B, infer C, infer D, infer E, infer F] ? {[K in keyof (A & B & C & D & E & F)]: K extends keyof F ? F[K] : K extends keyof E ? E[K] : K extends keyof D ? D[K] : K extends keyof C ? C[K] : K extends keyof B ? B[K] : K extends keyof A ? A[K] : unknown} :
  Q extends [infer L, ...infer R] ? R extends {[x: string]: Value}[] ? MergeTwo<L, Merge<R>> : unknown :
  unknown

We now observe that the type of merged is Box<{position: A, velocity: A, color: B, mass: B}>:

let a = {id: 'A'} as A
let b = {id: 'B'} as B

let merged = merge(
  new Box({position: a}),
  {velocity: new Box(a), color: new Box(b)},
  {mass: new Box(b)}
)

merge now creates nice-looking types when called with <= 6 arguments, and falls back to producing correct-ish types when called with more.

Playground Link

  • Related