Skip to content

Regression: Mapped types are no longer homomorphic when wrapped in certain conditional types #63132

Open

Description

@aweebit

🔎 Search Terms

"homomorphic" "keyof" "extends"

🕗 Version & Regression Information

  • This changed between versions 3.8.3 and 3.9.7

⏯ Playground Link

https://www.typescriptlang.org/play/?#code/C4TwDgpgBA0hIFkCGYA8AVAfFAvFA1vAPYBmU6UEAHsBAHYAmAzlAAoBORk7ocIUAfigBvKAG0YUAJZ0CxMugC6ALlhQAvlFV0IANwjsA3AChjoSFAAauWPGRo6AVwC2AIwOZDQA

💻 Code

type KeyMap<T> = keyof T extends PropertyKey ? { [K in keyof T]: K } : never;

type X = KeyMap<number>;

🙁 Actual behavior

X is evaluated to

type X = {
    toString: "toString";
    toFixed: "toFixed";
    toExponential: "toExponential";
    toPrecision: "toPrecision";
    valueOf: "valueOf";
    toLocaleString: "toLocaleString";
}

🙂 Expected behavior

X should be evaluated to number because according to the FAQ,

Mapped types declared as { [ K in keyof T ]: U } where T is a type parameter are known as homomorphic mapped types, which means that the mapped type is a structure preserving function of T. When type parameter T is instantiated with a primitive type the mapped type evaluates to the same primitive.

Additional information about the issue

Replacing PropertyKey with either unknown or any fixes the issue for some reason.

