diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index 51c9493b61721..ffc7bcaed76e0 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -14782,6 +14782,18 @@ export function createTypeChecker(host: TypeCheckerHost): TypeChecker { if (existingProp) { existingProp.links.nameType = getUnionType([existingProp.links.nameType!, propNameType]); existingProp.links.keyType = getUnionType([existingProp.links.keyType, keyType]); + const modifiersProp = isTypeUsableAsPropertyName(keyType) ? getPropertyOfType(modifiersType, getPropertyNameFromType(keyType)) : undefined; + const isOptional = !!(templateModifiers & MappedTypeModifiers.IncludeOptional || + !(templateModifiers & MappedTypeModifiers.ExcludeOptional) && modifiersProp && modifiersProp.flags & SymbolFlags.Optional); + const isReadonly = !!(templateModifiers & MappedTypeModifiers.IncludeReadonly || + !(templateModifiers & MappedTypeModifiers.ExcludeReadonly) && modifiersProp && isReadonlySymbol(modifiersProp)); + if (isOptional && !(existingProp.flags & SymbolFlags.Optional)) { + existingProp.flags |= SymbolFlags.Optional; + existingProp.links.checkFlags &= ~CheckFlags.StripOptional; + } + if (isReadonly) { + existingProp.links.checkFlags |= CheckFlags.Readonly; + } } else { const modifiersProp = isTypeUsableAsPropertyName(keyType) ? getPropertyOfType(modifiersType, getPropertyNameFromType(keyType)) : undefined; diff --git a/tests/baselines/reference/mappedTypeRemappingModifierMerging.errors.txt b/tests/baselines/reference/mappedTypeRemappingModifierMerging.errors.txt new file mode 100644 index 0000000000000..3d5a2fbf7d7cc --- /dev/null +++ b/tests/baselines/reference/mappedTypeRemappingModifierMerging.errors.txt @@ -0,0 +1,56 @@ +mappedTypeRemappingModifierMerging.ts(45,5): error TS2540: Cannot assign to 'foo' because it is a read-only property. +mappedTypeRemappingModifierMerging.ts(46,5): error TS2540: Cannot assign to 'foo' because it is a read-only property. + + +==== mappedTypeRemappingModifierMerging.ts (2 errors) ==== + // Mapped types with key remapping should merge modifiers consistently + // when multiple keys map to the same output key + + type RemapKeyToInitialPart = { + [K in keyof T as K extends `${infer First}.${infer _Rest}` ? First : K]: null; + }; + + // Both should produce { foo?: null } since at least one input is optional + type FirstOptional = RemapKeyToInitialPart<{ + "foo.bar"?: string; + "foo.baz": number; + }>; + + type FirstRequired = RemapKeyToInitialPart<{ + "foo.baz": number; + "foo.bar"?: string; + }>; + + // Test that they are equivalent + const testOptional: FirstOptional = { foo: null }; + const testOptional2: FirstOptional = {}; + + const testRequired: FirstRequired = { foo: null }; + const testRequired2: FirstRequired = {}; + + // Readonly should work the same way + type RemapWithReadonly = { + [K in keyof T as K extends `${infer First}.${string}` ? First : K]: null; + }; + + type FirstReadonly = RemapWithReadonly<{ + readonly "foo.bar": string; + "foo.baz": number; + }>; + + type SecondReadonly = RemapWithReadonly<{ + "foo.baz": number; + readonly "foo.bar": string; + }>; + + declare const ro1: FirstReadonly; + declare const ro2: SecondReadonly; + + // Both should be readonly + ro1.foo = null; // Error + ~~~ +!!! error TS2540: Cannot assign to 'foo' because it is a read-only property. + ro2.foo = null; // Error + ~~~ +!!! error TS2540: Cannot assign to 'foo' because it is a read-only property. + \ No newline at end of file diff --git a/tests/baselines/reference/mappedTypeRemappingModifierMerging.js b/tests/baselines/reference/mappedTypeRemappingModifierMerging.js new file mode 100644 index 0000000000000..aa2186d860601 --- /dev/null +++ b/tests/baselines/reference/mappedTypeRemappingModifierMerging.js @@ -0,0 +1,94 @@ +//// [tests/cases/compiler/mappedTypeRemappingModifierMerging.ts] //// + +//// [mappedTypeRemappingModifierMerging.ts] +// Mapped types with key remapping should merge modifiers consistently +// when multiple keys map to the same output key + +type RemapKeyToInitialPart = { + [K in keyof T as K extends `${infer First}.${infer _Rest}` ? First : K]: null; +}; + +// Both should produce { foo?: null } since at least one input is optional +type FirstOptional = RemapKeyToInitialPart<{ + "foo.bar"?: string; + "foo.baz": number; +}>; + +type FirstRequired = RemapKeyToInitialPart<{ + "foo.baz": number; + "foo.bar"?: string; +}>; + +// Test that they are equivalent +const testOptional: FirstOptional = { foo: null }; +const testOptional2: FirstOptional = {}; + +const testRequired: FirstRequired = { foo: null }; +const testRequired2: FirstRequired = {}; + +// Readonly should work the same way +type RemapWithReadonly = { + [K in keyof T as K extends `${infer First}.${string}` ? First : K]: null; +}; + +type FirstReadonly = RemapWithReadonly<{ + readonly "foo.bar": string; + "foo.baz": number; +}>; + +type SecondReadonly = RemapWithReadonly<{ + "foo.baz": number; + readonly "foo.bar": string; +}>; + +declare const ro1: FirstReadonly; +declare const ro2: SecondReadonly; + +// Both should be readonly +ro1.foo = null; // Error +ro2.foo = null; // Error + + +//// [mappedTypeRemappingModifierMerging.js] +"use strict"; +// Mapped types with key remapping should merge modifiers consistently +// when multiple keys map to the same output key +// Test that they are equivalent +var testOptional = { foo: null }; +var testOptional2 = {}; +var testRequired = { foo: null }; +var testRequired2 = {}; +// Both should be readonly +ro1.foo = null; // Error +ro2.foo = null; // Error + + +//// [mappedTypeRemappingModifierMerging.d.ts] +type RemapKeyToInitialPart = { + [K in keyof T as K extends `${infer First}.${infer _Rest}` ? First : K]: null; +}; +type FirstOptional = RemapKeyToInitialPart<{ + "foo.bar"?: string; + "foo.baz": number; +}>; +type FirstRequired = RemapKeyToInitialPart<{ + "foo.baz": number; + "foo.bar"?: string; +}>; +declare const testOptional: FirstOptional; +declare const testOptional2: FirstOptional; +declare const testRequired: FirstRequired; +declare const testRequired2: FirstRequired; +type RemapWithReadonly = { + [K in keyof T as K extends `${infer First}.${string}` ? First : K]: null; +}; +type FirstReadonly = RemapWithReadonly<{ + readonly "foo.bar": string; + "foo.baz": number; +}>; +type SecondReadonly = RemapWithReadonly<{ + "foo.baz": number; + readonly "foo.bar": string; +}>; +declare const ro1: FirstReadonly; +declare const ro2: SecondReadonly; diff --git a/tests/baselines/reference/mappedTypeRemappingModifierMerging.symbols b/tests/baselines/reference/mappedTypeRemappingModifierMerging.symbols new file mode 100644 index 0000000000000..22c48d0417a88 --- /dev/null +++ b/tests/baselines/reference/mappedTypeRemappingModifierMerging.symbols @@ -0,0 +1,123 @@ +//// [tests/cases/compiler/mappedTypeRemappingModifierMerging.ts] //// + +=== mappedTypeRemappingModifierMerging.ts === +// Mapped types with key remapping should merge modifiers consistently +// when multiple keys map to the same output key + +type RemapKeyToInitialPart = { +>RemapKeyToInitialPart : Symbol(RemapKeyToInitialPart, Decl(mappedTypeRemappingModifierMerging.ts, 0, 0)) +>T : Symbol(T, Decl(mappedTypeRemappingModifierMerging.ts, 3, 27)) + + [K in keyof T as K extends `${infer First}.${infer _Rest}` ? First : K]: null; +>K : Symbol(K, Decl(mappedTypeRemappingModifierMerging.ts, 4, 5)) +>T : Symbol(T, Decl(mappedTypeRemappingModifierMerging.ts, 3, 27)) +>K : Symbol(K, Decl(mappedTypeRemappingModifierMerging.ts, 4, 5)) +>First : Symbol(First, Decl(mappedTypeRemappingModifierMerging.ts, 4, 39)) +>_Rest : Symbol(_Rest, Decl(mappedTypeRemappingModifierMerging.ts, 4, 54)) +>First : Symbol(First, Decl(mappedTypeRemappingModifierMerging.ts, 4, 39)) +>K : Symbol(K, Decl(mappedTypeRemappingModifierMerging.ts, 4, 5)) + +}; + +// Both should produce { foo?: null } since at least one input is optional +type FirstOptional = RemapKeyToInitialPart<{ +>FirstOptional : Symbol(FirstOptional, Decl(mappedTypeRemappingModifierMerging.ts, 5, 2)) +>RemapKeyToInitialPart : Symbol(RemapKeyToInitialPart, Decl(mappedTypeRemappingModifierMerging.ts, 0, 0)) + + "foo.bar"?: string; +>"foo.bar" : Symbol("foo.bar", Decl(mappedTypeRemappingModifierMerging.ts, 8, 44)) + + "foo.baz": number; +>"foo.baz" : Symbol("foo.baz", Decl(mappedTypeRemappingModifierMerging.ts, 9, 23)) + +}>; + +type FirstRequired = RemapKeyToInitialPart<{ +>FirstRequired : Symbol(FirstRequired, Decl(mappedTypeRemappingModifierMerging.ts, 11, 3)) +>RemapKeyToInitialPart : Symbol(RemapKeyToInitialPart, Decl(mappedTypeRemappingModifierMerging.ts, 0, 0)) + + "foo.baz": number; +>"foo.baz" : Symbol("foo.baz", Decl(mappedTypeRemappingModifierMerging.ts, 13, 44)) + + "foo.bar"?: string; +>"foo.bar" : Symbol("foo.bar", Decl(mappedTypeRemappingModifierMerging.ts, 14, 22)) + +}>; + +// Test that they are equivalent +const testOptional: FirstOptional = { foo: null }; +>testOptional : Symbol(testOptional, Decl(mappedTypeRemappingModifierMerging.ts, 19, 5)) +>FirstOptional : Symbol(FirstOptional, Decl(mappedTypeRemappingModifierMerging.ts, 5, 2)) +>foo : Symbol(foo, Decl(mappedTypeRemappingModifierMerging.ts, 19, 37)) + +const testOptional2: FirstOptional = {}; +>testOptional2 : Symbol(testOptional2, Decl(mappedTypeRemappingModifierMerging.ts, 20, 5)) +>FirstOptional : Symbol(FirstOptional, Decl(mappedTypeRemappingModifierMerging.ts, 5, 2)) + +const testRequired: FirstRequired = { foo: null }; +>testRequired : Symbol(testRequired, Decl(mappedTypeRemappingModifierMerging.ts, 22, 5)) +>FirstRequired : Symbol(FirstRequired, Decl(mappedTypeRemappingModifierMerging.ts, 11, 3)) +>foo : Symbol(foo, Decl(mappedTypeRemappingModifierMerging.ts, 22, 37)) + +const testRequired2: FirstRequired = {}; +>testRequired2 : Symbol(testRequired2, Decl(mappedTypeRemappingModifierMerging.ts, 23, 5)) +>FirstRequired : Symbol(FirstRequired, Decl(mappedTypeRemappingModifierMerging.ts, 11, 3)) + +// Readonly should work the same way +type RemapWithReadonly = { +>RemapWithReadonly : Symbol(RemapWithReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 23, 40)) +>T : Symbol(T, Decl(mappedTypeRemappingModifierMerging.ts, 26, 23)) + + [K in keyof T as K extends `${infer First}.${string}` ? First : K]: null; +>K : Symbol(K, Decl(mappedTypeRemappingModifierMerging.ts, 27, 5)) +>T : Symbol(T, Decl(mappedTypeRemappingModifierMerging.ts, 26, 23)) +>K : Symbol(K, Decl(mappedTypeRemappingModifierMerging.ts, 27, 5)) +>First : Symbol(First, Decl(mappedTypeRemappingModifierMerging.ts, 27, 39)) +>First : Symbol(First, Decl(mappedTypeRemappingModifierMerging.ts, 27, 39)) +>K : Symbol(K, Decl(mappedTypeRemappingModifierMerging.ts, 27, 5)) + +}; + +type FirstReadonly = RemapWithReadonly<{ +>FirstReadonly : Symbol(FirstReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 28, 2)) +>RemapWithReadonly : Symbol(RemapWithReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 23, 40)) + + readonly "foo.bar": string; +>"foo.bar" : Symbol("foo.bar", Decl(mappedTypeRemappingModifierMerging.ts, 30, 40)) + + "foo.baz": number; +>"foo.baz" : Symbol("foo.baz", Decl(mappedTypeRemappingModifierMerging.ts, 31, 31)) + +}>; + +type SecondReadonly = RemapWithReadonly<{ +>SecondReadonly : Symbol(SecondReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 33, 3)) +>RemapWithReadonly : Symbol(RemapWithReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 23, 40)) + + "foo.baz": number; +>"foo.baz" : Symbol("foo.baz", Decl(mappedTypeRemappingModifierMerging.ts, 35, 41)) + + readonly "foo.bar": string; +>"foo.bar" : Symbol("foo.bar", Decl(mappedTypeRemappingModifierMerging.ts, 36, 22)) + +}>; + +declare const ro1: FirstReadonly; +>ro1 : Symbol(ro1, Decl(mappedTypeRemappingModifierMerging.ts, 40, 13)) +>FirstReadonly : Symbol(FirstReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 28, 2)) + +declare const ro2: SecondReadonly; +>ro2 : Symbol(ro2, Decl(mappedTypeRemappingModifierMerging.ts, 41, 13)) +>SecondReadonly : Symbol(SecondReadonly, Decl(mappedTypeRemappingModifierMerging.ts, 33, 3)) + +// Both should be readonly +ro1.foo = null; // Error +>ro1.foo : Symbol(foo) +>ro1 : Symbol(ro1, Decl(mappedTypeRemappingModifierMerging.ts, 40, 13)) +>foo : Symbol(foo) + +ro2.foo = null; // Error +>ro2.foo : Symbol(foo) +>ro2 : Symbol(ro2, Decl(mappedTypeRemappingModifierMerging.ts, 41, 13)) +>foo : Symbol(foo) + diff --git a/tests/baselines/reference/mappedTypeRemappingModifierMerging.types b/tests/baselines/reference/mappedTypeRemappingModifierMerging.types new file mode 100644 index 0000000000000..40d5ceabb057e --- /dev/null +++ b/tests/baselines/reference/mappedTypeRemappingModifierMerging.types @@ -0,0 +1,136 @@ +//// [tests/cases/compiler/mappedTypeRemappingModifierMerging.ts] //// + +=== mappedTypeRemappingModifierMerging.ts === +// Mapped types with key remapping should merge modifiers consistently +// when multiple keys map to the same output key + +type RemapKeyToInitialPart = { +>RemapKeyToInitialPart : RemapKeyToInitialPart +> : ^^^^^^^^^^^^^^^^^^^^^^^^ + + [K in keyof T as K extends `${infer First}.${infer _Rest}` ? First : K]: null; +}; + +// Both should produce { foo?: null } since at least one input is optional +type FirstOptional = RemapKeyToInitialPart<{ +>FirstOptional : RemapKeyToInitialPart<{ "foo.bar"?: string; "foo.baz": number; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^ + + "foo.bar"?: string; +>"foo.bar" : string | undefined +> : ^^^^^^^^^^^^^^^^^^ + + "foo.baz": number; +>"foo.baz" : number +> : ^^^^^^ + +}>; + +type FirstRequired = RemapKeyToInitialPart<{ +>FirstRequired : RemapKeyToInitialPart<{ "foo.baz": number; "foo.bar"?: string; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ ^^^^ + + "foo.baz": number; +>"foo.baz" : number +> : ^^^^^^ + + "foo.bar"?: string; +>"foo.bar" : string | undefined +> : ^^^^^^^^^^^^^^^^^^ + +}>; + +// Test that they are equivalent +const testOptional: FirstOptional = { foo: null }; +>testOptional : RemapKeyToInitialPart<{ "foo.bar"?: string; "foo.baz": number; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^ +>{ foo: null } : { foo: null; } +> : ^^^^^^^^^^^^^^ +>foo : null +> : ^^^^ + +const testOptional2: FirstOptional = {}; +>testOptional2 : RemapKeyToInitialPart<{ "foo.bar"?: string; "foo.baz": number; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^ +>{} : {} +> : ^^ + +const testRequired: FirstRequired = { foo: null }; +>testRequired : RemapKeyToInitialPart<{ "foo.baz": number; "foo.bar"?: string; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ ^^^^ +>{ foo: null } : { foo: null; } +> : ^^^^^^^^^^^^^^ +>foo : null +> : ^^^^ + +const testRequired2: FirstRequired = {}; +>testRequired2 : RemapKeyToInitialPart<{ "foo.baz": number; "foo.bar"?: string; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^ ^^^^ +>{} : {} +> : ^^ + +// Readonly should work the same way +type RemapWithReadonly = { +>RemapWithReadonly : RemapWithReadonly +> : ^^^^^^^^^^^^^^^^^^^^ + + [K in keyof T as K extends `${infer First}.${string}` ? First : K]: null; +}; + +type FirstReadonly = RemapWithReadonly<{ +>FirstReadonly : RemapWithReadonly<{ readonly "foo.bar": string; "foo.baz": number; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^ + + readonly "foo.bar": string; +>"foo.bar" : string +> : ^^^^^^ + + "foo.baz": number; +>"foo.baz" : number +> : ^^^^^^ + +}>; + +type SecondReadonly = RemapWithReadonly<{ +>SecondReadonly : RemapWithReadonly<{ "foo.baz": number; readonly "foo.bar": string; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^ + + "foo.baz": number; +>"foo.baz" : number +> : ^^^^^^ + + readonly "foo.bar": string; +>"foo.bar" : string +> : ^^^^^^ + +}>; + +declare const ro1: FirstReadonly; +>ro1 : RemapWithReadonly<{ readonly "foo.bar": string; "foo.baz": number; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^ + +declare const ro2: SecondReadonly; +>ro2 : RemapWithReadonly<{ "foo.baz": number; readonly "foo.bar": string; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^ + +// Both should be readonly +ro1.foo = null; // Error +>ro1.foo = null : null +> : ^^^^ +>ro1.foo : any +> : ^^^ +>ro1 : RemapWithReadonly<{ readonly "foo.bar": string; "foo.baz": number; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ ^^^^ +>foo : any +> : ^^^ + +ro2.foo = null; // Error +>ro2.foo = null : null +> : ^^^^ +>ro2.foo : any +> : ^^^ +>ro2 : RemapWithReadonly<{ "foo.baz": number; readonly "foo.bar": string; }> +> : ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^ ^^^^ +>foo : any +> : ^^^ + diff --git a/tests/cases/compiler/mappedTypeRemappingModifierMerging.ts b/tests/cases/compiler/mappedTypeRemappingModifierMerging.ts new file mode 100644 index 0000000000000..3b4c50bcaf113 --- /dev/null +++ b/tests/cases/compiler/mappedTypeRemappingModifierMerging.ts @@ -0,0 +1,49 @@ +// @strict: true +// @declaration: true + +// Mapped types with key remapping should merge modifiers consistently +// when multiple keys map to the same output key + +type RemapKeyToInitialPart = { + [K in keyof T as K extends `${infer First}.${infer _Rest}` ? First : K]: null; +}; + +// Both should produce { foo?: null } since at least one input is optional +type FirstOptional = RemapKeyToInitialPart<{ + "foo.bar"?: string; + "foo.baz": number; +}>; + +type FirstRequired = RemapKeyToInitialPart<{ + "foo.baz": number; + "foo.bar"?: string; +}>; + +// Test that they are equivalent +const testOptional: FirstOptional = { foo: null }; +const testOptional2: FirstOptional = {}; + +const testRequired: FirstRequired = { foo: null }; +const testRequired2: FirstRequired = {}; + +// Readonly should work the same way +type RemapWithReadonly = { + [K in keyof T as K extends `${infer First}.${string}` ? First : K]: null; +}; + +type FirstReadonly = RemapWithReadonly<{ + readonly "foo.bar": string; + "foo.baz": number; +}>; + +type SecondReadonly = RemapWithReadonly<{ + "foo.baz": number; + readonly "foo.bar": string; +}>; + +declare const ro1: FirstReadonly; +declare const ro2: SecondReadonly; + +// Both should be readonly +ro1.foo = null; // Error +ro2.foo = null; // Error