TypeScript UnionToIntersection Utility Type TypeScript UnionToIntersection Utility Typetypescript

The UnionToIntersection utility type converts union types to intersection types.

For example:

type Union = { a: string } | { b: number };
type Intersection = UnionToIntersection<Union>;

// Intersection = { a: string } & { b: number }

How It Works

type UnionToIntersection<T> = (
	T extends unknown ? (x: T) => void : never
) extends (x: infer I) => void
	? I
	: never;
  1. The first part T extends unknown ? (x: T) => void : never is a conditional type:

    • It checks if T extends unknown (which is always true).
    • If true, it returns a function type (x: T) => void.
    • If false (which never happens), it returns never.
  2. The result of this conditional type is another function type that looks like this: (x: T) => void.

  3. This function type is then used in another conditional type: (...) extends (x: infer I) => void ? I : never

  4. Here, we're using the infer keyword to infer the type of the parameter x in the function type.

  5. If the previous function type extends (x: infer I) => void, then the final type will be I. Otherwise, it will be never.

The magic happens due to how TypeScript handles function types with union types as parameters. When a union type is used as a parameter in a function type, it's treated as an intersection of functions.

T is A | B | C, the type (x: T) => void is equivalent to: ((x: A) => void) & ((x: B) => void) & ((x: C) => void)

When we infer I from this intersection of functions, we get the intersection of the parameter types: A & B & C.

The key to this type is the distributive conditional type: T extends unknown ? ... : .... This makes TypeScript apply the type transformation to each member of the union separately.

So, how's this different from:

type UnionToIntersection2<T> = ((x: T) => void) extends (x: infer I) => void
	? I
	: never;

The key difference lies in how TypeScript handles conditional types with naked type parameters (like T) versus "non-naked" type parameters.

In the UnionToIntersection<T> type, the T extends unknown ? ... : ... part is a distributive conditional type. When T is a union, this conditional type is applied to each member of the union separately, and the results are then combined into a union.

UnionToIntersection2<T> doesn't have this distributive behavior because T is not used naked in a conditional type, but is instead wrapped in a function type (x: T) => void.

Naked vs Non-Naked Type Parameters

A naked type parameter appears directly in a conditional type without being wrapped in another type. For example:

type NakedConditional<T> = T extends string ? 'string' : 'not string';

Here, T is a naked type parameter. When T is a union type, TypeScript distributes the conditional type over each member of the union.

type Result = NakedConditional<string | number>;
// Equivalent to:
// NakedConditional<string> | NakedConditional<number>
// Result is 'string' | 'not string'

A non-naked type parameter is wrapped in another type construct. For example:

type NonNakedConditional<T> = [T] extends [string] ? 'string' : 'not string';

Here, T is wrapped in an array type, making it non-naked. This prevents the distribution behavior over union types.

type Result = NonNakedConditional<string | number>;
// Result is 'not string'

Application

I encountered the UnionToIntersection utility type while working on a proof-of-concept game engine. The challenge was to create a system where different components could be combined, ensuring type safety.

export function engine<T extends unknown[]>({
	components,
	children,
}: {
	components: T;
	children: ((
		ctx: T extends Array<infer A> ? UnionToIntersection<A> : never,
	) => void)[];
}) {
	const context = Object.assign({}, ...components);
	for (const child of children) child(context);
}

For example, this allows combining two systems, each offering their own methods, into a unified context. The merged context makes all methods from both systems available to the child functions, preserving type safety.

Here’s a simple example:

const renderer = () => ({ render: () => console.log('render') });
const input = () => ({ process: () => console.log('process') });

engine({
	components: [renderer(), input()],
	children: [
		ctx => {
			ctx.process();
			ctx.render();
		},
	],
});

The TypeScript compiler infers the type of ctx from the components array, which results in:

{
    render: () => void;
} & {
    process: () => void;
}

Here's how the engine function works:

If you pass an array of different component types, each with its own properties, the UnionToIntersection type ensures that the context passed to each child function has all the properties from all the components. This means that in your child functions, you can access any property from any of the components, with full type safety.

A more complex example:

type WebGL2Context = { gl: WebGL2RenderingContext };
type RendererContext = { render(cb: () => void): void };

function webgl2(options: { width: number; height: number }): WebGL2Context {
	// Implementation details...
}

function renderer(): RendererContext {
	// Implementation details...
}

function image({ src }: { src: string }) {
	return (ctx: WebGL2Context & RendererContext) => {
		// Here, ctx is inferred to have both gl and render properties
		const { gl, render } = ctx;

		// Use gl to create textures, buffers, etc.
		const texture = gl.createTexture();
		// ... more WebGL setup ...

		render(() => {
			// Use gl to render the image
			gl.bindTexture(gl.TEXTURE_2D, texture);
			// ... more rendering code ...
		});
	};
}

engine({
	components: [
		webgl2({
			width: 1920,
			height: 1080,
		}),
		renderer(),
	],
	children: [image({ src: 'background.jpg' })],
});

In this setup:

  1. webgl2 is a function that initializes a WebGL2 context and returns an object of type { gl: WebGL2RenderingContext }.
  2. renderer is a function that sets up a rendering system and returns an object of type { render(cb: () => void): void }.
  3. image is a function that creates a child component to render an image.

The UnionToIntersection type allows the image function to receive a context that combines the types from both webgl2 and renderer. This means TypeScript knows that ctx has both gl and render properties. If you tried to use a property that neither component provides, TypeScript would raise an error.

You can easily add more components (like audio, input handling, etc.) without changing existing code. Each child function only needs to declare the types it requires in its context parameter. The engine ensures that all required context is provided, catching errors at compile-time.

Back to Main Page