Skip to content

Commit 47fcbc4

Browse files
JeanMechekirjs
authored andcommitted
feat(compiler): allow safe navigation to correctly narrow down nullables
The commit updates the TCB for safe navigation expressions to allow for correct narrowing of nullables. This will trigger the `nullishCoalescingNotNullable` and `optionalChainNotNullable` diagnostics on exisiting projects. You might want to disable those 2 diagnotiscs in your `tsconfig` temporarily if you want to update your project without having to fix all the issues at once. Narrowing can be disabled altogether with `strictSafeNavigationTyes: false`. fixes angular#37619 BREAKING CHANGE: This change will trigger the `nullishCoalescingNotNullable` and `optionalChainNotNullable` diagnostics on exisiting projects. You might want to disable those 2 diagnotiscs in your `tsconfig` temporarily.
1 parent b9265cc commit 47fcbc4

9 files changed

Lines changed: 118 additions & 73 deletions

File tree

packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/optional_chain_not_nullable/optional_chain_not_nullable_spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {DiagnosticCategoryLabel} from '../../../../../core/api';
109
import ts from 'typescript';
10+
import {DiagnosticCategoryLabel} from '../../../../../core/api';
1111

1212
import {ErrorCode, ExtendedTemplateDiagnosticName, ngErrorCode} from '../../../../../diagnostics';
1313
import {absoluteFrom, getSourceFileOrError} from '../../../../../file_system';
@@ -98,7 +98,7 @@ runInEachFileSystem(() => {
9898
expect(diags[0].category).toBe(ts.DiagnosticCategory.Warning);
9999
expect(diags[0].code).toBe(ngErrorCode(ErrorCode.OPTIONAL_CHAIN_NOT_NULLABLE));
100100
expect(diags[0].messageText).toContain(`the '?.' operator can be safely removed`);
101-
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`var1?.['bar']`);
101+
expect(getSourceCodeForDiagnostic(diags[0])).toBe(`'bar'`);
102102
});
103103

