DEV Community

Cover image for Iteration in TypeScript Types
beqa
beqa

Posted on Edited on AI-assisted

Iteration in TypeScript Types

TypeScript unions represent values that match one of several types. Beyond modeling runtime data variants, unions serve as the primary mechanism for iteration in the type system.

When you pass a union to a conditional type, TypeScript evaluates each member separately. When you combine unions with mapped types, you can transform object keys and values. When you add recursion, you can generate complex permutations and validate input shapes at compile time.

This guide explains how unions drive iteration, how to manipulate keys and tuples, and how to avoid common pitfalls in recursive types.

Understanding union types

A union type represents a value that can be one of several types. A pipe character (|) separates each member in a union.

Developers write unions to express valid states, component props, and configuration options:

// Status of an asynchronous operation
type Status = "pending" | "resolved" | "rejected";
type ApiResponse = { data: unknown; error: Error; status: Status };

function fetchProducts(): ApiResponse {
    /* ... */
}

// Props for a component that renders as a button or an anchor
type ButtonOrLinkProps =
    | {
            as?: "button";
            onClick: (event: Event) => void;
        }
    | {
            as: "a";
            href: string;
        };

function ButtonOrLink(props: ButtonOrLinkProps) {
    if (props.as === "a") {
        /* ... */
    }
}

// Configuration options
type Options =
    | boolean
    | { capture: boolean; once: boolean; passive: boolean };

function registerListener(
    target: string,
    callback: () => void,
    options: Options
): void {}
Enter fullscreen mode Exit fullscreen mode

TypeScript expressions also produce unions. The keyof operator extracts an object type's property names as a union of string, number, or symbol literals:

type MyObj = {
    foo: string;
    bar: number;
    baz: boolean;
};

type MyKeys = keyof MyObj; // "foo" | "bar" | "baz"
Enter fullscreen mode Exit fullscreen mode

Indexing an array or tuple type with number produces a union of its element types:

// Extract a union from an array type
const myArray = [42, "foo", true];
type MyArrayType = typeof myArray; // (string | number | boolean)[]
type MyUnionValues = MyArrayType[number]; // string | number | boolean

// Extract a union from a tuple type
const myTuple = [42, "foo", true] as const;
type MyTupleType = typeof myTuple; // readonly [42, "foo", true]
type MyTupleValues = MyTupleType[number]; // 42 | "foo" | true
Enter fullscreen mode Exit fullscreen mode

For myArray, TypeScript infers (string | number | boolean)[]. Indexing the array type with number returns the underlying union type string | number | boolean.

The as const assertion narrows an array literal into a readonly tuple with fixed positions. For example, MyTupleType[0] returns the literal type 42. Indexing the tuple with number returns a union of every element in that tuple: 42 | "foo" | true.

Iteration with distributive conditional types

Conditional types evaluate a condition and return one of two types. The syntax mirrors a JavaScript ternary operator:

SomeType extends OtherType ? TrueBranch : FalseBranch;
Enter fullscreen mode Exit fullscreen mode

When wrapped in a generic type, a conditional type checks the type argument:

type GroupTypes<T> = T extends number ? { numberType: T } : { unknownType: T };

type NumberType = GroupTypes<11>; // { numberType: 11 }
type UnknownType = GroupTypes<"El">; // { unknownType: "El" }
Enter fullscreen mode Exit fullscreen mode

When the checked type T is a bare generic parameter, conditional types distribute over unions. If you pass a union to GroupTypes<T>, TypeScript evaluates each member of the union separately:

type GroupTypes<T> = T extends number ? { numberType: T } : { unknownType: T };
type RandomUnion = 11 | "El" | number | boolean;

type Output = GroupTypes<RandomUnion>;
// { numberType: 11 } | { unknownType: "El" } | { numberType: number } | { unknownType: boolean }
Enter fullscreen mode Exit fullscreen mode

The compiler evaluates each union member individually and joins the results into a new union. This distribution acts as a map operation over the union.

To map a union into a single target shape, you cannot omit the conditional. A generic type without a condition treats the entire union as a single type argument:

type MyArg = number | { value: number };
type ToFunctionArgs<T> = (arg: T) => void;

type MyFunction = ToFunctionArgs<MyArg>;
// Result: (arg: number | { value: number }) => void
// Expected: ((arg: number) => void) | ((arg: { value: number }) => void)
Enter fullscreen mode Exit fullscreen mode

To force distribution, use an identity condition like T extends T and return never for the unused branch. In TypeScript, never represents the empty set. When never appears inside a union, TypeScript automatically removes it:

