You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Regression: Mapped types are no longer homomorphic when wrapped in certain conditional types #63132
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.
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.
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:
typeExample={value: number|undefined};typeCorrect=DeepOptionalize<Example>;// ✅ Correctly evaluated to:/* type Correct = { value?: number | undefined;} */typeWrong=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 // -------------------exporttypeOverwriteLeaves<T,Leaf>=[T]extends[never]
? Leaf
: Textendsobject
? {[KinkeyofT]: OverwriteLeaves<T[K],Leaf>}
: Leaf;exporttypeDeepOptionalize<T,DefaultsextendsOverwriteLeaves<T,unknown>=OverwriteLeaves<T,undefined>,>=UnconstrainedDeepOptionalize<T,Defaults>;typeUnconstrainedDeepOptionalize<T,Defaults>=Textendsobject
? Textendsreadonlyunknown[]
? keyofTextendskeyofDefaults// <<<<< THE PART CAUSING PROBLEMS
? {[KinkeyofT]: UnconstrainedDeepOptionalize<T[K],Defaults[K]>}
: never
: keyofTextendskeyofDefaults
? {[KinkeyofT]?: UnconstrainedDeepOptionalize<T[K],Defaults[K]>;}&UnconstrainedDeepOptionalizeRequiredPart<T,Defaults>
: never
: T;typeUnconstrainedDeepOptionalizeRequiredPart<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[KinRequiredKeysFromDefaults<T,Defaults>&keyofDefaults]: UnconstrainedDeepOptionalize<T[K],Defaults[K]>;},>=IsEmptyObject<Result>extendstrue
? // 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;typeRequiredKeysFromDefaults<T,Defaults>={[KinkeyofT&keyofDefaults]: Intersects<Defaults[K],T[K]>extendsfalse ? K : never;}[keyofT&keyofDefaults];// -----------------// Utility types// -----------------typeIntersects<U,T>=UextendsT ? true : false;typeIsEmptyObject<T>=[T,T,keyofT]extends[UnionToIntersection<T>,Record<PropertyKey,unknown>,never]
? true
: false;typeUnionToIntersection<T>=(Textendsunknown ? (x: T)=>unknown : never)extends(x: infer R)=>unknown
? R
: never;
Ideally, the code for UnconstrainedDeepOptionalize should actually look like this:
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.
Activity
Andarist commentedon Feb 12, 2026
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 Tbecomes a substitution type in the true type of this conditional.aweebit commentedon Feb 13, 2026
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:
Ideally, the code for
UnconstrainedDeepOptionalizeshould actually look like this:I.e.
keyof T extends keyof Defaultsshould not be repeated two times. However, despite solving the demonstrated issue, implementing it this way introduces a new error in the true branch of theT extends readonly unknown[]check: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:
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 :)