Skip to content

Commit 8056e2b

Browse files
authored
infer from usage's unification uses multiple passes (#28244)
* infer from usage's unification uses multiple passes Previously, the unification step of infer-from-usage codefix would stop as soon an answer was found. Now it continues if the result is *incomplete*, with the idea that later passes may provide a better inference. Currently, an *incomplete* inference is 1. The type any. 2. The empty object type `{}` or a union or intersection that contains `{}`. In the checker, any takes priority over other types since it basically shuts down type checking. For type inference, however, any is one of the least useful inferences. `{}` is not a good inference for a similar reason; as a parameter inference, it doesn't tell the caller much about what is expected, and it doesn't allow the function author to use an object as expected. But currently it's inferred whenever there's an initialisation with the value `{}`. With this change, subsequent property assignments to the same parameter will replace the `{}` with a specific anonymous type. For example: ```js function C(config) { if (config === undefined) config = {}; this.x = config.x; this.y = config.y; this.z = config.z; } ``` * Unify all passes of inference from usage In the previous commit, I changed inference from usage to continue inference if a the result was *incomplete*. This commit now runs all 4 inference passes and combines them in a unification step. Currently the unification step is simple, it: 1. Gathers all inferences in a list. 2. Makes properties of anonymous types optional if there is an empty object in the inference list. 3. Removes *vacuous* inferences. 4. Combines the type in a union. An inference is *vacuous* if it: 1. Is any or void, when a non-any, non-void type is also inferred. 2. Is the empty object type, when an object type that is not empty is also inferred. 3. Is an anonymous type, when a non-nullable, non-any, non-void, non-anonymous type is also inferred. I think I might eventually want a concept of priorities, like the compiler's type parameter inference, but I don't have enough examples to be sure yet. Eventually, unification should have an additional step that examines the whole inference list to see if its contents are collectively meaningless. A good example is `null | undefined`, which is not useful. * Remove isNumberOrString * Unify anonymous types @Andy-MS pointed out that my empty object code was a special case of merging all anonymous types from an inference and making properties optional that are not in all the anonymous type. So I did that instead. * Use getTypeOfSymbolAtLocation instead of Symbol.type! * Unify parameter call-site inferences too Because they still have a separate code path, they didn't use the new unification code. Also some cleanup from PR comments. * Add object type unification test Also remove dead code. * Only use fallback if no inferences were found Instead of relying on the unification code to remove the fallback.
1 parent 29dc7b2 commit 8056e2b

File tree

6 files changed

+122
-33
lines changed

6 files changed

+122
-33
lines changed