type MyUnion = string | never | number | undefined | never;
// Evaluates to: string | number | undefined
Enter fullscreen mode Exit fullscreen mode

Because never collapses out of unions, you can write single-branch transformations:

type MyArg = number | { value: number };
type ToFunctionArgs<T> = T extends T ? (arg: T) => void : never;

type MyFunction = ToFunctionArgs<MyArg>;
// ((arg: number) => void) | ((arg: { value: number }) => void)
Enter fullscreen mode Exit fullscreen mode

In existing codebases, you may also see T extends unknown or T extends any. All three forms force distribution over T.

TypeScript automatically flattens nested unions. If a conditional branch returns a union, the result flattens into a single union with more members:

type MyUnion = "foo" | "bar";
type MyTransform<T> = T extends string ? T | Uppercase<T> : never;

type Out = MyTransform<MyUnion>; // "foo" | "FOO" | "bar" | "BAR"
Enter fullscreen mode Exit fullscreen mode

The Uppercase<T> utility type returns the uppercase string alongside the original T, doubling the number of members.

You can also use never to filter members out of a union. For example, this utility type removes falsy values from an array:

type OnlyTruthy<T> = T extends false | 0 | "" | null | undefined ? never : T;

function onlyTruthy<T>(arr: T[]): OnlyTruthy<T>[] {
    return arr.filter(Boolean) as OnlyTruthy<T>[];
}

const myArr = [42, undefined, "foo", null];
const result = onlyTruthy(myArr); // (string | number)[]
Enter fullscreen mode Exit fullscreen mode

The parameter arr: T[] causes TypeScript to infer T as the union of all elements in the input array. In this example, T is number | undefined | string | null. The type OnlyTruthy<T> distributes over each member. If a member matches a falsy literal, the type returns never, removing that member from the resulting array element type.

Advanced patterns with distributive conditional types

A type parameter distributes only when it is naked. A type parameter is naked when it is not wrapped in another type, such as [T], T[], or Promise<T>.

You can nest conditionals over multiple naked type parameters to produce Cartesian products:

type DiceRoll<T, U> = T extends T ? (U extends U ? [T, U] : never) : never;

type Sides = 1 | 2 | 3;
type Combos = DiceRoll<Sides, Sides>;
// [1, 1] | [1, 2] | [1, 3]
// [2, 1] | [2, 2] | [2, 3]
// [3, 1] | [3, 2] | [3, 3]
Enter fullscreen mode Exit fullscreen mode

The outer conditional distributes over T. For each member of T, the inner conditional distributes over U.

Extract subtypes with infer

To extract a type from within another structure, use the infer keyword.

The infer keyword is valid only inside the extends clause of a conditional type. It declares a new type variable that TypeScript populates by pattern matching against the target type:

type FunctionUnion =
    | ((id: number) => void)
    | ((name: string, ignoredArg: boolean) => void);

type FirstArg<T> = T extends (arg: infer FirstArg, ...rest: any[]) => void
    ? FirstArg
    : never;

type Out = FirstArg<FunctionUnion>; // string | number
Enter fullscreen mode Exit fullscreen mode

Because T is a naked type parameter, FirstArg<T> distributes over FunctionUnion. For each function type, TypeScript matches the pattern, binds the first parameter to FirstArg, and returns it.

You can constrain the inferred type directly inside the pattern with an extends clause:

type FunctionUnion =
    | ((id: number) => void)
    | ((name: string, ignoredArg: boolean) => void);

type FirstArg<T> = T extends (
    arg: infer FirstArg extends number,
    ...rest: any[]
) => void
    ? FirstArg
    : never;

type Out = FirstArg<FunctionUnion>; // number
Enter fullscreen mode Exit fullscreen mode

The second function in FunctionUnion has a first argument of type string. Because string does not extend number, the condition fails for that function and returns never.

You can also combine infer with template literal types to parse and validate strings:

type MyUnion = "42.69" | "123" | "foo";
type ToNumber<T extends string> = T extends `${infer Num extends number}`
    ? Num
    : never;

type Out = ToNumber<MyUnion>; // 42.69 | 123
Enter fullscreen mode Exit fullscreen mode

ToNumber<T> distributes over MyUnion. The template literal matches substrings that parse as numbers. The value "foo" fails the condition and evaluates to never.

Mapping with in and keyof

The in operator iterates over a union of property keys to create an object type. The right-hand side of in must evaluate to a union of strings, numbers, or symbols:

const myUniqueSymbol = Symbol("some symbol");
type MyUnion = 42 | "foo" | typeof myUniqueSymbol;

type MyObject = {
    [K in MyUnion]: boolean;
};

