feat(compiler): Add support for the instanceof binary operator

Because why not ?

fixes #59975
This commit is contained in:
Matthieu Riegler 2026-01-09 06:38:36 +01:00 committed by Jessica Janiuk
parent 9273f1c3c2
commit 72534e2a34
21 changed files with 147 additions and 22 deletions

View file

@ -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

View file

@ -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`.

View file

@ -47,6 +47,7 @@ const BINARY_OPERATORS = /* @__PURE__ */ new Map<o.BinaryOperator, BinaryOperato
[o.BinaryOperator.NullishCoalesce, '??'],
[o.BinaryOperator.Exponentiation, '**'],
[o.BinaryOperator.In, 'in'],
[o.BinaryOperator.InstanceOf, 'instanceof'],
[o.BinaryOperator.Assign, '='],
[o.BinaryOperator.AdditionAssignment, '+='],
[o.BinaryOperator.SubtractionAssignment, '-='],

View file

@ -68,7 +68,6 @@ export class TypeScriptAstFactory implements AstFactory<ts.Statement, ts.Express
'||': ts.SyntaxKind.BarBarToken,
'+': ts.SyntaxKind.PlusToken,
'??': ts.SyntaxKind.QuestionQuestionToken,
'in': ts.SyntaxKind.InKeyword,
'=': ts.SyntaxKind.EqualsToken,
'+=': ts.SyntaxKind.PlusEqualsToken,
'-=': ts.SyntaxKind.MinusEqualsToken,
@ -79,6 +78,8 @@ export class TypeScriptAstFactory implements AstFactory<ts.Statement, ts.Express
'&&=': ts.SyntaxKind.AmpersandAmpersandEqualsToken,
'||=': ts.SyntaxKind.BarBarEqualsToken,
'??=': ts.SyntaxKind.QuestionQuestionEqualsToken,
'in': ts.SyntaxKind.InKeyword,
'instanceof': ts.SyntaxKind.InstanceOfKeyword,
}))();
private readonly VAR_TYPES: Record<VariableDeclarationType, ts.NodeFlags> =

View file

@ -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(

View file

@ -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 = `<div dir inputA="value"></div>`;
const DIRECTIVES: TestDeclaration[] = [

View file

@ -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 }}
<button (click)="number += 1"></button>
<button (click)="number -= 1"></button>
<button (click)="number *= 1"></button>
@ -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 }}
<button (click)="number += 1"></button>
<button (click)="number -= 1"></button>
<button (click)="number *= 1"></button>
@ -143,11 +149,15 @@ export declare class IdentityPipe {
static ɵfac: i0.ɵɵFactoryDeclaration<IdentityPipe, never>;
static ɵpipe: i0.ɵɵPipeDeclaration<IdentityPipe, "identity", true>;
}
export declare class Bar {
}
export declare class MyApp {
foo: {
bar?: string;
};
number: number;
bar: Bar;
Bar: typeof Bar;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, true, never>;
}

View file

@ -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 }}
<button (click)="number += 1"></button>
<button (click)="number -= 1"></button>
<button (click)="number *= 1"></button>
@ -34,4 +37,6 @@ export class IdentityPipe {
export class MyApp {
foo: {bar?: string} = {bar: 'baz'};
number = 1;
bar = new Bar();
Bar = Bar;
}

View file

@ -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, " "
]);
}
}

View file

@ -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(

View file

@ -296,6 +296,7 @@ type BinaryOperation =
| '<='
| '>='
| 'in'
| 'instanceof'
// Additive
| '+'
| '-'

View file

@ -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;
}

View file

@ -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);

View file

@ -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, '*='],

View file

@ -143,6 +143,7 @@ export enum BinaryOperator {
NullishCoalesce,
Exponentiation,
In,
InstanceOf,
AdditionAssignment,
SubtractionAssignment,
MultiplicationAssignment,

View file

@ -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],

View file

@ -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');

View file

@ -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"`);
});

View file

@ -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');
});
});
});

View file

@ -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: `

View file

@ -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();