104104
it('should produce optional chain warning for method call', () => {

packages/compiler-cli/src/ngtsc/typecheck/extended/test/checks/uninvoked_function_in_text_interpolation/uninvoked_function_in_text_interpolation_spec.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
*/
88

99
import ts from 'typescript';
10-
import {runInEachFileSystem} from '../../../../../file_system/testing';
11-
import {factory as uninvokedFunctionInTextInterpolationFactory} from '../../../checks/uninvoked_function_in_text_interpolation';
1210
import {ErrorCode, ExtendedTemplateDiagnosticName, ngErrorCode} from '../../../../../diagnostics';
1311
import {absoluteFrom, getSourceFileOrError} from '../../../../../file_system';
14-
import {getClass, setup} from '../../../../testing';
15-
import {ExtendedTemplateCheckerImpl} from '../../../src/extended_template_checker';
12+
import {runInEachFileSystem} from '../../../../../file_system/testing';
1613
import {getSourceCodeForDiagnostic} from '../../../../../testing';
14+
import {getClass, setup} from '../../../../testing';
1715
import {formatExtendedError} from '../../../api/format-extended-error';
16+
import {factory as uninvokedFunctionInTextInterpolationFactory} from '../../../checks/uninvoked_function_in_text_interpolation';
17+
import {ExtendedTemplateCheckerImpl} from '../../../src/extended_template_checker';
1818

1919
runInEachFileSystem(() => {
2020
describe('UninvokedFunctionInTextInterpolationFactoryCheck', () => {
@@ -127,11 +127,12 @@ runInEachFileSystem(() => {
127127
{
128128
fileName,
129129
templates: {
130-
'TestCmp': `<p> {{ myObj.firstName }} - {{ myObj?.lastName }}</p>`,
130+
'TestCmp': `<p> {{ myObj.firstName }} - {{ otherObj?.lastName }}</p>`,
131131
},
132132
source: `
133133
export class TestCmp {
134-
myObj = { firstName: () => "Gordon", lastName: () => "Freeman" };
134+
myObj = { firstName: () => "Gordon"}
135+
otherObj?:{lastName: () => string } = { lastName: () => "Freeman" };
135136
}`,
136137
},
137138
]);

packages/compiler-cli/src/ngtsc/typecheck/src/completion.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,10 @@ import {AbsoluteFsPath} from '../../file_system';
2626
import {
2727
CompletionKind,
2828
GlobalCompletion,
29+
LetDeclarationCompletion,
2930
ReferenceCompletion,
3031
TcbLocation,
3132
VariableCompletion,
32-
LetDeclarationCompletion,
3333
} from '../api';
3434

3535
import {ExpressionIdentifier, findFirstMatchingNode} from './comments';
@@ -172,24 +172,32 @@ export class CompletionEngine {
172172
withSpan: expr.nameSpan,
173173
});
174174
} else if (expr instanceof SafePropertyRead) {
175-
// Safe navigation operations are a little more complex, and involve a ternary. Completion
176-
// happens in the "true" case of the ternary.
177-
const ternaryExpr = findFirstMatchingNode(this.tcb, {
178-
filter: ts.isParenthesizedExpression,
179-
withSpan: expr.sourceSpan,
175+
// Safe navigation operations in strict mode are emitted as optional chains and in
176+
// compatibility mode as regular property accesses wrapped in casts/assertions.
177+
tsExpr = findFirstMatchingNode(this.tcb, {
178+
filter: ts.isPropertyAccessExpression,
179+
withSpan: expr.nameSpan,
180180
});
181-
if (ternaryExpr === null || !ts.isConditionalExpression(ternaryExpr.expression)) {
182-
return null;
183-
}
184-
const whenTrue = ternaryExpr.expression.whenTrue;
185181

186-
if (ts.isPropertyAccessExpression(whenTrue)) {
187-
tsExpr = whenTrue;
188-
} else if (
189-
ts.isCallExpression(whenTrue) &&
190-
ts.isPropertyAccessExpression(whenTrue.expression)
191-
) {
192-
tsExpr = whenTrue.expression;
182+
// Fall back to the older ternary-based TCB shape for compatibility.
183+
const ternaryExpr =
184+
tsExpr === null
185+
? findFirstMatchingNode(this.tcb, {
186+
filter: ts.isParenthesizedExpression,
187+
withSpan: expr.sourceSpan,
188+
})
189+
: null;
190+
if (ternaryExpr !== null && ts.isConditionalExpression(ternaryExpr.expression)) {
191+
const whenTrue = ternaryExpr.expression.whenTrue;
192+
193+
if (ts.isPropertyAccessExpression(whenTrue)) {
194+
tsExpr = whenTrue;
195+
} else if (
196+
ts.isCallExpression(whenTrue) &&
197+
ts.isPropertyAccessExpression(whenTrue.expression)
198+
) {
199+
tsExpr = whenTrue.expression;
200+
}
193201
}
194202
}
195203

packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ import {
3939
Unary,
4040
VoidExpression,
4141
} from '@angular/compiler';
42-
import {quoteAndEscape, TcbExpr} from './ops/codegen';
4342
import {TypeCheckingConfig} from '../api';
43+
import {quoteAndEscape, TcbExpr} from './ops/codegen';
4444

4545
/**
4646
* Convert an `AST` to a `TcbExpr` directly, without going through an intermediate `Expression`
@@ -258,11 +258,9 @@ class TcbExprTranslator implements AstVisitor {
258258

259259
// The form of safe property reads depends on whether strictness is in use.
260260
if (this.config.strictSafeNavigationTypes) {
261-
// Basically, the return here is either the type of the complete expression with a null-safe
262-
// property read, or `undefined`. So a ternary is used to create an "or" type:
263-
// "a?.b" becomes (0 as any ? a!.b : undefined)
264-
// The type of this expression is (typeof a!.b) | undefined, which is exactly as desired.
265-
node = new TcbExpr(`(0 as any ? ${receiver.print()}!.${name.print()} : undefined)`);
261+
// Use native optional chaining so TypeScript can keep control-flow information for
262+
// narrowing in guard expressions.
263+
node = new TcbExpr(`${receiver.print()}?.${name.print()}`);
266264
} else if (VeSafeLhsInferenceBugDetector.veWillInferAnyFor(ast)) {
267265
// Emulate a View Engine bug where 'any' is inferred for the left-hand side of the safe
268266
// navigation operation. With this bug, the type of the left-hand side is regarded as any.
@@ -286,11 +284,9 @@ class TcbExprTranslator implements AstVisitor {
286284

287285
// The form of safe property reads depends on whether strictness is in use.
288286
if (this.config.strictSafeNavigationTypes) {
289-
// "a?.[...]" becomes (0 as any ? a![...] : undefined)
290-
const elementAccess = new TcbExpr(`${receiver.print()}![${key.print()}]`).addParseSpanInfo(
291-
ast.sourceSpan,
292-
);
293-
node = new TcbExpr(`(0 as any ? ${elementAccess.print()} : undefined)`);
287+
// Use native optional chaining so TypeScript can keep control-flow information for
288+
// narrowing in guard expressions.
289+
node = new TcbExpr(`${receiver.print()}?.[${key.print()}]`);
294290
} else if (VeSafeLhsInferenceBugDetector.veWillInferAnyFor(ast)) {
295291
// "a?.[...]" becomes (a as any)[...]
296292
node = new TcbExpr(`(${receiver.print()} as any)[${key.print()}]`);
@@ -436,7 +432,8 @@ class TcbExprTranslator implements AstVisitor {
436432
const args = argNodes.map((node) => node.print()).join(', ');
437433

438434
if (this.config.strictSafeNavigationTypes) {
439-
// (0 as any ? a!.method(...) : undefined)
435+
// Use optional chaining for property access, then assert non-null before calling.
436+
// (0 as any ? expr!() : undefined)
440437
return new TcbExpr(`(0 as any ? ${expr}!(${args}) : undefined)`);
441438
}
442439

packages/compiler-cli/src/ngtsc/typecheck/src/template_symbol_builder.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
ASTWithSource,
1313
Binary,
1414
BindingPipe,
15-
BoundTarget,
1615
ClassPropertyMapping,
1716
MatchSource,
1817
ParseSourceSpan,
@@ -53,23 +52,17 @@ import {
5352
ReferenceSymbol,
5453
SelectorlessComponentSymbol,
5554
SelectorlessDirectiveSymbol,
56-
SymbolReference,
5755
Symbol,
5856
SymbolKind,
57+
SymbolReference,
5958
TcbLocation,
6059
TemplateSymbol,
6160
TypeCheckingConfig,
6261
VariableSymbol,
6362
} from '../api';
64-
import {
65-
ExpressionIdentifier,
66-
findAllMatchingNodes,
67-
findFirstMatchingNode,
68-
hasExpressionIdentifier,
69-
readDirectiveIdFromComment,
70-
} from './comments';
63+
import {findAllMatchingNodes, findFirstMatchingNode, readDirectiveIdFromComment} from './comments';
64+
7165
import {isAccessExpression, isDirectiveDeclaration} from './ts_util';
72-
import {MaybeSourceFileWithOriginalFile, NgOriginalFile} from '../../program_driver';
7366

7467
export interface SymbolDirectiveMeta {
7568
getSymbolReference(): SymbolReference;
@@ -680,7 +673,6 @@ export class SymbolBuilder {
680673

681674
// The `name` part of a property write and `ASTWithName` do not have their own
682675
// AST so there is no way to retrieve a `Symbol` for just the `name` via a specific node.
683-
// Also skipping SafePropertyReads as it breaks nullish coalescing not nullable extended diagnostic
684676
if (
685677
expression instanceof Binary &&
686678
Binary.isAssignmentOperation(expression.operation) &&
@@ -699,7 +691,7 @@ export class SymbolBuilder {
699691

700692
// Property reads in templates usually map to a `PropertyAccessExpression`
701693
// (e.g. `ctx.foo`) so try looking for one first.
702-
if (expression instanceof PropertyRead) {
694+
if (expression instanceof PropertyRead || expression instanceof SafePropertyRead) {
703695
node = findFirstMatchingNode(this.typeCheckBlock, {
704696
withSpan,
705697
filter: ts.isPropertyAccessExpression,
@@ -711,6 +703,28 @@ export class SymbolBuilder {
711703
node = findFirstMatchingNode(this.typeCheckBlock, {withSpan, filter: anyNodeFilter});
712704
}
713705

706+
// Safe property reads can be emitted as optional chaining in the TCB.
707+
// In that form, the full source span does not always map to a single node,
708+
// so fall back to resolving from the property name span.
709+
if (node === null && expression instanceof SafePropertyRead) {
710+
const nameNode = findFirstMatchingNode(this.typeCheckBlock, {
711+
withSpan: expression.nameSpan,
712+
filter: anyNodeFilter,
713+
});
714+
715+
if (nameNode !== null) {
716+
node = nameNode;
717+
while (
718+
node.parent !== undefined &&
719+
(ts.isParenthesizedExpression(node.parent) ||
720+
ts.isNonNullExpression(node.parent) ||
721+
isAccessExpression(node.parent))
722+
) {
723+
node = node.parent;
724+
}
725+
}
726+
}
727+
714728
if (node === null) {
715729
return null;
716730
}
@@ -719,8 +733,8 @@ export class SymbolBuilder {
719733
node = node.expression;
720734
}
721735

722-
// - If we have safe property read ("a?.b") we want to get the Symbol for b, the `whenTrue`
723-
// expression.
736+
// - If we have safe property read ("a?.b") in the legacy conditional form, we want to get the
737+
// Symbol for b, the `whenTrue` expression.
724738
// - If our expression is a pipe binding ("a | test:b:c"), we want the Symbol for the
725739
// `transform` on the pipe.
726740
// - Otherwise, we retrieve the symbol for the node itself with no special considerations

packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,22 +140,20 @@ describe('type check blocks diagnostics', () => {
140140

141141
it('should annotate safe property access', () => {
142142
const TEMPLATE = `{{ a?.b }}`;
143-
expect(tcbWithSpans(TEMPLATE)).toContain(
144-
'(0 as any ? (((this).a /*3,4*/) /*3,4*/)!.b /*6,7*/ : undefined) /*3,7*/',
145-
);
143+
expect(tcbWithSpans(TEMPLATE)).toContain('((((this).a /*3,4*/) /*3,4*/)?.b /*6,7*/ /*3,7*/');
146144
});
147145

148146
it('should annotate safe method calls', () => {
149147
const TEMPLATE = `{{ a?.method(b) }}`;
150148
expect(tcbWithSpans(TEMPLATE)).toContain(
151-
'((0 as any ? (0 as any ? (((this).a /*3,4*/) /*3,4*/)!.method /*6,12*/ : undefined) /*3,12*/!(((this).b /*13,14*/) /*13,14*/) : undefined) /*3,15*/)',
149+
'((0 as any ? (((this).a /*3,4*/) /*3,4*/)?.method /*6,12*/ /*3,12*/!(((this).b /*13,14*/) /*13,14*/) : undefined) /*3,15*/)',
152150
);
153151
});
154152

155153
it('should annotate safe keyed reads', () => {
156154
const TEMPLATE = `{{ a?.[0] }}`;
157155
expect(tcbWithSpans(TEMPLATE)).toContain(
158-
'(0 as any ? (((this).a /*3,4*/) /*3,4*/)![0 /*7,8*/] /*3,9*/ : undefined) /*3,9*/',
156+
'((((this).a /*3,4*/) /*3,4*/)?.[0 /*7,8*/] /*3,9*/)',
159157
);
160158
});
161159

packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,11 +1444,9 @@ describe('type check blocks', () => {
14441444

14451445
it('should use undefined for safe navigation operations when enabled', () => {
14461446
const block = tcb(TEMPLATE, DIRECTIVES);
1447-
expect(block).toContain(
1448-
'(0 as any ? (0 as any ? (((this).a))!.method : undefined)!() : undefined)',
1449-
);
1450-
expect(block).toContain('(0 as any ? (((this).a))!.b : undefined)');
1451-
expect(block).toContain('(0 as any ? (((this).a))![0] : undefined)');
1447+
expect(block).toContain('(0 as any ? (((this).a))?.method!() : undefined)');
1448+
expect(block).toContain('((((this).a))?.b)');
1449+
expect(block).toContain('((((this).a))?.[0])');
14521450
expect(block).toContain('(0 as any ? (((((this).a)).optionalMethod))!() : undefined)');
14531451
});
14541452
it("should use an 'any' type for safe navigation operations when disabled", () => {
@@ -1468,13 +1466,11 @@ describe('type check blocks', () => {
14681466
const TEMPLATE = `{{a.method()?.b}} {{a()?.method()}} {{a.method()?.[0]}} {{a.method()?.otherMethod?.()}}`;
14691467
it('should check the presence of a property/method on the receiver when enabled', () => {
14701468
const block = tcb(TEMPLATE, DIRECTIVES);
1471-
expect(block).toContain('(0 as any ? ((((this).a)).method())!.b : undefined)');
1472-
expect(block).toContain(
1473-
'(0 as any ? (0 as any ? ((this).a())!.method : undefined)!() : undefined)',
1474-
);
1475-
expect(block).toContain('(0 as any ? ((((this).a)).method())![0] : undefined)');
1469+
expect(block).toContain('(((((this).a)).method())?.b)');
1470+
expect(block).toContain('(0 as any ? ((this).a())?.method!() : undefined)');
1471+
expect(block).toContain('(((((this).a)).method())?.[0])');
14761472
expect(block).toContain(
1477-
'(0 as any ? ((0 as any ? ((((this).a)).method())!.otherMethod : undefined))!() : undefined)',
1473+
'(0 as any ? (((((this).a)).method())?.otherMethod)!() : undefined)',
14781474
);
14791475
});
14801476
it('should not check the presence of a property/method on the receiver when disabled', () => {

packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,6 +1049,39 @@ runInEachFileSystem(() => {
10491049
`Property 'invalid' does not exist on type 'TestCmp'.`,
10501050
);
10511051
});
1052+
1053+
it('should narrow the type of safe navigation expressions in an if guard when enabled', () => {
1054+
env.tsconfig({
1055+
fullTemplateTypeCheck: true,
1056+
strictInputTypes: true,
1057+
strictNullInputTypes: true,
1058+
strictSafeNavigationTypes: true,
1059+
});
1060+
1061+
env.write(
1062+
'test.ts',
1063+
`
1064+
import {Component, NgModule} from '@angular/core';
1065+
1066+
@Component({
1067+
selector: 'test',
1068+
template: '@if (user?.isMember) { {{user.isMember}} }',
1069+
standalone: false,
1070+
})
1071+
class TestCmp {
1072+
user?: {isMember: boolean};
1073+
}
1074+
1075+
@NgModule({
1076+
declarations: [TestCmp],
1077+
})
1078+
class Module {}
1079+
`,
1080+
);
1081+
1082+
const diags = env.driveDiagnostics();
1083+
expect(diags.length).toBe(0);
1084+
});
10521085
});
10531086

10541087
describe('strictOutputEventTypes', () => {

packages/language-service/test/quick_info_spec.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -392,16 +392,14 @@ describe('quick info', () => {
392392
});
393393

394394
it('should work for safe keyed reads', () => {
395-
expectQuickInfo({
396-
templateOverride: `<div>{{constNamesOptional?.[0¦]}}</div>`,
397-
expectedSpanText: '0',
398-
expectedDisplayString: '(property) 0: {\n readonly name: "name";\n}',
399-
});
395+
// TypeScript Language Service natively does not provide quick info for numeric/string literals
396+
// in an optional element access chain (e.g. `a?.[0]`). Because TCB now uses optional chaining,
397+
// we can no longer expect a result here. It's consistent with TS behavior natively!
400398

401399
expectQuickInfo({
402400
templateOverride: `<div>{{constNamesOptional?.[0]?.na¦me}}</div>`,
403401
expectedSpanText: 'constNamesOptional?.[0]?.name',
404-
expectedDisplayString: '(property) name: "name"',
402+
expectedDisplayString: '(property) name: "name" | undefined',
405403
});
406404
});
407405

0 commit comments

Comments
 (0)