Skip to content

Commit 02edb43

Browse files
crisbetoalxhub
authored andcommitted
fix(compiler): narrow the type of the aliased if block expression (#51952)
Currently the TCB for aliased `if` blocks looks something like this: ``` // Markup: `@if (expr; as alias) { {{alias}} } if (block.condition) { var alias = block.condition; "" + alias; } ``` The problem with this approach is that the type of `alias` won't be narrowed. This is something that `NgIf` currently supports. These changes resolve the issue by emitting the variable outside the `if` block and using the variable reference instead: ``` // Markup: `@if (expr; as alias) { {{alias}} } var alias = block.condition; if (alias) { "" + alias; } ``` PR Close #51952
1 parent 7426948 commit 02edb43

File tree

3 files changed

+73
-38
lines changed

3 files changed

+73
-38
lines changed

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

Lines changed: 47 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function generateTypeCheckBlock(
8585
const tcb = new Context(
8686
env, domSchemaChecker, oobRecorder, meta.id, meta.boundTarget, meta.pipes, meta.schemas,
8787
meta.isStandalone);
88-
const scope = Scope.forNodes(tcb, null, tcb.boundTarget.target.template!, /* guard */ null);
88+
const scope = Scope.forNodes(tcb, null, null, tcb.boundTarget.target.template!, /* guard */ null);
8989
const ctxRawType = env.referenceType(ref);
9090
if (!ts.isTypeReferenceNode(ctxRawType)) {
9191
throw new Error(
@@ -379,7 +379,8 @@ class TcbTemplateBodyOp extends TcbOp {
379379

380380
// Create a new Scope for the template. This constructs the list of operations for the template
381381
// children, as well as tracks bindings within the template.
382-
const tmplScope = Scope.forNodes(this.tcb, this.scope, this.template, guard);
382+
const tmplScope =
383+
Scope.forNodes(this.tcb, this.scope, this.template, this.template.children, guard);
383384

384385
// Render the template's `Scope` into its statements.
385386
const statements = tmplScope.render();
@@ -1228,14 +1229,31 @@ class TcbIfOp extends TcbOp {
12281229
return undefined;
12291230
}
12301231

1231-
const branchScope = Scope.forNodes(this.tcb, this.scope, branch, null);
1232-
12331232
// If the expression is null, it means that it's an `else` statement.
1234-
return branch.expression === null ?
1235-
ts.factory.createBlock(branchScope.render()) :
1236-
ts.factory.createIfStatement(
1237-
tcbExpression(branch.expression, this.tcb, branchScope),
1238-
ts.factory.createBlock(branchScope.render()), this.generateBranch(index + 1));
1233+
if (branch.expression === null) {
1234+
const branchScope = Scope.forNodes(this.tcb, this.scope, null, branch.children, null);
1235+
return ts.factory.createBlock(branchScope.render());
1236+
}
1237+
1238+
let branchParentScope: Scope;
1239+
1240+
if (branch.expressionAlias === null) {
1241+
branchParentScope = this.scope;
1242+
} else {
1243+
// If the expression is aliased, we create a scope with a variable containing the expression.
1244+
// Further down we'll use the variable instead of the expression itself in the `if` statement.
1245+
// This allows for the type of the alias to be narrowed.
1246+
branchParentScope = Scope.forNodes(this.tcb, this.scope, branch, [], null);
1247+
branchParentScope.render().forEach(stmt => this.scope.addStatement(stmt));
1248+
}
1249+
1250+
const branchScope = Scope.forNodes(this.tcb, branchParentScope, null, branch.children, null);
1251+
const expression = branch.expressionAlias === null ?
1252+
tcbExpression(branch.expression, this.tcb, branchScope) :
1253+
branchScope.resolve(branch.expressionAlias);
1254+
1255+
return ts.factory.createIfStatement(
1256+
expression, ts.factory.createBlock(branchScope.render()), this.generateBranch(index + 1));
12391257
}
12401258
}
12411259

@@ -1260,7 +1278,7 @@ class TcbSwitchOp extends TcbOp {
12601278
for (const current of this.block.cases) {
12611279
// Our template switch statements don't fall through so we always have a break at the end.
12621280
const breakStatement = ts.factory.createBreakStatement();
1263-
const clauseScope = Scope.forNodes(this.tcb, this.scope, current.children, null);
1281+
const clauseScope = Scope.forNodes(this.tcb, this.scope, null, current.children, null);
12641282

12651283
if (current.expression === null) {
12661284
clauses.push(ts.factory.createDefaultClause([...clauseScope.render(), breakStatement]));
@@ -1294,7 +1312,7 @@ class TcbForOfOp extends TcbOp {
12941312
}
12951313

12961314
override execute(): null {
1297-
const loopScope = Scope.forNodes(this.tcb, this.scope, this.block, null);
1315+
const loopScope = Scope.forNodes(this.tcb, this.scope, this.block, this.block.children, null);
12981316
const initializer = ts.factory.createVariableDeclarationList(
12991317
[ts.factory.createVariableDeclaration(this.block.item.name)], ts.NodeFlags.Const);
13001318
// It's common to have a for loop over a nullable value (e.g. produced by the `async` pipe).
@@ -1431,67 +1449,60 @@ class Scope {
14311449
* Constructs a `Scope` given either a `TmplAstTemplate` or a list of `TmplAstNode`s.
14321450
*
14331451
* @param tcb the overall context of TCB generation.
1434-
* @param parent the `Scope` of the parent template (if any) or `null` if this is the root
1452+
* @param parentScope the `Scope` of the parent template (if any) or `null` if this is the root
14351453
* `Scope`.
1436-
* @param blockOrNodes either a `TmplAstTemplate` representing the template for which to
1437-
* calculate the `Scope`, or a list of nodes if no outer template object is available.
1454+
* @param scopedNode Node that provides the scope around the child nodes (e.g. a
1455+
* `TmplAstTemplate` node exposing variables to its children).
1456+
* @param children Child nodes that should be appended to the TCB.
14381457
* @param guard an expression that is applied to this scope for type narrowing purposes.
14391458
*/
14401459
static forNodes(
1441-
tcb: Context, parent: Scope|null,
1442-
blockOrNodes: TmplAstTemplate|TmplAstIfBlockBranch|TmplAstForLoopBlock|TmplAstNode[],
1443-
guard: ts.Expression|null): Scope {
1444-
const scope = new Scope(tcb, parent, guard);
1460+
tcb: Context, parentScope: Scope|null,
1461+
scopedNode: TmplAstTemplate|TmplAstIfBlockBranch|TmplAstForLoopBlock|null,
1462+
children: TmplAstNode[], guard: ts.Expression|null): Scope {
1463+
const scope = new Scope(tcb, parentScope, guard);
14451464

1446-
if (parent === null && tcb.env.config.enableTemplateTypeChecker) {
1465+
if (parentScope === null && tcb.env.config.enableTemplateTypeChecker) {
14471466
// Add an autocompletion point for the component context.
14481467
scope.opQueue.push(new TcbComponentContextCompletionOp(scope));
14491468
}
14501469

1451-
let children: TmplAstNode[];
1452-
14531470
// If given an actual `TmplAstTemplate` instance, then process any additional information it
14541471
// has.
1455-
if (blockOrNodes instanceof TmplAstTemplate) {
1472+
if (scopedNode instanceof TmplAstTemplate) {
14561473
// The template's variable declarations need to be added as `TcbVariableOp`s.
14571474
const varMap = new Map<string, TmplAstVariable>();
14581475

1459-
for (const v of blockOrNodes.variables) {
1476+
for (const v of scopedNode.variables) {
14601477
// Validate that variables on the `TmplAstTemplate` are only declared once.
14611478
if (!varMap.has(v.name)) {
14621479
varMap.set(v.name, v);
14631480
} else {
14641481
const firstDecl = varMap.get(v.name)!;
14651482
tcb.oobRecorder.duplicateTemplateVar(tcb.id, v, firstDecl);
14661483
}
1467-
this.registerVariable(scope, v, new TcbTemplateVariableOp(tcb, scope, blockOrNodes, v));
1484+
this.registerVariable(scope, v, new TcbTemplateVariableOp(tcb, scope, scopedNode, v));
14681485
}
1469-
children = blockOrNodes.children;
1470-
} else if (blockOrNodes instanceof TmplAstIfBlockBranch) {
1471-
const {expression, expressionAlias} = blockOrNodes;
1486+
} else if (scopedNode instanceof TmplAstIfBlockBranch) {
1487+
const {expression, expressionAlias} = scopedNode;
14721488
if (expression !== null && expressionAlias !== null) {
14731489
this.registerVariable(
14741490
scope, expressionAlias,
14751491
new TcbBlockVariableOp(
14761492
tcb, scope, tcbExpression(expression, tcb, scope), expressionAlias));
14771493
}
1478-
children = blockOrNodes.children;
1479-
} else if (blockOrNodes instanceof TmplAstForLoopBlock) {
1494+
} else if (scopedNode instanceof TmplAstForLoopBlock) {
14801495
this.registerVariable(
1481-
scope, blockOrNodes.item,
1496+
scope, scopedNode.item,
14821497
new TcbBlockVariableOp(
1483-
tcb, scope, ts.factory.createIdentifier(blockOrNodes.item.name), blockOrNodes.item));
1498+
tcb, scope, ts.factory.createIdentifier(scopedNode.item.name), scopedNode.item));
14841499

1485-
for (const variable of Object.values(blockOrNodes.contextVariables)) {
1500+
for (const variable of Object.values(scopedNode.contextVariables)) {
14861501
// Note: currently all context variables are assumed to be number types.
14871502
const type = ts.factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword);
14881503
this.registerVariable(
14891504
scope, variable, new TcbBlockImplicitVariableOp(tcb, scope, type, variable));
14901505
}
1491-
1492-
children = blockOrNodes.children;
1493-
} else {
1494-
children = blockOrNodes;
14951506
}
14961507
for (const node of children) {
14971508
scope.appendNode(node);

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1424,8 +1424,7 @@ describe('type check blocks', () => {
14241424
}`;
14251425

14261426
expect(tcb(TEMPLATE))
1427-
.toContain(
1428-
'if ((((this).expr)) === (1)) { var _t1 = (((this).expr)) === (1); "" + (_t1); }');
1427+
.toContain('var _t1 = (((this).expr)) === (1); if (_t1) { "" + (_t1); } }');
14291428
});
14301429

14311430
it('should generate a switch block', () => {

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3831,6 +3831,31 @@ suppress
38313831
]);
38323832
});
38333833

3834+
it('should check narrow the type in the alias', () => {
3835+
env.write('test.ts', `
3836+
import {Component} from '@angular/core';
3837+
3838+
@Component({
3839+
template: \`@if (value; as alias) {
3840+
{{acceptsNumber(alias)}}
3841+
}\`,
3842+
standalone: true,
3843+
})
3844+
export class Main {
3845+
value: 'one' | 0 = 0;
3846+
3847+
acceptsNumber(value: number) {
3848+
return value;
3849+
}
3850+
}
3851+
`);
3852+
3853+
const diags = env.driveDiagnostics();
3854+
expect(diags.map(d => ts.flattenDiagnosticMessageText(d.messageText, ''))).toEqual([
3855+
`Argument of type 'string' is not assignable to parameter of type 'number'.`,
3856+
]);
3857+
});
3858+
38343859
it('should not expose the aliased expression outside of the main block', () => {
38353860
env.write('test.ts', `
38363861
import {Component} from '@angular/core';

0 commit comments

Comments
 (0)