src/compiler/checker.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,7 @@ namespace ts {
301301
getESSymbolType: () => esSymbolType,
302302
getNeverType: () => neverType,
303303
isSymbolAccessible,
304+
getObjectFlags,
304305
isArrayLikeType,
305306
isTypeInvalidDueToUnionDiscriminant,
306307
getAllPossiblePropertiesOfTypes,

src/compiler/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3173,6 +3173,8 @@ namespace ts {
31733173
/* @internal */ getTypeCount(): number;
31743174

31753175
/* @internal */ isArrayLikeType(type: Type): boolean;
3176+
/* @internal */ getObjectFlags(type: Type): ObjectFlags;
3177+
31763178
/**
31773179
* True if `contextualType` should not be considered for completions because
31783180
* e.g. it specifies `kind: "a"` and obj has `kind: "b"`.

src/services/codefixes/inferFromUsage.ts

Lines changed: 85 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,9 @@ namespace ts.codefix {
369369
interface UsageContext {
370370
isNumber?: boolean;
371371
isString?: boolean;
372-
isNumberOrString?: boolean;
372+
hasNonVacuousType?: boolean;
373+
hasNonVacuousNonAnonymousType?: boolean;
374+
373375
candidateTypes?: Type[];
374376
properties?: UnderscoreEscapedMap<UsageContext>;
375377
callContexts?: CallContext[];
@@ -384,7 +386,7 @@ namespace ts.codefix {
384386
cancellationToken.throwIfCancellationRequested();
385387
inferTypeFromContext(reference, checker, usageContext);
386388
}
387-
return getTypeFromUsageContext(usageContext, checker) || checker.getAnyType();
389+
return unifyFromContext(inferFromContext(usageContext, checker), checker);
388390
}
389391

390392
export function inferTypeForParametersFromReferences(references: ReadonlyArray<Identifier>, declaration: FunctionLikeDeclaration, program: Program, cancellationToken: CancellationToken): ParameterInference[] | undefined {
@@ -411,6 +413,7 @@ namespace ts.codefix {
411413
for (const callContext of callContexts) {
412414
if (callContext.argumentTypes.length <= parameterIndex) {
413415
isOptional = isInJSFile(declaration);
416+
types.push(checker.getUndefinedType());
414417
continue;
415418
}
416419

@@ -423,14 +426,10 @@ namespace ts.codefix {
423426
types.push(checker.getBaseTypeOfLiteralType(callContext.argumentTypes[parameterIndex]));
424427
}
425428
}
426-
427-
let type = types.length && checker.getWidenedType(checker.getUnionType(types, UnionReduction.Subtype));
428-
if ((!type || type.flags & TypeFlags.Any) && isIdentifier(parameter.name)) {
429+
let type = unifyFromContext(types, checker);
430+
if (type.flags & TypeFlags.Any && isIdentifier(parameter.name)) {
429431
type = inferTypeForVariableFromUsage(parameter.name, program, cancellationToken);
430432
}
431-
if (!type) {
432-
type = checker.getAnyType();
433-
}
434433
return {
435434
type: isRest ? checker.createArrayType(type) : type,
436435
isOptional: isOptional && !isRest,
@@ -504,7 +503,8 @@ namespace ts.codefix {
504503
break;
505504

506505
case SyntaxKind.PlusToken:
507-
usageContext.isNumberOrString = true;
506+
usageContext.isNumber = true;
507+
usageContext.isString = true;
508508
break;
509509

510510
// case SyntaxKind.ExclamationToken:
@@ -575,7 +575,8 @@ namespace ts.codefix {
575575
usageContext.isString = true;
576576
}
577577
else {
578-
usageContext.isNumberOrString = true;
578+
usageContext.isNumber = true;
579+
usageContext.isString = true;
579580
}
580581
break;
581582

@@ -649,7 +650,8 @@ namespace ts.codefix {
649650

650651
function inferTypeFromPropertyElementExpressionContext(parent: ElementAccessExpression, node: Expression, checker: TypeChecker, usageContext: UsageContext): void {
651652
if (node === parent.argumentExpression) {
652-
usageContext.isNumberOrString = true;
653+
usageContext.isNumber = true;
654+
usageContext.isString = true;
653655
return;
654656
}
655657
else {
@@ -665,29 +667,83 @@ namespace ts.codefix {
665667
}
666668
}
667669

668-
function getTypeFromUsageContext(usageContext: UsageContext, checker: TypeChecker): Type | undefined {
669-
if (usageContext.isNumberOrString && !usageContext.isNumber && !usageContext.isString) {
670-
return checker.getUnionType([checker.getNumberType(), checker.getStringType()]);
670+
function unifyFromContext(inferences: ReadonlyArray<Type>, checker: TypeChecker, fallback = checker.getAnyType()): Type {
671+
if (!inferences.length) return fallback;
672+
const hasNonVacuousType = inferences.some(i => !(i.flags & (TypeFlags.Any | TypeFlags.Void)));
673+
const hasNonVacuousNonAnonymousType = inferences.some(
674+
i => !(i.flags & (TypeFlags.Nullable | TypeFlags.Any | TypeFlags.Void)) && !(checker.getObjectFlags(i) & ObjectFlags.Anonymous));
675+
const anons = inferences.filter(i => checker.getObjectFlags(i) & ObjectFlags.Anonymous) as AnonymousType[];
676+
const good = [];
677+
if (!hasNonVacuousNonAnonymousType && anons.length) {
678+
good.push(unifyAnonymousTypes(anons, checker));
671679
}
672-
else if (usageContext.isNumber) {
673-
return checker.getNumberType();
680+
good.push(...inferences.filter(i => !(checker.getObjectFlags(i) & ObjectFlags.Anonymous) && !(hasNonVacuousType && i.flags & (TypeFlags.Any | TypeFlags.Void))));
681+
return checker.getWidenedType(checker.getUnionType(good));
682+
}
683+
684+
function unifyAnonymousTypes(anons: AnonymousType[], checker: TypeChecker) {
685+
if (anons.length === 1) {
686+
return anons[0];
674687
}
675-
else if (usageContext.isString) {
676-
return checker.getStringType();
688+
const calls = [];
689+
const constructs = [];
690+
const stringIndices = [];
691+
const numberIndices = [];
692+
let stringIndexReadonly = false;
693+
let numberIndexReadonly = false;
694+
const props = createMultiMap<Type>();
695+
for (const anon of anons) {
696+
for (const p of checker.getPropertiesOfType(anon)) {
697+
props.add(p.name, checker.getTypeOfSymbolAtLocation(p, p.valueDeclaration));
698+
}
699+
calls.push(...checker.getSignaturesOfType(anon, SignatureKind.Call));
700+
constructs.push(...checker.getSignaturesOfType(anon, SignatureKind.Construct));
701+
if (anon.stringIndexInfo) {
702+
stringIndices.push(anon.stringIndexInfo.type);
703+
stringIndexReadonly = stringIndexReadonly || anon.stringIndexInfo.isReadonly;
704+
}
705+
if (anon.numberIndexInfo) {
706+
numberIndices.push(anon.numberIndexInfo.type);
707+
numberIndexReadonly = numberIndexReadonly || anon.numberIndexInfo.isReadonly;
708+
}
677709
}
678-
else if (usageContext.candidateTypes) {
679-
return checker.getWidenedType(checker.getUnionType(usageContext.candidateTypes.map(t => checker.getBaseTypeOfLiteralType(t)), UnionReduction.Subtype));
710+
const members = mapEntries(props, (name, types) => {
711+
const isOptional = types.length < anons.length ? SymbolFlags.Optional : 0;
712+
const s = checker.createSymbol(SymbolFlags.Property | isOptional, name as __String);
713+
s.type = checker.getUnionType(types);
714+
return [name, s];
715+
});
716+
return checker.createAnonymousType(
717+
anons[0].symbol,
718+
members as UnderscoreEscapedMap<TransientSymbol>,
719+
calls,
720+
constructs,
721+
stringIndices.length ? checker.createIndexInfo(checker.getUnionType(stringIndices), stringIndexReadonly) : undefined,
722+
numberIndices.length ? checker.createIndexInfo(checker.getUnionType(numberIndices), numberIndexReadonly) : undefined);
723+
}
724+
725+
function inferFromContext(usageContext: UsageContext, checker: TypeChecker) {
726+
const types = [];
727+
if (usageContext.isNumber) {
728+
types.push(checker.getNumberType());
680729
}
681-
else if (usageContext.properties && hasCallContext(usageContext.properties.get("then" as __String))) {
730+
if (usageContext.isString) {
731+
types.push(checker.getStringType());
732+
}
733+
734+
types.push(...(usageContext.candidateTypes || []).map(t => checker.getBaseTypeOfLiteralType(t)));
735+
736+
if (usageContext.properties && hasCallContext(usageContext.properties.get("then" as __String))) {
682737
const paramType = getParameterTypeFromCallContexts(0, usageContext.properties.get("then" as __String)!.callContexts!, /*isRestParameter*/ false, checker)!; // TODO: GH#18217
683738
const types = paramType.getCallSignatures().map(c => c.getReturnType());
684-
return checker.createPromiseType(types.length ? checker.getUnionType(types, UnionReduction.Subtype) : checker.getAnyType());
739+
types.push(checker.createPromiseType(types.length ? checker.getUnionType(types, UnionReduction.Subtype) : checker.getAnyType()));
685740
}
686741
else if (usageContext.properties && hasCallContext(usageContext.properties.get("push" as __String))) {
687-
return checker.createArrayType(getParameterTypeFromCallContexts(0, usageContext.properties.get("push" as __String)!.callContexts!, /*isRestParameter*/ false, checker)!);
742+
types.push(checker.createArrayType(getParameterTypeFromCallContexts(0, usageContext.properties.get("push" as __String)!.callContexts!, /*isRestParameter*/ false, checker)!));
688743
}
689-
else if (usageContext.numberIndexContext) {
690-
return checker.createArrayType(recur(usageContext.numberIndexContext));
744+
745+
if (usageContext.numberIndexContext) {
746+
return [checker.createArrayType(recur(usageContext.numberIndexContext))];
691747
}
692748
else if (usageContext.properties || usageContext.callContexts || usageContext.constructContexts || usageContext.stringIndexContext) {
693749
const members = createUnderscoreEscapedMap<Symbol>();
@@ -719,14 +775,12 @@ namespace ts.codefix {
719775
stringIndexInfo = checker.createIndexInfo(recur(usageContext.stringIndexContext), /*isReadonly*/ false);
720776
}
721777

722-
return checker.createAnonymousType(/*symbol*/ undefined!, members, callSignatures, constructSignatures, stringIndexInfo, /*numberIndexInfo*/ undefined); // TODO: GH#18217
723-
}
724-
else {
725-
return undefined;
778+
types.push(checker.createAnonymousType(/*symbol*/ undefined!, members, callSignatures, constructSignatures, stringIndexInfo, /*numberIndexInfo*/ undefined)); // TODO: GH#18217
726779
}
780+
return types;
727781

728782
function recur(innerContext: UsageContext): Type {
729-
return getTypeFromUsageContext(innerContext, checker) || checker.getAnyType();
783+
return unifyFromContext(inferFromContext(innerContext, checker), checker);
730784
}
731785
}
732786

@@ -759,7 +813,7 @@ namespace ts.codefix {
759813
symbol.type = checker.getWidenedType(checker.getBaseTypeOfLiteralType(callContext.argumentTypes[i]));
760814
parameters.push(symbol);
761815
}
762-
const returnType = getTypeFromUsageContext(callContext.returnType, checker) || checker.getVoidType();
816+
const returnType = unifyFromContext(inferFromContext(callContext.returnType, checker), checker, checker.getVoidType());
763817
// TODO: GH#18217
764818
return checker.createSignature(/*declaration*/ undefined!, /*typeParameters*/ undefined, /*thisParameter*/ undefined, parameters, returnType, /*typePredicate*/ undefined, callContext.argumentTypes.length, /*hasRestParameter*/ false, /*hasLiteralTypes*/ false);
765819
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/// <reference path='fourslash.ts' />
2+
// @strict: true
3+
// based on acorn, translated to TS
4+
5+
////function TokenType([|label, conf |]) {
6+
//// if ( conf === void 0 ) conf = {};
7+
////
8+
//// var l = label;
9+
//// var keyword = conf.keyword;
10+
//// var beforeExpr = !!conf.beforeExpr;
11+
////};
12+
13+
verify.rangeAfterCodeFix("label: any, conf: { keyword?: any; beforeExpr?: any; } | undefined",/*includeWhiteSpace*/ undefined, /*errorCode*/ undefined, 0);

tests/cases/fourslash/codeFixInferFromUsageMemberJS.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ verify.codeFixAll({
2727
constructor() {
2828
/**
2929
* this is fine
30-
* @type {undefined}
30+
* @type {number[] | undefined}
3131
*/
3232
this.p = undefined;
33-
/** @type {undefined} */
33+
/** @type {number[] | undefined} */
3434
this.q = undefined
3535
}
3636
method() {
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/// <reference path='fourslash.ts' />
2+
// @strict: true
3+
// based on acorn, translated to TS
4+
5+
////function kw([|name, options |]) {
6+
//// if ( options === void 0 ) options = {};
7+
////
8+
//// options.keyword = name;
9+
//// return keywords$1[name] = new TokenType(name, options)
10+
////}
11+
////kw("1")
12+
////kw("2", { startsExpr: true })
13+
////kw("3", { beforeExpr: false })
14+
////kw("4", { isLoop: false })
15+
////kw("5", { beforeExpr: true, startsExpr: true })
16+
////kw("6", { beforeExpr: true, prefix: true, startsExpr: true })
17+
18+
19+
verify.rangeAfterCodeFix("name: string, options: { startsExpr?: boolean; beforeExpr?: boolean; isLoop?: boolean; prefix?: boolean; } | undefined",/*includeWhiteSpace*/ undefined, /*errorCode*/ undefined, 0);

0 commit comments

Comments
 (0)