// Evaluates to:
// {
//   42: boolean;
//   foo: boolean;
//   [myUniqueSymbol]: boolean;
// }
Enter fullscreen mode Exit fullscreen mode

Combining in with keyof transforms the property types of an existing object:

type MyObject = {
    foo: string;
    bar: number;
};

type ToFunctionProperties<T> = {
    [K in keyof T]: (arg: T[K]) => void;
};

type Out = ToFunctionProperties<MyObject>;
// {
//   foo: (arg: string) => void;
//   bar: (arg: number) => void;
// }
Enter fullscreen mode Exit fullscreen mode

In [K in keyof T], K represents each key in turn. You can index T[K] to access the original value type and use it within the mapped property definition.

Remap and filter keys with the as clause

Use the as clause to rename or filter keys during iteration:

type MyObject = {
    foo: string;
    bar: number;
};

type ToFunctionProperties<T> = {
    [K in Extract<keyof T, string> as Uppercase<K>]: (arg: T[K]) => void;
};

type Out = ToFunctionProperties<MyObject>;
// {
//   FOO: (arg: string) => void;
//   BAR: (arg: number) => void;
// }
Enter fullscreen mode Exit fullscreen mode

The utility Extract<keyof T, string> ensures that K contains only string keys, satisfying Uppercase<K>. On the right-hand side of the colon, K continues to reference the original property name. Therefore, T[K] accesses the original value type.

Returning never in an as clause filters out that key:

type PickEvents<T> = {
    [K in keyof T as K extends `on${string}` ? K : never]: T[K];
};

type Out = PickEvents<HTMLInputElement>;
// {
//   onChange: ((this: GlobalEventHandlers, ev: Event) => any) | null;
//   onfocus: ((this: GlobalEventHandlers, ev: FocusEvent) => any) | null;
//   ...
// }
Enter fullscreen mode Exit fullscreen mode

The template literal pattern on${string} matches any property name that begins with on. Non-matching keys evaluate to never and are omitted from the resulting type.

Preserve tuple structures with homomorphic mapped types

A mapped type is homomorphic when it maps directly over keyof T without key remapping (as). Homomorphic mapped types preserve the input structure. If you pass an array or a tuple to a homomorphic mapped type, TypeScript returns an array or a tuple:

type ToFunctionProperties<T> = {
    [K in keyof T]: (arg: T[K]) => void;
};

type Out = ToFunctionProperties<["a", "b"]>;
// [(arg: "a") => void, (arg: "b") => void]
Enter fullscreen mode Exit fullscreen mode

When you add an as clause, the mapped type is no longer homomorphic. TypeScript treats the tuple as a standard object and maps over every prototype method and array property:

type ToFunctionProperties<T> = {
    [K in keyof T as K]: (arg: T[K]) => void;
};

type Out = ToFunctionProperties<["a", "b"]>;
// {
//   [x: number]: (arg: "a" | "b") => void;
//   0: (arg: "a") => void;
//   1: (arg: "b") => void;
//   length: (arg: 2) => void;
//   toString: (arg: () => string) => void;
// }
Enter fullscreen mode Exit fullscreen mode

Reverse mapped types

In reverse mapped types, a generic function declares a mapped type in its parameter position instead of its return type. The compiler infers the generic type argument from the shape of the value passed at the call site.

This pattern lets you validate inputs while preserving exact literal types:

function configure<T>(config: { [K in keyof T]: number }) {
    return config;
}

configure({ a: 1, b: 2 }); // Valid
configure({ a: 1, b: true }); // Error: Type 'boolean' is not assignable to type 'number'.
Enter fullscreen mode Exit fullscreen mode

The function expects values of type number, but leaves property names generic. TypeScript infers T as { a: number; b: number }.

Reverse mapped types are effective when you need autocompletion across nested configuration objects. Consider a render function that accepts an object of HTML element names and their corresponding props:

render({
    button: {
        value: "Click Me",
        onClick: (event) => {},
    },
    a: {
        href: "/blog",
    },
});
Enter fullscreen mode Exit fullscreen mode

To validate element names, element props, and event handlers, construct the generic type step by step.

First, constrain T to element keys:

type Elements = React.JSX.IntrinsicElements;

type Config<T extends keyof Elements> = {
    [K in T]: unknown;
};

function render<T extends keyof Elements>(config: Config<T>) {}

render({
    button: {},
    a: {},
    invalidTag: {}, // Error: Object literal may only specify known properties
});
Enter fullscreen mode Exit fullscreen mode

Constraining T with T extends keyof Elements infers T as a union of the keys provided at the call site.

Do not constrain T with T extends Elements. If you write function render<T extends Elements>(config: Config<T>), TypeScript requires config to provide every HTML element defined in Elements.

