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:
- Covariant position (return type):
() => Dogis assignable to() => Animal. Multiple sources produce a union (widest common type). - Contravariant position (parameter type):
(x: Animal) => voidis assignable to(x: Dog) => void. Multiple sources produce an intersection (strictest type that satisfies all).
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 |