Chapter 24

Type Gymnastics: Implementing Awaited, FlatArray, UnionToIntersection

Why Implement These Utility Types from Scratch

TypeScript ships Awaited, Parameters, ReturnType and more built-in. Understanding the type system means being able to reproduce themโ€”because you will hit cases the built-ins don't cover, and you'll need to compose something new. Working through these implementations forces you to understand conditional type evaluation order, the role of infer, and how distributive conditional types behave under edge cases.

This chapter goes in difficulty order. Every utility type shows the wrong naive attempt first, explains why it fails, then shows the correct solution.


1. MyAwaited: Recursively Unwrapping Promises

The Wrong First Attempt

// Wrong: only strips one Promise layer
type MyAwaited_Wrong<T> = T extends Promise<infer U> ? U : T;

type A = MyAwaited_Wrong<Promise<Promise<string>>>;
// Result: Promise<string>  โ€” still has one layer left

The problem: when T is Promise<Promise<string>>, U infers to Promise<string>. Conditional types don't automatically recurse.

Correct Implementation

// Simplified: handles standard Promise, covers 99% of cases
type MyAwaited<T> =
  T extends Promise<infer U>
    ? MyAwaited<U>
    : T;

type B = MyAwaited<Promise<Promise<Promise<string>>>>;
// B = string  โœ“

type C = MyAwaited<number>;
// C = number  โœ“

type D = MyAwaited<Promise<number[]>>;
// D = number[]  โœ“

The full TypeScript 4.5 implementation matches the .then signature rather than Promise<infer U> directlyโ€”because any thenable (not just native Promise) should be awaitable:

type MyAwaited<T> =
  T extends null | undefined
    ? T
    : T extends object & { then(onfulfilled: infer F, ...args: any[]): any }
      ? F extends (value: infer V, ...args: any[]) => any
        ? MyAwaited<V>
        : never
      : T;

Recursion terminates when T no longer extends Promise<infer U> (or the thenable shape), returning T unchanged.


2. FlatArray: Flattening Nested Arrays to a Given Depth

This is the most mechanically complex implementation. JavaScript's Array.prototype.flat(depth) requires recursive conditional types plus simulated numeric subtraction to control depth.

The Wrong Intuition

// Wrong: no depth control, infinite recursion
type FlatArray_Wrong<T> = T extends (infer U)[] ? FlatArray_Wrong<U> : T;

Helper: Simulating Decrement

TypeScript has no numeric subtraction, but tuple length gives us a workaround:

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];
// Prev[3] = 2, Prev[1] = 0, Prev[0] = never

Correct Implementation

type Prev = [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20];

type FlatArray<T, Depth extends number> =
  Depth extends 0
    ? T
    : T extends ReadonlyArray<infer Inner>
      ? FlatArray<Inner, Prev[Depth]>
      : T;

// Testing
type Nested = number[][][];

type F0 = FlatArray<Nested, 0>;  // number[][][]
type F1 = FlatArray<Nested, 1>;  // number[][]
type F2 = FlatArray<Nested, 2>;  // number[]
type F3 = FlatArray<Nested, 3>;  // number

ReadonlyArray<infer Inner> matches both mutable and readonly arraysโ€”Array<T> is a subtype of ReadonlyArray<T>, making this more general than (infer Inner)[].

Wiring it to a function:

declare function myFlat<T extends readonly unknown[], D extends number = 1>(
  arr: T,
  depth?: D
): FlatArray<T, D>[];

const result = myFlat([[[1, 2], [3]], [[4]]], 2);
// Inferred as number[]  โœ“

3. UnionToIntersection: Exploiting Contravariant Inference

This is the implementation that most requires explanation, because it relies on one of the least intuitive rules in TypeScript's type system.

The Wrong Intuition

// Doesn't work: distributive conditional types process each union member separately
type UnionToIntersection_Wrong<U> = U extends any ? U & ??? : never;
// There's no way to "collect" the distributed results back into an intersection

Correct Implementation

type UnionToIntersection<U> =
  (U extends any ? (x: U) => void : never) extends (x: infer I) => void
    ? I
    : never;

// Verification
type UI1 = UnionToIntersection<{ a: 1 } | { b: 2 }>;
// UI1 = { a: 1 } & { b: 2 }  โœ“

type UI2 = UnionToIntersection<string | number>;
// UI2 = never  โœ“ (string & number = never)

type UI3 = UnionToIntersection<((x: string) => void) | ((x: number) => void)>;
// UI3 = ((x: string) => void) & ((x: number) => void)

Why This Works: Contravariant Position

The key is variance:

Step by step:

// Step 1: Distributive conditional type expands the union
// U = A | B | C
// (U extends any ? (x: U) => void : never)
// = ((x: A) => void) | ((x: B) => void) | ((x: C) => void)

// Step 2: infer in contravariant position
// ((x: A) => void) | ((x: B) => void) | ((x: C) => void)
//   extends (x: infer I) => void
// I must satisfy all three function types simultaneously
// Therefore: I = A & B & C

When TypeScript must find a type I such that (x: I) => void is a supertype of the union of those three function types, I must be the intersection of all parameter types. That's contravariant inference in action.


