Skip to content

Commit 8d5c197

Browse files
authored
keyof should always include remapped keys (microsoft#45923)
* Loosen check in getIndexTypeForMappedType to directly map property names when any indexy type is present * Handle homomorphic mappings better in keyof, add specific relationship rule for relating generic keyof MappedType to handle remapped keys * Remove trailing whitespace
1 parent 530b0e2 commit 8d5c197

7 files changed

+642
-33
lines changed

Diff for: src/compiler/checker.ts

+98-26
Original file line numberDiff line numberDiff line change
@@ -11265,6 +11265,22 @@ namespace ts {
1126511265
return getCheckFlags(s) & CheckFlags.Late;
1126611266
}
1126711267

11268+
function forEachMappedTypePropertyKeyTypeAndIndexSignatureKeyType(type: Type, include: TypeFlags, stringsOnly: boolean, cb: (keyType: Type) => void) {
11269+
for (const prop of getPropertiesOfType(type)) {
11270+
cb(getLiteralTypeFromProperty(prop, include));
11271+
}
11272+
if (type.flags & TypeFlags.Any) {
11273+
cb(stringType);
11274+
}
11275+
else {
11276+
for (const info of getIndexInfosOfType(type)) {
11277+
if (!stringsOnly || info.keyType.flags & (TypeFlags.String | TypeFlags.TemplateLiteral)) {
11278+
cb(info.keyType);
11279+
}
11280+
}
11281+
}
11282+
}
11283+
1126811284
/** Resolve the members of a mapped type { [P in K]: T } */
1126911285
function resolveMappedTypeMembers(type: MappedType) {
1127011286
const members: SymbolTable = createSymbolTable();
@@ -11282,19 +11298,7 @@ namespace ts {
1128211298
const include = keyofStringsOnly ? TypeFlags.StringLiteral : TypeFlags.StringOrNumberLiteralOrUnique;
1128311299
if (isMappedTypeWithKeyofConstraintDeclaration(type)) {
1128411300
// We have a { [P in keyof T]: X }
11285-
for (const prop of getPropertiesOfType(modifiersType)) {
11286-
addMemberForKeyType(getLiteralTypeFromProperty(prop, include));
11287-
}
11288-
if (modifiersType.flags & TypeFlags.Any) {
11289-
addMemberForKeyType(stringType);
11290-
}
11291-
else {
11292-
for (const info of getIndexInfosOfType(modifiersType)) {
11293-
if (!keyofStringsOnly || info.keyType.flags & (TypeFlags.String | TypeFlags.TemplateLiteral)) {
11294-
addMemberForKeyType(info.keyType);
11295-
}
11296-
}
11297-
}
11301+
forEachMappedTypePropertyKeyTypeAndIndexSignatureKeyType(modifiersType, include, keyofStringsOnly, addMemberForKeyType);
1129811302
}
1129911303
else {
1130011304
forEachType(getLowerBoundOfKeyType(constraintType), addMemberForKeyType);
@@ -14653,19 +14657,58 @@ namespace ts {
1465314657
type.resolvedIndexType || (type.resolvedIndexType = createIndexType(type, /*stringsOnly*/ false));
1465414658
}
1465514659

14656-
function instantiateTypeAsMappedNameType(nameType: Type, type: MappedType, t: Type) {
14657-
return instantiateType(nameType, appendTypeMapping(type.mapper, getTypeParameterFromMappedType(type), t));
14658-
}
14660+
/**
14661+
* This roughly mirrors `resolveMappedTypeMembers` in the nongeneric case, except only reports a union of the keys calculated,
14662+
* rather than manufacturing the properties. We can't just fetch the `constraintType` since that would ignore mappings
14663+
* and mapping the `constraintType` directly ignores how mapped types map _properties_ and not keys (thus ignoring subtype
14664+
* reduction in the constraintType) when possible.
14665+
* @param noIndexSignatures Indicates if _string_ index signatures should be elided. (other index signatures are always reported)
14666+
*/
14667+
function getIndexTypeForMappedType(type: MappedType, stringsOnly: boolean, noIndexSignatures: boolean | undefined) {
14668+
const typeParameter = getTypeParameterFromMappedType(type);
14669+
const constraintType = getConstraintTypeFromMappedType(type);
14670+
const nameType = getNameTypeFromMappedType(type.target as MappedType || type);
14671+
if (!nameType && !noIndexSignatures) {
14672+
// no mapping and no filtering required, just quickly bail to returning the constraint in the common case
14673+
return constraintType;
14674+
}
14675+
const keyTypes: Type[] = [];
14676+
if (isMappedTypeWithKeyofConstraintDeclaration(type)) {
14677+
// We have a { [P in keyof T]: X }
14678+
14679+
// `getApparentType` on the T in a generic mapped type can trigger a circularity
14680+
// (conditionals and `infer` types create a circular dependency in the constraint resolution)
14681+
// so we only eagerly manifest the keys if the constraint is nongeneric
14682+
if (!isGenericIndexType(constraintType)) {
14683+
const modifiersType = getApparentType(getModifiersTypeFromMappedType(type)); // The 'T' in 'keyof T'
14684+
forEachMappedTypePropertyKeyTypeAndIndexSignatureKeyType(modifiersType, TypeFlags.StringOrNumberLiteralOrUnique, stringsOnly, addMemberForKeyType);
14685+
}
14686+
else {
14687+
// we have a generic index and a homomorphic mapping (but a distributive key remapping) - we need to defer the whole `keyof whatever` for later
14688+
// since it's not safe to resolve the shape of modifier type
14689+
return getIndexTypeForGenericType(type, stringsOnly);
14690+
}
14691+
}
14692+
else {
14693+
forEachType(getLowerBoundOfKeyType(constraintType), addMemberForKeyType);
14694+
}
14695+
if (isGenericIndexType(constraintType)) { // include the generic component in the resulting type
14696+
forEachType(constraintType, addMemberForKeyType);
14697+
}
14698+
// we had to pick apart the constraintType to potentially map/filter it - compare the final resulting list with the original constraintType,
14699+
// so we can return the union that preserves aliases/origin data if possible
14700+
const result = noIndexSignatures ? filterType(getUnionType(keyTypes), t => !(t.flags & (TypeFlags.Any | TypeFlags.String))) : getUnionType(keyTypes);
14701+
if (result.flags & TypeFlags.Union && constraintType.flags & TypeFlags.Union && getTypeListId((result as UnionType).types) === getTypeListId((constraintType as UnionType).types)){
14702+
return constraintType;
14703+
}
14704+
return result;
1465914705

14660-
function getIndexTypeForMappedType(type: MappedType, noIndexSignatures: boolean | undefined) {
14661-
const constraint = filterType(getConstraintTypeFromMappedType(type), t => !(noIndexSignatures && t.flags & (TypeFlags.Any | TypeFlags.String)));
14662-
const nameType = type.declaration.nameType && getTypeFromTypeNode(type.declaration.nameType);
14663-
// If the constraint is exclusively string/number/never type(s), we need to pull the property names from the modified type and run them through the `nameType` mapper as well
14664-
// since they won't appear in the constraint, due to subtype reducing with the string/number index types
14665-
const properties = nameType && everyType(constraint, t => !!(t.flags & (TypeFlags.String | TypeFlags.Number | TypeFlags.Never))) && getPropertiesOfType(getApparentType(getModifiersTypeFromMappedType(type)));
14666-
return nameType ?
14667-
getUnionType([mapType(constraint, t => instantiateTypeAsMappedNameType(nameType, type, t)), mapType(getUnionType(map(properties || emptyArray, p => getLiteralTypeFromProperty(p, TypeFlags.StringOrNumberLiteralOrUnique))), t => instantiateTypeAsMappedNameType(nameType, type, t))]):
14668-
constraint;
14706+
function addMemberForKeyType(keyType: Type) {
14707+
const propNameType = nameType ? instantiateType(nameType, appendTypeMapping(type.mapper, typeParameter, keyType)) : keyType;
14708+
// `keyof` currently always returns `string | number` for concrete `string` index signatures - the below ternary keeps that behavior for mapped types
14709+
// See `getLiteralTypeFromProperties` where there's a similar ternary to cause the same behavior.
14710+
keyTypes.push(propNameType === stringType ? stringOrNumberType : propNameType);
14711+
}
1466914712
}
1467014713

1467114714
// Ordinarily we reduce a keyof M, where M is a mapped type { [P in K as N<P>]: X }, to simply N<K>. This however presumes
@@ -14728,7 +14771,7 @@ namespace ts {
1472814771
return type.flags & TypeFlags.Union ? getIntersectionType(map((type as UnionType).types, t => getIndexType(t, stringsOnly, noIndexSignatures))) :
1472914772
type.flags & TypeFlags.Intersection ? getUnionType(map((type as IntersectionType).types, t => getIndexType(t, stringsOnly, noIndexSignatures))) :
1473014773
type.flags & TypeFlags.InstantiableNonPrimitive || isGenericTupleType(type) || isGenericMappedType(type) && !hasDistributiveNameType(type) ? getIndexTypeForGenericType(type as InstantiableType | UnionOrIntersectionType, stringsOnly) :
14731-
getObjectFlags(type) & ObjectFlags.Mapped ? getIndexTypeForMappedType(type as MappedType, noIndexSignatures) :
14774+
getObjectFlags(type) & ObjectFlags.Mapped ? getIndexTypeForMappedType(type as MappedType, stringsOnly, noIndexSignatures) :
1473214775
type === wildcardType ? wildcardType :
1473314776
type.flags & TypeFlags.Unknown ? neverType :
1473414777
type.flags & (TypeFlags.Any | TypeFlags.Never) ? keyofConstraintType :
@@ -18793,6 +18836,35 @@ namespace ts {
1879318836
return Ternary.True;
1879418837
}
1879518838
}
18839+
else if (isGenericMappedType(targetType)) {
18840+
// generic mapped types that don't simplify or have a constraint still have a very simple set of keys we can compare against
18841+
// - their nameType or constraintType.
18842+
// In many ways, this comparison is a deferred version of what `getIndexTypeForMappedType` does to actually resolve the keys for _non_-generic types
18843+
18844+
const nameType = getNameTypeFromMappedType(targetType);
18845+
const constraintType = getConstraintTypeFromMappedType(targetType);
18846+
let targetKeys;
18847+
if (nameType && isMappedTypeWithKeyofConstraintDeclaration(targetType)) {
18848+
// we need to get the apparent mappings and union them with the generic mappings, since some properties may be
18849+
// missing from the `constraintType` which will otherwise be mapped in the object
18850+
const modifiersType = getApparentType(getModifiersTypeFromMappedType(targetType));
18851+
const mappedKeys: Type[] = [];
18852+
forEachMappedTypePropertyKeyTypeAndIndexSignatureKeyType(
18853+
modifiersType,
18854+
TypeFlags.StringOrNumberLiteralOrUnique,
18855+
/*stringsOnly*/ false,
18856+
t => void mappedKeys.push(instantiateType(nameType, appendTypeMapping(targetType.mapper, getTypeParameterFromMappedType(targetType), t)))
18857+
);
18858+
// We still need to include the non-apparent (and thus still generic) keys in the target side of the comparison (in case they're in the source side)
18859+
targetKeys = getUnionType([...mappedKeys, nameType]);
18860+
}
18861+
else {
18862+
targetKeys = nameType || constraintType;
18863+
}
18864+
if (isRelatedTo(source, targetKeys, reportErrors) === Ternary.True) {
18865+
return Ternary.True;
18866+
}
18867+
}
1879618868
}
1879718869
}
1879818870
else if (target.flags & TypeFlags.IndexedAccess) {

Diff for: tests/baselines/reference/computedTypesKeyofNoIndexSignatureType.types

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,5 @@ type WithIndexKey = keyof WithIndex; // string | number <-- Expected: stri
3939
>WithIndexKey : string | number
4040

4141
type WithoutIndexKey = keyof WithoutIndex; // number <-- Expected: "foo" | "bar"
42-
>WithoutIndexKey : number | "foo" | "bar"
42+
>WithoutIndexKey : "foo" | "bar"
4343

Diff for: tests/baselines/reference/keyRemappingKeyofResult.js

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
//// [keyRemappingKeyofResult.ts]
2+
const sym = Symbol("")
3+
type Orig = { [k: string]: any, str: any, [sym]: any }
4+
5+
type Okay = Exclude<keyof Orig, never>
6+
// type Okay = string | number | typeof sym
7+
8+
type Remapped = { [K in keyof Orig as {} extends Record<K, any> ? never : K]: any }
9+
/* type Remapped = {
10+
str: any;
11+
[sym]: any;
12+
} */
13+
// no string index signature, right?
14+
15+
type Oops = Exclude<keyof Remapped, never>
16+
declare let x: Oops;
17+
x = sym;
18+
x = "str";
19+
// type Oops = typeof sym <-- what happened to "str"?
20+
21+
// equivalently, with an unresolved generic (no `exclude` shenanigans, since conditions won't execute):
22+
function f<T>() {
23+
type Orig = { [k: string]: any, str: any, [sym]: any } & T;
24+
25+
type Okay = keyof Orig;
26+
let a: Okay;
27+
a = "str";
28+
a = sym;
29+
a = "whatever";
30+
// type Okay = string | number | typeof sym
31+
32+
type Remapped = { [K in keyof Orig as {} extends Record<K, any> ? never : K]: any }
33+
/* type Remapped = {
34+
str: any;
35+
[sym]: any;
36+
} */
37+
// no string index signature, right?
38+
39+
type Oops = keyof Remapped;
40+
let x: Oops;
41+
x = sym;
42+
x = "str";
43+
}
44+
45+
// and another generic case with a _distributive_ mapping, to trigger a different branch in `getIndexType`
46+
function g<T>() {
47+
type Orig = { [k: string]: any, str: any, [sym]: any } & T;
48+
49+
type Okay = keyof Orig;
50+
let a: Okay;
51+
a = "str";
52+
a = sym;
53+
a = "whatever";
54+
// type Okay = string | number | typeof sym
55+
56+
type NonIndex<T extends PropertyKey> = {} extends Record<T, any> ? never : T;
57+
type DistributiveNonIndex<T extends PropertyKey> = T extends unknown ? NonIndex<T> : never;
58+
59+
type Remapped = { [K in keyof Orig as DistributiveNonIndex<K>]: any }
60+
/* type Remapped = {
61+
str: any;
62+
[sym]: any;
63+
} */
64+
// no string index signature, right?
65+
66+
type Oops = keyof Remapped;
67+
let x: Oops;
68+
x = sym;
69+
x = "str";
70+
}
71+
72+
export {};
73+
74+
//// [keyRemappingKeyofResult.js]
75+
const sym = Symbol("");
76+
x = sym;
77+
x = "str";
78+
// type Oops = typeof sym <-- what happened to "str"?
79+
// equivalently, with an unresolved generic (no `exclude` shenanigans, since conditions won't execute):
80+
function f() {
81+
let a;
82+
a = "str";
83+
a = sym;
84+
a = "whatever";
85+
let x;
86+
x = sym;
87+
x = "str";
88+
}
89+
// and another generic case with a _distributive_ mapping, to trigger a different branch in `getIndexType`
90+
function g() {
91+
let a;
92+
a = "str";
93+
a = sym;
94+
a = "whatever";
95+
let x;
96+
x = sym;
97+
x = "str";
98+
}
99+
export {};

0 commit comments

Comments
 (0)