Skip to content

Flatten<O> does not merge union object fields — distributes instead #63361

@kevalcodezee

Description

@kevalcodezee

Acknowledgement

  • I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.

Comment

Problem

I have a utility type Flatten<O> that merges a union of objects into a single flat object. It works correctly at the root level, but when a field's value is itself a union of objects, it is left as-is instead of being recursively flattened.


Implementation

type AllKeys<U> = U extends unknown ? keyof U : never;

type FlattenArray<A extends Record<string, unknown>[]> =
  A extends (infer T extends Record<string, unknown>)[]
    ? Flatten<T>[]
    : never;

type Flatten<O extends Record<string, unknown>> = {
  [K in AllKeys<O>]: O extends Record<K, infer V>
    ? V extends Record<string, unknown>[]
      ? FlattenArray<V>
      : V extends Record<string, unknown>
        ? Flatten<V>
        : V
    : never;
};

What works

Root-level union is flattened correctly:

type Input =
  | { mediatorId: string; brokerId: string; }
  | { mediatorId: null;   brokerId: string; }
  | { mediatorId: string; brokerId: null;   }
  | { mediatorId: null;   brokerId: null;   };

type Result = Flatten<Input>;
// Correctly produces:
// { mediatorId: string | null; brokerId: string | null; }

What does NOT work

When a nested field's value is itself a union of objects, Flatten is not applied — the union is left as-is:

type Input = {
  nested:
    | { id: string; value: string; }
    | { id: string; value: null;  };
};

type Result = Flatten<Input>;
// Expected:
// { nested: { id: string; value: string | null; } }

// Actual:
// { nested: { id: string; value: string; } | { id: string; value: null; } }

Root cause (suspected)

When V is inferred as a union of objects via O extends Record<K, infer V>, the check V extends Record<string, unknown> distributes over each union member. So Flatten<V> is called per member — Flatten<Obj1> | Flatten<Obj2> — instead of Flatten<Obj1 | Obj2>, which is the whole point.


What I tried

  • [V] extends [Record<string, unknown>] ? Flatten<V> — tuple brackets to suppress distributivity on the object check. Did not help because the outer V extends Record<string, unknown>[] already split V.
  • Making every check non-distributive with tuple brackets from the start.
  • [NonNullable<V>] extends [object] — caused primitives like string to incorrectly match since string satisfies object in TS.

Question

How can I call Flatten<V> with the full union of object types rather than distributing it, while still correctly handling primitives, null, and nested arrays?

Metadata

Metadata

Assignees

No one assigned

    Labels

    QuestionAn issue which isn't directly actionable in code

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions