1

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)

2
  • Typescript doesn't really give you much control over how it displays complex types. If the type is weirdly displayed, but correct, then you may have to make peace with the fact it won't be pretty. Commented Mar 9, 2022 at 7:08
  • The more I use TypeScript, the more I find myself wanting types (and their representation in IntelliSense) to become expressions in the runtime language. From time to time, I have to pause and remind myself that this is not the state of things, and that IntelliSense is not (currently) designed to be a configurable device for presentation, but rather like an x-ray machine: providing raw output of the state of the AST at a location. Commented Mar 9, 2022 at 9:19

2 Answers 2

3

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

Sign up to request clarification or add additional context in comments.

1 Comment

Great solution! It does "overexpand" (ExpandBox<Box<{position: A}>> should yield Box<{position: A}>) but that's easily fixable by sticking T extends Value ? T : /* ... */ in the definition of Expand (playground link too long for comment)
1

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

1 Comment

While this does work, we can make it considerably more flexible (and fast because of all the conditionals and infers) using mapped types.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.