4. TupleToUnion: Tuple Members as a Union

// Method 1: numeric index access (cleanest)
type TupleToUnion<T extends readonly any[]> = T[number];

type TTU = TupleToUnion<[string, number, boolean]>;
// TTU = string | number | boolean  โœ“

// Method 2: recursive (illustrates the mechanism)
type TupleToUnion2<T extends any[]> =
  T extends [infer Head, ...infer Tail]
    ? Head | TupleToUnion2<Tail>
    : never;

T[number] is the idiomatic form: indexing a tuple with number returns the union of all positional types.


5. UnionToTuple: The Reverse Direction (Hardest)

Converting a union to a tuple requires imposing an order on something inherently unordered. The approach: repeatedly extract one member and prepend it.

type LastOf<U> =
  UnionToIntersection<U extends any ? () => U : never> extends () => infer R
    ? R
    : never;

type UnionToTuple<U, Last = LastOf<U>> =
  [U] extends [never]
    ? []
    : [...UnionToTuple<Exclude<U, Last>>, Last];

// Verification
type UTT = UnionToTuple<string | number | boolean>;
// UTT = [string, number, boolean] (some permutation)

Critical warning: the order of UnionToTuple's result is undefined behavior. It depends on TypeScript's internal union member ordering, which can differ between compiler versions. Use this only for type-level computation where order doesn't matter.

How LastOf works: converting each union member into () => member function types, then UnionToIntersection collapses them into an overloaded function type. TypeScript's overload resolution selects the last overload when inferring, yielding the "last" union member.


6. IsNever: Detecting the never Type

The Wrong Attempt

// Wrong: distributive conditional types don't fire on never
type IsNever_Wrong<T> = T extends never ? true : false;

type R = IsNever_Wrong<never>;  // Result is never, not true!

When T is never, distributive conditional type distribution has zero members to distribute over, so the result is never directly.

Correct Implementation: Suppress Distribution with Brackets

type IsNever<T> = [T] extends [never] ? true : false;

type IN1 = IsNever<never>;     // true  โœ“
type IN2 = IsNever<string>;    // false โœ“
type IN3 = IsNever<0>;         // false โœ“

Wrapping T in a tuple [T] prevents distributionโ€”tuple types are not "naked type parameters," so the conditional type compares [never] against [never] as a whole, producing true.


7. IsUnion: Detecting Union Types

type IsUnion<T> = _IsUnion<T, T>;
type _IsUnion<T, Copy> =
  [T] extends [never]
    ? false
    : T extends any
      ? [Copy] extends [T]
        ? false
        : true
      : never;

type IU1 = IsUnion<string | number>;  // true  โœ“
type IU2 = IsUnion<string>;           // false โœ“
type IU3 = IsUnion<never>;            // false โœ“

Mechanism: T extends any triggers distribution, splitting T = A | B into individual members. For each member, we check if the full union Copy is assignable to that member. If T is A | B, then [A | B] extends [A] is falseโ€”so we return true (it's a union). If T is just A, then [A] extends [A] is true, returning false (not a union).

The Copy parameter must be set at the call site before distribution happensโ€”once T is naked in the conditional type, it gets distributed and the original union is lost.


Practical Combination

// Extract the resolved value type from an async function
type AsyncReturnType<T extends (...args: any) => any> =
  MyAwaited<ReturnType<T>>;

async function fetchUser(id: number) {
  return { id, name: "Alice", role: "admin" as const };
}

type User = AsyncReturnType<typeof fetchUser>;
// User = { id: number; name: string; role: "admin" }  โœ“

// Extract the shared interface from a union of API objects
type CommonInterface<U extends object> = UnionToIntersection<U>;

type ApiA = { get(url: string): Promise<string>; auth(): void };
type ApiB = { get(url: string): Promise<string>; log(): void };
type Common = CommonInterface<ApiA | ApiB>;
// Common = { get(url: string): Promise<string> }

Anti-Patterns

Anti-Pattern Problem Fix
T extends never ? true : false to check never Distributive trap returns never Use [T] extends [never]
Unbounded recursive unwrapping TypeScript recursion limit (~1000) Add Depth param with Prev mapping
Relying on UnionToTuple order Order is undefined behavior Use only for type-level calculation
Matching Promise<infer U> for thenable support Non-standard thenables not handled Match .then signature instead

Summary

Utility Type Core Mechanism Key Challenge
MyAwaited<T> Recursive conditional type + infer Matching thenable shape, not just Promise
FlatArray<T, D> Recursion + depth counter via Prev Simulating numeric decrement
UnionToIntersection<U> infer in contravariant position Understanding variance theory
TupleToUnion<T> Numeric index T[number] Simplest of the group
UnionToTuple<U> LastOf + Exclude recursion Order is unspecifiedโ€”use carefully
IsNever<T> [T] extends [never] to block distribution The naked type parameter trap
IsUnion<T> Distribute then compare against preserved Copy Must capture T before distribution
Rate this chapter
4.6  / 5  (5 ratings)

๐Ÿ’ฌ Comments