From 8bc31a515ff6e8edda6ea5786a47ae5a788acd36 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Thu, 19 Mar 2026 01:49:21 +0100 Subject: [PATCH] feat(core): Allow other expression for exhaustive typechecking When the switched expression is nested within a union, exhaustive typechecking needs to know which expression to check. This change adds the possibility of specifying the expression to check: ``` @Component({ selector: 'app-root', imports: [], template: ` @switch (state.mode) { @case ('show') { {{ state.menu }}; } @case ('hide') {} @default never(state); } `, changeDetection: ChangeDetectionStrategy.OnPush, }) export class App { state!: { mode: 'hide' } | { mode: 'show'; menu: number };; } ``` fixes #67406 --- .../content/guide/templates/control-flow.md | 17 +++++++ .../ngtsc/typecheck/src/ops/switch_block.ts | 7 ++- .../ngtsc/typecheck/test/diagnostics_spec.ts | 19 ++++++++ .../typecheck/test/type_check_block_spec.ts | 21 +++++++++ packages/compiler/src/ml_parser/lexer.ts | 16 +++---- packages/compiler/src/render3/r3_ast.ts | 1 + .../compiler/src/render3/r3_control_flow.ts | 5 +++ .../compiler/src/render3/view/t2_binder.ts | 2 +- .../compiler/test/ml_parser/lexer_spec.ts | 10 +++++ .../language-service/src/template_target.ts | 4 +- tools/manual_api_docs/blocks/switch.md | 17 +++++++ .../syntaxes/test/data/template-blocks.html | 5 +++ .../test/data/template-blocks.html.snap | 45 ++++++++++++++----- 13 files changed, 147 insertions(+), 22 deletions(-) diff --git a/adev/src/content/guide/templates/control-flow.md b/adev/src/content/guide/templates/control-flow.md index 6d812254739..cb88c26490a 100644 --- a/adev/src/content/guide/templates/control-flow.md +++ b/adev/src/content/guide/templates/control-flow.md @@ -160,3 +160,20 @@ export class AppComponent { state: 'loggedOut' | 'loading' | 'loggedIn' = 'loggedOut'; } ``` + +When the switched expression is nested within a union, you must explicitly specify the expression to check for exhaustiveness. + +```angular-ts +@Component({ + template: ` + @switch (state.mode) { + @case ('show') { {{ state.menu }}; } + @case ('hide') {} + @default never(state); + } + `, +}) +export class App { + state!: {mode: 'hide'} | {mode: 'show'; menu: number}; +} +``` diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts index 7798a78efde..2b9cad1e29a 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/ops/switch_block.ts @@ -60,7 +60,12 @@ export class TcbSwitchOp extends TcbOp { }); if (this.block.exhaustiveCheck) { - const switchValue = tcbExpression(this.block.expression, this.tcb, this.scope); + let translateExpression = this.block.expression; + if (this.block.exhaustiveCheck.expression) { + translateExpression = this.block.exhaustiveCheck.expression; + } + + const switchValue = tcbExpression(translateExpression, this.tcb, this.scope); const exhaustiveId = this.tcb.allocateId(); clauses.push( diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts index 21a8ec3a372..55436d5b4f3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts @@ -1453,6 +1453,25 @@ class TestComponent { expect(messages).toEqual([]); }); + + it('should narrow union when switching on a nested prop', () => { + const messages = diagnose( + ` + @switch (state.mode) { + @case ('show') { {{ state.menu }}; } + @case ('hide') {} + @default never(state); + } + `, + ` + export class TestComponent { + state: { mode: 'hide' } | { mode: 'show'; menu: number }; + } + `, + ); + + expect(messages).toEqual([]); + }); }); // https://github.com/angular/angular/issues/43970 diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts index 69c27d13606..e61f06454d3 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts @@ -2202,6 +2202,27 @@ describe('type check blocks', () => { ); }); + it('should generate a switch block with exhaustiveness checking with param', () => { + const TEMPLATE = ` + @switch (expr) { + @case (1) { + {{one()}} + } + @case (2) { + {{two()}} + } + @default never(expr.prop); + } + `; + + expect(tcb(TEMPLATE)).toContain( + 'switch (((this).expr)) { ' + + 'case 1: "" + ((this).one()); break; ' + + 'case 2: "" + ((this).two()); break; ' + + 'default: const tcbExhaustive_t1: never = ((((this).expr)).prop);', + ); + }); + it('should not report unused locals for exhaustiveness check variable', () => { const TEMPLATE = ` @switch (expr) { diff --git a/packages/compiler/src/ml_parser/lexer.ts b/packages/compiler/src/ml_parser/lexer.ts index a1553abae32..c463bc4a42a 100644 --- a/packages/compiler/src/ml_parser/lexer.ts +++ b/packages/compiler/src/ml_parser/lexer.ts @@ -299,14 +299,6 @@ class _Tokenizer { this._beginToken(TokenType.BLOCK_OPEN_START, start); const startToken = this._endToken([this._getBlockName()]); - if (startToken.parts[0] === 'default never' && this._attemptCharCode(chars.$SEMICOLON)) { - this._beginToken(TokenType.BLOCK_OPEN_END); - this._endToken([]); - this._beginToken(TokenType.BLOCK_CLOSE); - this._endToken([]); - return; - } - if (this._cursor.peek() === chars.$LPAREN) { // Advance past the opening paren. this._cursor.advance(); @@ -324,6 +316,14 @@ class _Tokenizer { } } + if (startToken.parts[0] === 'default never' && this._attemptCharCode(chars.$SEMICOLON)) { + this._beginToken(TokenType.BLOCK_OPEN_END); + this._endToken([]); + this._beginToken(TokenType.BLOCK_CLOSE); + this._endToken([]); + return; + } + if (this._attemptCharCode(chars.$LBRACE)) { this._beginToken(TokenType.BLOCK_OPEN_END); this._endToken([]); diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 61303e61fdb..bd6cb2988d9 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -471,6 +471,7 @@ export class SwitchBlockCaseGroup extends BlockNode implements Node { export class SwitchExhaustiveCheck extends BlockNode implements Node { constructor( + public expression: AST | null, sourceSpan: ParseSourceSpan, startSourceSpan: ParseSourceSpan, endSourceSpan: ParseSourceSpan | null, diff --git a/packages/compiler/src/render3/r3_control_flow.ts b/packages/compiler/src/render3/r3_control_flow.ts index 78fe0434da8..0f37877ffb0 100644 --- a/packages/compiler/src/render3/r3_control_flow.ts +++ b/packages/compiler/src/render3/r3_control_flow.ts @@ -264,6 +264,10 @@ export function createSwitchBlock( if (isCase) { expression = parseBlockParameterToBinding(node.parameters[0], bindingParser); } else if (node.name === 'default never') { + if (node.parameters.length > 0) { + expression = parseBlockParameterToBinding(node.parameters[0], bindingParser); + } + if ( node.children.length > 0 || (node.endSourceSpan !== null && @@ -287,6 +291,7 @@ export function createSwitchBlock( } exhaustiveCheck = new t.SwitchExhaustiveCheck( + expression, node.sourceSpan, node.startSourceSpan, node.endSourceSpan, diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index 549ef8a1488..c24ca3fdb79 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -953,7 +953,7 @@ class TemplateBinder extends CombinedRecursiveAstVisitor { } override visitSwitchExhaustiveCheck(block: SwitchExhaustiveCheck) { - // There are no bindings/references in the exhaustive check block. + block.expression?.visit(this); } override visitForLoopBlock(block: ForLoopBlock) { diff --git a/packages/compiler/test/ml_parser/lexer_spec.ts b/packages/compiler/test/ml_parser/lexer_spec.ts index e3ebac0e827..054145ee8f5 100644 --- a/packages/compiler/test/ml_parser/lexer_spec.ts +++ b/packages/compiler/test/ml_parser/lexer_spec.ts @@ -3396,6 +3396,16 @@ describe('HtmlLexer', () => { ]); }); + it('should parse @default never(expr);', () => { + expect(tokenizeAndHumanizeParts('@default never(expr);')).toEqual([ + [TokenType.BLOCK_OPEN_START, 'default never'], + [TokenType.BLOCK_PARAMETER, 'expr'], + [TokenType.BLOCK_OPEN_END], + [TokenType.BLOCK_CLOSE], + [TokenType.EOF], + ]); + }); + it('should parse @default never ;', () => { expect(tokenizeAndHumanizeParts('@default never ;')).toEqual([ [TokenType.BLOCK_OPEN_START, 'default never'], diff --git a/packages/language-service/src/template_target.ts b/packages/language-service/src/template_target.ts index 1fb327e8fa0..360a12a0f1b 100644 --- a/packages/language-service/src/template_target.ts +++ b/packages/language-service/src/template_target.ts @@ -677,7 +677,9 @@ class TemplateTargetVisitor implements TmplAstVisitor { this.visitAll(block.children); } - visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck) {} + visitSwitchExhaustiveCheck(block: TmplAstSwitchExhaustiveCheck) { + block.expression && this.visitBinding(block.expression); + } visitForLoopBlock(block: TmplAstForLoopBlock) { this.visit(block.item); diff --git a/tools/manual_api_docs/blocks/switch.md b/tools/manual_api_docs/blocks/switch.md index ad742361e74..a32738f08a2 100644 --- a/tools/manual_api_docs/blocks/switch.md +++ b/tools/manual_api_docs/blocks/switch.md @@ -57,3 +57,20 @@ export class AppComponent { state: 'loggedOut' | 'loading' | 'loggedIn' = 'loggedOut'; } ``` + +When the switched expression is nested within a union, you must explicitly specify the expression to check for exhaustiveness. + +```angular-ts +@Component({ + template: ` + @switch (state.mode) { + @case ('show') { {{ state.menu }}; } + @case ('hide') {} + @default never(state); + } + `, +}) +export class App { + state!: {mode: 'hide'} | {mode: 'show'; menu: number}; +} +``` diff --git a/vscode-ng-language-service/syntaxes/test/data/template-blocks.html b/vscode-ng-language-service/syntaxes/test/data/template-blocks.html index e43a1a5d1af..628a4b0eb26 100644 --- a/vscode-ng-language-service/syntaxes/test/data/template-blocks.html +++ b/vscode-ng-language-service/syntaxes/test/data/template-blocks.html @@ -23,6 +23,11 @@ @default never; } +@switch(aOrb) { + @case(a) {} + @default never(aOrb.c); +} + @if (a==b) { hello } @else { goodbye } @if (a==b) { diff --git a/vscode-ng-language-service/syntaxes/test/data/template-blocks.html.snap b/vscode-ng-language-service/syntaxes/test/data/template-blocks.html.snap index bc3c7dc51e1..0de100e6d50 100644 --- a/vscode-ng-language-service/syntaxes/test/data/template-blocks.html.snap +++ b/vscode-ng-language-service/syntaxes/test/data/template-blocks.html.snap @@ -130,18 +130,41 @@ >} #^ template.blocks.ng control.block.ng punctuation.definition.block.ts > ->@if (a==b) { hello } @else { goodbye } +>@switch(aOrb) { #^ template.blocks.ng control.block.ng keyword.control.block.transition.ng -# ^^ template.blocks.ng control.block.ng keyword.control.block.kind.ng -# ^ template.blocks.ng control.block.ng -# ^ template.blocks.ng control.block.ng meta.brace.round.ts -# ^ template.blocks.ng control.block.ng control.block.expression.ng variable.other.readwrite.ts -# ^^ template.blocks.ng control.block.ng control.block.expression.ng keyword.operator.comparison.ts -# ^ template.blocks.ng control.block.ng control.block.expression.ng variable.other.readwrite.ts -# ^ template.blocks.ng control.block.ng meta.brace.round.ts -# ^ template.blocks.ng control.block.ng -# ^ template.blocks.ng control.block.ng punctuation.definition.block.ts -# ^^^^^^^ template.blocks.ng control.block.ng control.block.body.ng +# ^^^^^^ template.blocks.ng control.block.ng keyword.control.block.kind.ng +# ^ template.blocks.ng control.block.ng meta.brace.round.ts +# ^^^^ template.blocks.ng control.block.ng control.block.expression.ng variable.other.readwrite.ts +# ^ template.blocks.ng control.block.ng meta.brace.round.ts +# ^ template.blocks.ng control.block.ng +# ^ template.blocks.ng control.block.ng punctuation.definition.block.ts +> @case(a) {} +#^^^^ template.blocks.ng control.block.ng control.block.body.ng +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng keyword.control.block.transition.ng +# ^^^^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng keyword.control.block.kind.ng +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng meta.brace.round.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng control.block.expression.ng variable.other.readwrite.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng meta.brace.round.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng punctuation.definition.block.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng punctuation.definition.block.ts +> @default never(aOrb.c); +#^^^^ template.blocks.ng control.block.ng control.block.body.ng +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng keyword.control.block.transition.ng +# ^^^^^^^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng keyword.control.block.kind.ng +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng +# ^^^^^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng meta.brace.round.ts +# ^^^^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng control.block.expression.ng variable.other.object.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng control.block.expression.ng punctuation.accessor.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng control.block.expression.ng variable.other.property.ts +# ^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng meta.brace.round.ts +# ^^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng control.block.case.header.ng +>} +#^ template.blocks.ng control.block.ng control.block.body.ng control.block.case.ng +> +>@if (a==b) { hello } @else { goodbye } +#^^^^^^^^^^^^^^^^^^^ template.blocks.ng control.block.ng control.block.body.ng # ^ template.blocks.ng control.block.ng punctuation.definition.block.ts # ^ template.blocks.ng # ^ template.blocks.ng control.block.ng keyword.control.block.transition.ng