Next, map over each element's props:

type Elements = React.JSX.IntrinsicElements;

type Config<T extends keyof Elements> = {
    [K1 in T]: {
        [K2 in keyof Elements[K1]]?: Elements[K1][K2];
    };
};

function render<T extends keyof Elements>(config: Config<T>) {}

render({
    button: {
        value: "Click Me",
        onClick: (event) => {
            // event is inferred automatically
        },
    },
    a: {
        href: "/blog",
        invalidProp: 123, // Error: Object literal may only specify known properties
    },
});
Enter fullscreen mode Exit fullscreen mode

The variable K1 iterates over the union of provided tags ("button" | "a"). Then Elements[K1] retrieves the prop definitions for that specific element. The inner mapped type [K2 in keyof Elements[K1]] iterates over those props and enforces their types.

Implement recursive types with unions

Type-level recursion requires a base case to terminate. In TypeScript, conditional types provide this termination check. Because conditional types distribute over unions, mixing recursion with unions requires careful handling of distributivity.

Consider implementing a UniqueArray<T> utility type that validates an array contains no duplicate items from a known union:

type UniqueNumbers = UniqueArray<1 | 2 | 3>;

const valid: UniqueNumbers = [1, 2];
const invalid: UniqueNumbers = [1, 1, 2]; // Error: Type '[1, 1, 2]' is not assignable
Enter fullscreen mode Exit fullscreen mode

Before implementing the type, consider the recursive algorithm in JavaScript:

function uniqueTuples(items) {
    if (items.length === 0) {
        return [];
    }

    const results = [];

    for (let i = 0; i < items.length; i++) {
        const current = items[i];
        const remaining = items.filter((item) => item !== current);
        const subPermutations = uniqueTuples(remaining);

        results.push([current]);

        for (const sub of subPermutations) {
            results.push([current, ...sub]);
        }
    }

    return results;
}
Enter fullscreen mode Exit fullscreen mode

The algorithm performs three steps:

  1. Base case: Return empty when no items remain.
  2. Iteration: Loop through each item in the collection.
  3. Recursion: Exclude the current item, generate sub-tuples from the remainder, and prepend the current item.

A naive attempt in TypeScript reveals two common traps:

type UniqueArray<T> = T extends never
    ? []
    : [T] | [T, ...UniqueArray<Exclude<T, T>>];
Enter fullscreen mode Exit fullscreen mode

This definition fails for two reasons:

  1. Exclude<T, T> always evaluates to never.
    Because T is a naked type parameter, T extends ... distributes over T. Inside the conditional branch, T represents a single union member. Evaluating Exclude<T, T> subtracts that member from itself, always producing never.

  2. T extends never does not match never when T is naked.
    In TypeScript, never is an empty union. Distributing over an empty union performs zero iterations, evaluating directly to never. The true branch ? [] never executes.

To resolve both problems, separate distribution from the base case check:

type UniqueArray<T, U = T> = [T] extends [never]
    ? []
    : T extends any
        ? [T] | [T, ...UniqueArray<Exclude<U, T>>]
        : never;
Enter fullscreen mode Exit fullscreen mode

Here is how this implementation functions:

  • [T] extends [never]: Wrapping T in a tuple prevents distribution. When no members remain, T is never, resolving to [].
  • T extends any: Distributes over each individual member of the active union T.
  • Exclude<U, T>: Subtracts the current member T from the full union U, passing the remaining members to the next recursive step.
  • [T] | [T, ...UniqueArray<Exclude<U, T>>]: Emits single-element tuples and prepends T to every sub-tuple.

Test the resulting type:

type UniqueNumbers = UniqueArray<1 | 2 | 3>;

const valid1: UniqueNumbers = [1];
const valid2: UniqueNumbers = [1, 2];
const valid3: UniqueNumbers = [1, 2, 3];
const invalid: UniqueNumbers = [1, 1, 2]; // Error: Type '[1, 1, 2]' is not assignable to type 'UniqueNumbers'.
Enter fullscreen mode Exit fullscreen mode

Summary

Unions drive type-level computation in TypeScript through six core mechanisms:

  • Distributive conditional types map and filter union members when a generic parameter is naked.
  • Identity conditions like T extends T ? ... : never force distributivity over single branches.
  • The infer keyword extracts subtypes within functions, tuples, and template literals.
  • Mapped types with in and keyof transform object shapes. Non-homomorphic mapped types convert arrays and tuples into objects.
  • Reverse mapped types invert inference, validating caller values while inferring literal property types.
  • Recursive types require non-distributive checks to detect base cases without collapsing to never.

Top comments (0)