From 72534e2a3458df4e1bb097973872f00bbb92be42 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Fri, 9 Jan 2026 06:38:36 +0100 Subject: [PATCH] feat(compiler): Add support for the `instanceof` binary operator Because why not ? fixes #59975 --- .../guide/templates/expression-syntax.md | 2 +- .../ngtsc/translator/src/api/ast_factory.ts | 5 ++-- .../src/ngtsc/translator/src/translator.ts | 1 + .../translator/src/typescript_ast_factory.ts | 3 ++- .../src/ngtsc/typecheck/src/expression.ts | 3 ++- .../typecheck/test/type_check_block_spec.ts | 9 +++++++ .../r3_view_compiler/GOLDEN_PARTIAL.js | 10 +++++++ .../test_cases/r3_view_compiler/operators.ts | 7 ++++- .../r3_view_compiler/operators_template.js | 10 +++---- .../test/ngtsc/template_typecheck_spec.ts | 20 ++++++++++++++ .../compiler/src/expression_parser/ast.ts | 1 + .../compiler/src/expression_parser/lexer.ts | 5 ++++ .../compiler/src/expression_parser/parser.ts | 9 +++++-- .../compiler/src/output/abstract_emitter.ts | 1 + packages/compiler/src/output/output_ast.ts | 1 + .../src/template/pipeline/src/conversion.ts | 1 + .../test/expression_parser/lexer_spec.ts | 6 +++++ .../test/expression_parser/parser_spec.ts | 21 ++++++++++----- .../test/expression_parser/serializer_spec.ts | 4 +++ .../test/acceptance/control_flow_if_spec.ts | 26 +++++++++++++++++++ .../core/test/acceptance/integration_spec.ts | 24 +++++++++++++++-- 21 files changed, 147 insertions(+), 22 deletions(-) diff --git a/adev/src/content/guide/templates/expression-syntax.md b/adev/src/content/guide/templates/expression-syntax.md index b0d32c28d00..298f5e40fa1 100644 --- a/adev/src/content/guide/templates/expression-syntax.md +++ b/adev/src/content/guide/templates/expression-syntax.md @@ -68,6 +68,7 @@ Angular supports the following operators from standard JavaScript. | typeof | `typeof 42` | | void | `void 1` | | in | `'model' in car` | +| instanceof | `car instanceof Automobile` | | Assignment | `a = b` | | Addition Assignment | `a += b` | | Subtraction Assignment | `a -= b` | @@ -100,7 +101,6 @@ NOTE: Optional chaining behaves differently from the standard JavaScript version | Object destructuring | `const { name } = person` | | Array destructuring | `const [firstItem] = items` | | Comma operator | `x = (x++, x)` | -| instanceof | `car instanceof Automobile` | | new | `new Car()` | ## Lexical context for expressions diff --git a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts index 5461ddf91ff..cb9dc984f55 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/api/ast_factory.ts @@ -333,7 +333,6 @@ export type BinaryOperator = | '||' | '+' | '??' - | 'in' | '=' | '+=' | '-=' @@ -343,7 +342,9 @@ export type BinaryOperator = | '**=' | '&&=' | '||=' - | '??='; + | '??=' + | 'in' + | 'instanceof'; /** * The original location of the start or end of a node created by the `AstFactory`. diff --git a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts index d4bb430c000..517fd26790a 100644 --- a/packages/compiler-cli/src/ngtsc/translator/src/translator.ts +++ b/packages/compiler-cli/src/ngtsc/translator/src/translator.ts @@ -47,6 +47,7 @@ const BINARY_OPERATORS = /* @__PURE__ */ new Map = diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts index 96158374d76..e360bfcec9d 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts @@ -106,7 +106,6 @@ class AstTranslator implements AstVisitor { ['&', ts.SyntaxKind.AmpersandToken], ['|', ts.SyntaxKind.BarToken], ['??', ts.SyntaxKind.QuestionQuestionToken], - ['in', ts.SyntaxKind.InKeyword], ['=', ts.SyntaxKind.EqualsToken], ['+=', ts.SyntaxKind.PlusEqualsToken], ['-=', ts.SyntaxKind.MinusEqualsToken], @@ -117,6 +116,8 @@ class AstTranslator implements AstVisitor { ['&&=', ts.SyntaxKind.AmpersandAmpersandEqualsToken], ['||=', ts.SyntaxKind.BarBarEqualsToken], ['??=', ts.SyntaxKind.QuestionQuestionEqualsToken], + ['in', ts.SyntaxKind.InKeyword], + ['instanceof', ts.SyntaxKind.InstanceOfKeyword], ]); constructor( 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 41043cbc704..9a65f05c496 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 @@ -114,6 +114,15 @@ describe('type check blocks', () => { expect(tcb(`{{!('bar' in {bar: 'bar'}) }}`)).toContain(`!((("bar") in ({ "bar": "bar" })))`); }); + it('should handle "instanceof" expressions', () => { + expect(tcb(`{{obj instanceof MyClass}}`)).toContain( + `((((this).obj)) instanceof (((this).MyClass)))`, + ); + expect(tcb(`{{!(obj instanceof MyClass)}}`)).toContain( + `!(((((this).obj)) instanceof (((this).MyClass)))))`, + ); + }); + it('should handle attribute values for directive inputs', () => { const TEMPLATE = `
`; const DIRECTIVES: TestDeclaration[] = [ diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js index 70c0ecda69c..f40ecd9b275 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js @@ -80,9 +80,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE type: Pipe, args: [{ name: 'identity' }] }] }); +export class Bar { +} export class MyApp { foo = { bar: 'baz' }; number = 1; + bar = new Bar(); + Bar = Bar; static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` {{ 1 + 2 }} @@ -95,6 +99,7 @@ export class MyApp { {{ void 'test' }} {{ (-1) ** 3 }} {{ 'bar' in foo }} + {{ bar instanceof Bar }} @@ -120,6 +125,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE {{ void 'test' }} {{ (-1) ** 3 }} {{ 'bar' in foo }} + {{ bar instanceof Bar }} @@ -143,11 +149,15 @@ export declare class IdentityPipe { static ɵfac: i0.ɵɵFactoryDeclaration; static ɵpipe: i0.ɵɵPipeDeclaration; } +export declare class Bar { +} export declare class MyApp { foo: { bar?: string; }; number: number; + bar: Bar; + Bar: typeof Bar; static ɵfac: i0.ɵɵFactoryDeclaration; static ɵcmp: i0.ɵɵComponentDeclaration; } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts index 73568857a18..a4d03d7e2b2 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators.ts @@ -1,4 +1,4 @@ -import {Component, Pipe} from '@angular/core'; +import { Component, Pipe } from '@angular/core'; @Pipe({name: 'identity'}) export class IdentityPipe { @@ -7,6 +7,8 @@ export class IdentityPipe { } } +export class Bar {} + @Component({ template: ` {{ 1 + 2 }} @@ -19,6 +21,7 @@ export class IdentityPipe { {{ void 'test' }} {{ (-1) ** 3 }} {{ 'bar' in foo }} + {{ bar instanceof Bar }} @@ -34,4 +37,6 @@ export class IdentityPipe { export class MyApp { foo: {bar?: string} = {bar: 'baz'}; number = 1; + bar = new Bar(); + Bar = Bar; } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js index d2e83ba741c..c272e02ea1f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/operators_template.js @@ -25,14 +25,14 @@ template: function MyApp_Template(rf, $ctx$) { 1 + 2, " ", 1 % 2 + 3 / 4 * 5 ** 6, " ", +1, " ", - typeof $r3$.ɵɵpureFunction0(12, _c0) === "object", " ", - !(typeof $r3$.ɵɵpureFunction0(13, _c0) === "object"), " ", + typeof $r3$.ɵɵpureFunction0(13, _c0) === "object", " ", + !(typeof $r3$.ɵɵpureFunction0(14, _c0) === "object"), " ", typeof ($ctx$.foo == null ? null : $ctx$.foo.bar) === "string", " ", - $r3$.ɵɵpipeBind1(1, 10, typeof ($ctx$.foo == null ? null : $ctx$.foo.bar)), " ", + $r3$.ɵɵpipeBind1(1, 11, typeof ($ctx$.foo == null ? null : $ctx$.foo.bar)), " ", void "test", " ", (-1) ** 3, " ", - "bar" in $ctx$.foo, " " + "bar" in $ctx$.foo, " ", + $ctx$.bar instanceof $ctx$.Bar, " " ]); } } - \ No newline at end of file diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 8d2e0641464..c73ea218498 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -816,6 +816,26 @@ runInEachFileSystem(() => { expect(diags[0].messageText).toContain(`Type 'string' is not assignable to type 'object'`); }); + it('should error on invalid instanceof binary expressions', () => { + env.write( + 'test.ts', + ` + import {Component} from '@angular/core'; + @Component({ + template: \` {{'foo' instanceof String}} \`, + }) + class TestCmp { + } + `, + ); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText).toContain( + `The left-hand side of an 'instanceof' expression must be of type 'any', an object type or a type parameter.`, + ); + }); + describe('strictInputTypes', () => { beforeEach(() => { env.write( diff --git a/packages/compiler/src/expression_parser/ast.ts b/packages/compiler/src/expression_parser/ast.ts index abefe44ef39..3ab242cf27f 100644 --- a/packages/compiler/src/expression_parser/ast.ts +++ b/packages/compiler/src/expression_parser/ast.ts @@ -296,6 +296,7 @@ type BinaryOperation = | '<=' | '>=' | 'in' + | 'instanceof' // Additive | '+' | '-' diff --git a/packages/compiler/src/expression_parser/lexer.ts b/packages/compiler/src/expression_parser/lexer.ts index c91bf800cea..4d0568c51f8 100644 --- a/packages/compiler/src/expression_parser/lexer.ts +++ b/packages/compiler/src/expression_parser/lexer.ts @@ -41,6 +41,7 @@ const KEYWORDS = [ 'typeof', 'void', 'in', + 'instanceof', ]; export class Lexer { @@ -126,6 +127,10 @@ export class Token { return this.type === TokenType.Keyword && this.strValue === 'in'; } + isKeywordInstanceOf(): boolean { + return this.type === TokenType.Keyword && this.strValue === 'instanceof'; + } + isError(): boolean { return this.type === TokenType.Error; } diff --git a/packages/compiler/src/expression_parser/parser.ts b/packages/compiler/src/expression_parser/parser.ts index 73988ad73b2..c0de70d43d4 100644 --- a/packages/compiler/src/expression_parser/parser.ts +++ b/packages/compiler/src/expression_parser/parser.ts @@ -964,10 +964,14 @@ class _ParseAST { } private parseRelational(): AST { - // '<', '>', '<=', '>=', 'in' + // '<', '>', '<=', '>=', 'in', 'instanceof' const start = this.inputIndex; let result = this.parseAdditive(); - while (this.next.type == TokenType.Operator || this.next.isKeywordIn) { + while ( + this.next.type == TokenType.Operator || + this.next.isKeywordIn() || // Should be invoked. This is bug here that will be fixed by #65249 when the breaking change window opens. + this.next.isKeywordInstanceOf() + ) { const operator = this.next.strValue; switch (operator) { case '<': @@ -975,6 +979,7 @@ class _ParseAST { case '<=': case '>=': case 'in': + case 'instanceof': this.advance(); const right = this.parseAdditive(); result = new Binary(this.span(start), this.sourceSpan(start), operator, result, right); diff --git a/packages/compiler/src/output/abstract_emitter.ts b/packages/compiler/src/output/abstract_emitter.ts index 2ec7b776d54..adcff8205d6 100644 --- a/packages/compiler/src/output/abstract_emitter.ts +++ b/packages/compiler/src/output/abstract_emitter.ts @@ -44,6 +44,7 @@ const BINARY_OPERATORS = new Map([ [o.BinaryOperator.Or, '||'], [o.BinaryOperator.Plus, '+'], [o.BinaryOperator.In, 'in'], + [o.BinaryOperator.InstanceOf, 'instanceof'], [o.BinaryOperator.AdditionAssignment, '+='], [o.BinaryOperator.SubtractionAssignment, '-='], [o.BinaryOperator.MultiplicationAssignment, '*='], diff --git a/packages/compiler/src/output/output_ast.ts b/packages/compiler/src/output/output_ast.ts index 2680e8fb814..cf12cc811ff 100644 --- a/packages/compiler/src/output/output_ast.ts +++ b/packages/compiler/src/output/output_ast.ts @@ -143,6 +143,7 @@ export enum BinaryOperator { NullishCoalesce, Exponentiation, In, + InstanceOf, AdditionAssignment, SubtractionAssignment, MultiplicationAssignment, diff --git a/packages/compiler/src/template/pipeline/src/conversion.ts b/packages/compiler/src/template/pipeline/src/conversion.ts index e29a2e1b0c7..de4657af6ee 100644 --- a/packages/compiler/src/template/pipeline/src/conversion.ts +++ b/packages/compiler/src/template/pipeline/src/conversion.ts @@ -31,6 +31,7 @@ export const BINARY_OPERATORS = new Map([ ['||', o.BinaryOperator.Or], ['+', o.BinaryOperator.Plus], ['in', o.BinaryOperator.In], + ['instanceof', o.BinaryOperator.InstanceOf], ['+=', o.BinaryOperator.AdditionAssignment], ['-=', o.BinaryOperator.SubtractionAssignment], ['*=', o.BinaryOperator.MultiplicationAssignment], diff --git a/packages/compiler/test/expression_parser/lexer_spec.ts b/packages/compiler/test/expression_parser/lexer_spec.ts index 00ebb071202..62e3f6be554 100644 --- a/packages/compiler/test/expression_parser/lexer_spec.ts +++ b/packages/compiler/test/expression_parser/lexer_spec.ts @@ -222,6 +222,12 @@ describe('lexer', () => { expect(tokens[0].isKeywordIn()).toBe(true); }); + it('should tokenize instanceof keyword', () => { + const tokens: Token[] = lex('instanceof'); + expectKeywordToken(tokens[0], 0, 10, 'instanceof'); + expect(tokens[0].isKeywordInstanceOf()).toBe(true); + }); + it('should ignore whitespace', () => { const tokens: Token[] = lex('a \t \n \r b'); expectIdentifierToken(tokens[0], 0, 1, 'a'); diff --git a/packages/compiler/test/expression_parser/parser_spec.ts b/packages/compiler/test/expression_parser/parser_spec.ts index c62c630a202..86ac58fd0cd 100644 --- a/packages/compiler/test/expression_parser/parser_spec.ts +++ b/packages/compiler/test/expression_parser/parser_spec.ts @@ -6,30 +6,30 @@ * found in the LICENSE file at https://angular.dev/license */ +import {expect} from '@angular/private/testing/matchers'; import { AbsoluteSourceSpan, + ArrowFunction, ASTWithSource, BindingPipe, + BindingPipeType, Call, EmptyExpr, Interpolation, LiteralMap, + LiteralMapPropertyKey, + ParseSpan, PropertyRead, TemplateBinding, VariableBinding, - BindingPipeType, - ParseSpan, - LiteralMapPropertyKey, - ArrowFunction, } from '../../src/expression_parser/ast'; -import {ParseError} from '../../src/parse_util'; import {Lexer} from '../../src/expression_parser/lexer'; import {Parser, SplitInterpolation} from '../../src/expression_parser/parser'; -import {expect} from '@angular/private/testing/matchers'; +import {ParseError} from '../../src/parse_util'; +import {getFakeSpan} from './utils/span'; import {unparse, unparseWithSpan} from './utils/unparser'; import {validate} from './utils/validator'; -import {getFakeSpan} from './utils/span'; describe('parser', () => { describe('parseAction', () => { @@ -88,6 +88,8 @@ describe('parser', () => { checkAction('2 > 3'); checkAction('2 <= 2'); checkAction('2 >= 2'); + checkAction(`"key" in obj`); + checkAction(`foo instanceof Foo`); }); it('should parse equality expressions', () => { @@ -130,6 +132,11 @@ describe('parser', () => { checkAction('a //comment', 'a'); }); + it('should parse instanceof expressions', () => { + checkAction(`obj instanceof MyClass`, `obj instanceof MyClass`); + checkAction(`(obj instanceof MyClass) || false`, `(obj instanceof MyClass) || false`); + }); + it('should retain // in string literals', () => { checkAction(`"http://www.google.com"`, `"http://www.google.com"`); }); diff --git a/packages/compiler/test/expression_parser/serializer_spec.ts b/packages/compiler/test/expression_parser/serializer_spec.ts index 75a22317de7..a4d586361fb 100644 --- a/packages/compiler/test/expression_parser/serializer_spec.ts +++ b/packages/compiler/test/expression_parser/serializer_spec.ts @@ -132,5 +132,9 @@ describe('serializer', () => { it('serializes in expressions', () => { expect(serialize(parse(' foo in bar '))).toBe('foo in bar'); }); + + it('serializes instanceof expressions', () => { + expect(serialize(parse(' foo instanceof Bar '))).toBe('foo instanceof Bar'); + }); }); }); diff --git a/packages/core/test/acceptance/control_flow_if_spec.ts b/packages/core/test/acceptance/control_flow_if_spec.ts index 01082650f93..568982771e7 100644 --- a/packages/core/test/acceptance/control_flow_if_spec.ts +++ b/packages/core/test/acceptance/control_flow_if_spec.ts @@ -321,6 +321,32 @@ describe('control flow - if', () => { expect(fixture.nativeElement.textContent.trim()).toBe('no 42'); }); + it('should support a condition with the instanceof keyword', () => { + class Foo {} + + // prettier-ignore + @Component({ + template: ` + @if (value instanceof Foo) { + is Foo + } @else { + is not Foo + } + `, + }) + class TestComponent { + Foo = Foo; + value: string | Foo = new Foo(); + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('is Foo'); + fixture.componentInstance.value = 'not a Foo'; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('is not Foo'); + }); + it('should expose expression value through alias on @else if', () => { @Component({ template: ` diff --git a/packages/core/test/acceptance/integration_spec.ts b/packages/core/test/acceptance/integration_spec.ts index 00bda0ee963..80c34326f49 100644 --- a/packages/core/test/acceptance/integration_spec.ts +++ b/packages/core/test/acceptance/integration_spec.ts @@ -9,6 +9,8 @@ import {animate, AnimationEvent, state, style, transition, trigger} from '@angul import {AnimationDriver} from '@angular/animations/browser'; import {MockAnimationDriver, MockAnimationPlayer} from '@angular/animations/browser/testing'; import {CommonModule} from '@angular/common'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import { Component, ContentChild, @@ -38,8 +40,6 @@ import {isLView} from '../../src/render3/interfaces/type_checks'; import {ID, LView, PARENT, TVIEW} from '../../src/render3/interfaces/view'; import {getLView} from '../../src/render3/state'; import {fakeAsync, flushMicrotasks, TestBed} from '../../testing'; -import {By} from '@angular/platform-browser'; -import {NoopAnimationsModule} from '@angular/platform-browser/animations'; describe('acceptance integration tests', () => { beforeEach(() => { @@ -3098,6 +3098,26 @@ describe('acceptance integration tests', () => { expect(fixture.nativeElement.textContent).toContain('KO'); }); + it('should support "instanceof" expressions', () => { + class MyClass {} + + @Component({ + template: `{{ obj instanceof MyClass ? 'OK' : 'KO' }}`, + }) + class TestComponent { + MyClass = MyClass; + obj: any = new MyClass(); + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('OK'); + + fixture.componentInstance.obj = {}; + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('KO'); + }); + describe('tView.firstUpdatePass', () => { function isFirstUpdatePass() { const lView = getLView();