Activity

  1. Andarist commented on Feb 12, 2026

    @Andarist
    Contributor

    The above repro might feel artificial and not real-world but I believe this to be a genuine problem. Homomorphic mapped types distribute and this one doesn't (given the compiler doesn't see it as homomorphic): TS playground

    The reason it's not treated as a homomorphic mapped types is that keyof T becomes a substitution type in the true type of this conditional.

  2. aweebit commented on Feb 13, 2026

    @aweebit
    Author

    Hey Mateusz Burzyński (@Andarist),

    Thank you for submitting a fix!

    I agree that the repro feels artificial, but that's because I wanted to make it as simple as possible. The original code where I discovered the issue is rather complex and can be quite confusing because while writing it, I had to work around other TypeScript bugs that I just haven't had time to report yet, but if you're interested to see a real-world example of the issue, you're very welcome to have a look at it:

    type Example = { value: number | undefined };
    
    type Correct = DeepOptionalize<Example>;
    // ✅ Correctly evaluated to:
    /* type Correct = {
        value?: number | undefined;
    } */
    
    type Wrong = DeepOptionalize<Example[]>;
    // ❌ Should be {
    //     value?: number | undefined;
    // }[], but surprisingly gets evaluated to this monster:
    /* type Wrong = {
        [x: number]: {
            value?: number | undefined;
        };
        length: number;
        toString: {};
        toLocaleString: {};
        pop: {};
        push: {};
        concat: {};
        join: {};
        reverse: {};
        shift: {};
        slice: {};
        sort: {};
        splice: {};
        unshift: {};
        indexOf: {};
        lastIndexOf: {};
        every: {};
        some: {};
        forEach: {};
        map: {};
        filter: {};
        reduce: {};
        reduceRight: {};
        find: {};
        findIndex: {};
        fill: {};
        copyWithin: {};
        entries: {};
        keys: {};
        values: {};
        includes: {};
        flatMap: {};
        flat: {};
        at: {};
        findLast: {};
        findLastIndex: {};
        toReversed: {};
        toSorted: {};
        toSpliced: {};
        with: {};
        get: {};
        set: {};
        add: {};
        toArray: {};
        [Symbol.iterator]: {};
        readonly [Symbol.unscopables]: {
            ...;
        };
    } */
    
    // -------------------
    //   DeepOptionalize 
    // -------------------
    
    export type OverwriteLeaves<T, Leaf> = [T] extends [never]
      ? Leaf
      : T extends object
        ? { [K in keyof T]: OverwriteLeaves<T[K], Leaf> }
        : Leaf;
    
    export type DeepOptionalize<
      T,
      Defaults extends OverwriteLeaves<T, unknown> = OverwriteLeaves<T, undefined>,
    > = UnconstrainedDeepOptionalize<T, Defaults>;
    
    type UnconstrainedDeepOptionalize<T, Defaults> = T extends object
      ? T extends readonly unknown[]
        ? keyof T extends keyof Defaults // <<<<< THE PART CAUSING PROBLEMS
          ? { [K in keyof T]: UnconstrainedDeepOptionalize<T[K], Defaults[K]> }
          : never
        : keyof T extends keyof Defaults
          ? {
              [K in keyof T]?: UnconstrainedDeepOptionalize<T[K], Defaults[K]>;
            } & UnconstrainedDeepOptionalizeRequiredPart<T, Defaults>
          : never
      : T;
    
    type UnconstrainedDeepOptionalizeRequiredPart<
      T,
      Defaults,
      // Originally, key remapping was used to filter out keys whose value types
      // include default types, and that therefore may remain optional:
      //   { readonly [K in keyof T & keyof Defaults as Intersects<Defaults[K], T[K]> extends false ? K : never]: T[K] }
      // However, relying on RequiredKeysFromDefaults instead turned out to be the
      // better approach as it helped TypeScript to better infer type arguments for
      // deepApplyDefaults(), and also fixed a very bizarre error where a type would
      // not work correctly with deepApplyDefaults() unless defined in the file
      // where the function was called rather than being exported from a different
      // file.
      Result = {
        readonly [K in RequiredKeysFromDefaults<T, Defaults> & keyof Defaults]: UnconstrainedDeepOptionalize<
          T[K],
          Defaults[K]
        >;
      },
    > =
      IsEmptyObject<Result> extends true
        ? // Unlike Partial<T>, Partial<T> & U where U is a mapped typed over an
          // empty set of keys (e.g. Record<never, never>) behaves like {} for some
          // reason in that it includes all values except for null and undefined,
          // which is not what we want. However, that is not the case for
          // Partial<T> & unknown which is simply equivalent to Partial<T>, and
          // since that is the behavior we want, unknown is what we evaluate to
          // here.
          unknown
        : Result;
    
    type RequiredKeysFromDefaults<T, Defaults> = {
      [K in keyof T & keyof Defaults]: Intersects<Defaults[K], T[K]> extends false ? K : never;
    }[keyof T & keyof Defaults];
    
    // -----------------
    //   Utility types
    // -----------------
    
    type Intersects<U, T> = U extends T ? true : false;
    
    type IsEmptyObject<T> = [T, T, keyof T] extends [UnionToIntersection<T>, Record<PropertyKey, unknown>, never]
      ? true
      : false;
    
    type UnionToIntersection<T> = (T extends unknown ? (x: T) => unknown : never) extends (x: infer R) => unknown
      ? R
      : never;

    Ideally, the code for UnconstrainedDeepOptionalize should actually look like this:

    type UnconstrainedDeepOptionalize<T, Defaults> = T extends object
      ? keyof T extends keyof Defaults
        ? T extends readonly unknown[]
          ? { [K in keyof T]: UnconstrainedDeepOptionalize<T[K], Defaults[K]> }
          : {
              [K in keyof T]?: UnconstrainedDeepOptionalize<T[K], Defaults[K]>;
            } & UnconstrainedDeepOptionalizeRequiredPart<T, Defaults>
        : never
      : T;

    I.e. keyof T extends keyof Defaults should not be repeated two times. However, despite solving the demonstrated issue, implementing it this way introduces a new error in the true branch of the T extends readonly unknown[] check:

    Type 'K' cannot be used to index type 'Defaults'.
    

    I suspect that this error is closely related to what I'm reporting here, as it too only appears where array / tuple types are involved.

    What ended up working for me is this:

    type UnconstrainedDeepOptionalize<T, Defaults> = T extends object
      ? T extends readonly unknown[]
        ? { [K in keyof T]: K extends keyof Defaults ? UnconstrainedDeepOptionalize<T[K], Defaults[K]> : never }
        : keyof T extends keyof Defaults
          ? {
              [K in keyof T]?: UnconstrainedDeepOptionalize<T[K], Defaults[K]>;
            } & UnconstrainedDeepOptionalizeHelper<T, Defaults>
          : never
      : T;

    Of course it's very confusing that this version works, but the other two don't.

    And since the code comes from a real-world code base, I, of course, also believe this to be a genuine problem, and hope to see it fixed soon :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Needs InvestigationThis issue needs a team member to investigate its status.

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions