Fold a union of nested objects and tuples into one structure whose leaves carry the unions. The hard part is stopping mapped types from distributing.
A union of shapes goes in, a single shape with union-typed leaves comes out.
Collapse<T> walks a union of structures that share a layout and folds them into one structure. Wherever the members disagreed, the result holds a union of everything that was there. Objects merge key by key, tuples merge slot by slot, and the recursion keeps going until it reaches values that have no inner structure left.
type A = [{ a: { x: 1 }, b: [1, 2] }, 10]
type B = [{ a: { x: 2 }, b: [3, 4] }, 20]
type Result = Collapse<A | B>
// ^? [
// { a: { x: 1 | 2 }, b: [1 | 3, 2 | 4] },
// 10 | 20
// ]Note that this throws precision away on purpose. After the collapse, nothing records that { x: 1 } belonged with [1, 2] and { x: 2 } belonged with [3, 4]. The upstream author suggests it mainly for demonstration, or for very large unions where distributive conditional types start to slow the checker down.
Implement a type that collapses a union of nested structures (objects, tuples, function returns) into a single type containing the union of all deepest primitive values.
type A = [{ a: { x: 1 }, b: [1, 2] }, 10]
type B = [{ a: { x: 2 }, b: [3, 4] }, 20]
type Result = Collapse<A | B>
// ^? [
// { a: { x: 1 | 2 }, b: [1 | 3, 2 | 4] },
// 10 | 20
// ]Warning:* Collapse is an operation that worsens type precision.
Only use for:
View on GitHub: https://tsch.js.org/37745
Change the following code to make the test cases pass (no type check errors).
/* _____________ Your Code Here _____________ */
type Collapse<T> = any
/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'
type cases = [
Expect<Equal<Collapse<1 | 2 | 3>, 1 | 2 | 3>>,
Expect<Equal<Collapse<{a: 1} | {a: 2} | {a: 3}>, {a: 1 | 2 | 3}>>,
Expect<Equal<Collapse<[1, 2, 3] | ['1', '2', '3']>, [1 | '1', 2 | '2', 3 | '3']>>,
Expect<Equal<
Collapse<
{a: 1, f: (b: 1, c: 1) => (d: 1, e: [1, 1] | ['1', '1']) => [1, 1] | ['1', '1']} |
{a: 2, f: (b: 2, c: 2) => (d: 2, e: [2, 2] | ['2', '2']) => [2, 2] | ['2', '2']}
>,
Get access to all 200+ challenges, including every medium, hard, and extreme one.
Monthly subscription.
Cancel anytime + 30-day money-back guarantee.
The solution in full:
type Collapse<T> = [T] extends [(...args: any[]) => any]
? CollapseFunction<T>
: [T] extends [readonly unknown[]]
? CollapseTuple<T>
: [T] extends [object]
? CollapseObject<T>
: T
type CollapseFunction<T> = T extends (...args: infer A) => infer R
? (...args: A) => Collapse<R>
: never
type CollapseTuple<
T extends readonly unknown[],
Acc extends unknown[] = [],
> = number extends T['length']
? Collapse<T[number]>[]
: Acc['length'] extends T['length']
? Acc
: CollapseTuple<T, [...Acc, Collapse<T[Acc['length']]>]>
type CollapseObject<T, K extends keyof T = keyof T> = {
[P in K]: Collapse<T[P]>
}Most of the work is spent fighting one feature of the language, so start there.
A conditional type whose checked type is a bare type parameter splits a union apart and runs the branch once per member. Mapped types written as { [K in keyof T]: ... } do the same thing: that shape is called homomorphic, and it maps over each union member separately. So the obvious first attempt falls apart immediately:
type Naive<T> = T extends object ? { [K in keyof T]: Naive<T[K]> } : T
type Wrong = Naive<{ a: 1 } | { a: 2 }>
// { a: 1 } | { a: 2 }, the union is still thereEvery step of this challenge needs the opposite behaviour. The union has to stay whole so the members can be looked at together.
Wrapping both sides of a conditional in a one-element tuple stops the split: [T] extends [object] asks whether the whole union is an object, without taking it apart. That is why the three branches of Collapse all use the bracket form, and why the dispatch order matters. A function is an object and a tuple is an object, so functions are tested first, tuples second, and plain objects last. Anything that reaches the final branch has no inner structure to recurse into, so it is returned unchanged. That is what makes Collapse<1 | 2 | 3> give back 1 | 2 | 3.
With the union intact, a single indexed access does the merging for you. Reading a property off a union type returns the union of that property across all members:
type Props = ({ a: 1 } | { a: 2 } | { a: 3 })['a']
// 1 | 2 | 3So CollapseObject only has to visit each key once and recurse on what it finds there. The extra type parameter K extends keyof T = keyof T is doing quiet but important work: writing { [P in K]: ... } instead of { [P in keyof T]: ... } means the mapped type is no longer the homomorphic shape, so it will not distribute. K still defaults to keyof T, which for a union is the set of keys shared by every member.
A mapped type over a tuple is homomorphic too, so the same escape is needed, and here there is no key set to hide behind. CollapseTuple instead builds the result one slot at a time, using the length of an accumulator as the current index:
// T = [1, 2, 3] | ['1', '2', '3']
// Acc = [] -> T[0] is 1 | '1' -> Acc = [1 | '1']
// Acc = [1 | '1'] -> T[1] is 2 | '2' -> Acc = [1 | '1', 2 | '2']
// ...until Acc['length'] matches T['length'], which is 3T[Acc['length']] is the same indexed-access trick as before, just with a numeric key, and it collects the elements sitting at that position in every member of the union. The recursion terminates because Acc grows by exactly one element per step and T['length'] is a fixed literal. The number extends T['length'] guard at the top catches open-ended arrays, where that counter would never arrive.
Function types get the opposite treatment. CollapseFunction<T> puts T in the naked position on purpose, so a union of functions distributes and each signature is rebuilt on its own. Inside, infer A captures the parameter list as a labelled tuple, which is spread straight back into (...args: A), and only the return type is handed to Collapse:
type F = Collapse<
((d: 1, e: [1, 1] | ['1', '1']) => [1, 1] | ['1', '1'])
>
// (d: 1, e: [1, 1] | ['1', '1']) => [1 | '1', 1 | '1']Parameters are left exactly as they were, including the union in e. Merging them would be unsound, since a function that accepts 1 and a function that accepts 2 share no common caller.
Collapse<1 | 2 | 3>: no branch matches, so the union is returned untouched instead of being rebuilt.Collapse<[1, 2, 3] | ['1', '2', '3']>: positions are preserved, giving [1 | '1', 2 | '2', 3 | '3'] rather than three copies of 1 | 2 | 3 | '1' | '2' | '3'.[1, { a: { b: 1, c: { d: [1] } }, e: 1 }, [[1]], 1], recurse through every layer until only leaves are left.This challenge is originally from here.
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges