fix(language-service): Suggest ? and ! operator on nullable receiver (#35200)

Under strict mode, the language service fails to typecheck nullable
symbols that have already been verified to be non-null.

This generates incorrect (false positive) and confusing diagnostics
for users.

To work around this issue in the short term, this commit changes the
diagnostic message from an error to a suggestion, and prompts users to
use the safe navigation operator (?) or non-null assertion operator (!).

For example, instead of

```typescript
{{ optional && optional.toString() }}
```

the following is cleaner:

```typescript
{{ optional?.toString() }}
{{ optional!.toString() }}
```

Note that with this change, users who legitimately make a typo in their
code will no longer see an error. I think this is acceptable, since
false positive is worse than false negative. However, if users follow
the suggestion, add ? or ! to their code, then the error will be surfaced.
This seems a reasonable trade-off.

References:

1. Safe navigation operator (?)
   https://angular.io/guide/template-syntax#the-safe-navigation-operator----and-null-property-paths
2. Non-null assertion operator (!)
   https://angular.io/guide/template-syntax#the-non-null-assertion-operator---

PR closes https://github.com/angular/angular/pull/35070
PR closes https://github.com/angular/vscode-ng-language-service/issues/589

PR Close #35200
This commit is contained in:
Keen Yee Liau
2020-02-06 14:49:49 -08:00
committed by Kara Erickson
parent a92d97cda7
commit 81241af7ac
4 changed files with 98 additions and 33 deletions

View File

@ -339,7 +339,9 @@ describe('diagnostics', () => {
const diagnostics = ngLS.getSemanticDiagnostics(TEST_TEMPLATE) !;
expect(diagnostics.length).toBe(1);
const {messageText, start, length} = diagnostics[0];
expect(messageText).toBe(`Unknown method 'notSubstring'`);
expect(messageText)
.toBe(
`Identifier 'notSubstring' is not defined. 'string' does not contain such a member`);
expect(start).toBe(content.indexOf('$event'));
expect(length).toBe('$event.notSubstring()'.length);
});
@ -885,4 +887,67 @@ describe('diagnostics', () => {
const ngDiags = ngLS.getSemanticDiagnostics(fileName);
expect(ngDiags).toEqual([]);
});
it('should suggest ? or ! operator if method receiver is nullable', () => {
const content = mockHost.override(TEST_TEMPLATE, `{{optional && optional.toLowerCase()}}`);
const ngDiags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(ngDiags.length).toBe(1);
const {start, length, messageText, category} = ngDiags[0];
expect(messageText)
.toBe(
`'optional' is possibly undefined. ` +
`Consider using the safe navigation operator (optional?.toLowerCase) ` +
`or non-null assertion operator (optional!.toLowerCase).`);
expect(category).toBe(ts.DiagnosticCategory.Suggestion);
expect(content.substring(start !, start ! + length !)).toBe('optional.toLowerCase()');
});
it('should suggest ? or ! operator if property receiver is nullable', () => {
const content = mockHost.override(TEST_TEMPLATE, `{{optional && optional.length}}`);
const ngDiags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(ngDiags.length).toBe(1);
const {start, length, messageText, category} = ngDiags[0];
expect(messageText)
.toBe(
`'optional' is possibly undefined. ` +
`Consider using the safe navigation operator (optional?.length) ` +
`or non-null assertion operator (optional!.length).`);
expect(category).toBe(ts.DiagnosticCategory.Suggestion);
expect(content.substring(start !, start ! + length !)).toBe('optional.length');
});
it('should report error if method is not found on non-nullable receiver', () => {
const expressions = [
'optional?.someMethod()',
'optional!.someMethod()',
];
for (const expression of expressions) {
const content = mockHost.override(TEST_TEMPLATE, `{{${expression}}}`);
const ngDiags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(ngDiags.length).toBe(1);
const {start, length, messageText, category} = ngDiags[0];
expect(messageText)
.toBe(`Identifier 'someMethod' is not defined. 'string' does not contain such a member`);
expect(category).toBe(ts.DiagnosticCategory.Error);
expect(content.substring(start !, start ! + length !)).toBe(expression);
}
});
it('should report error if property is not found on non-nullable receiver', () => {
const expressions = [
'optional?.someProp',
'optional!.someProp',
];
for (const expression of expressions) {
const content = mockHost.override(TEST_TEMPLATE, `{{${expression}}}`);
const ngDiags = ngLS.getSemanticDiagnostics(TEST_TEMPLATE);
expect(ngDiags.length).toBe(1);
const {start, length, messageText, category} = ngDiags[0];
expect(messageText)
.toBe(`Identifier 'someProp' is not defined. 'string' does not contain such a member`);
expect(category).toBe(ts.DiagnosticCategory.Error);
expect(content.substring(start !, start ! + length !)).toBe(expression);
}
});
});