Skip to content

Commit 0c9d721

Browse files
JeanMechedevversion
authored andcommitted
feat(compiler): add support for the typeof keyword in template expressions. (angular#58183)
This commit adds the support for `typeof` in template expressions like interpolation, bindings, control flow blocks etc. PR Close angular#58183
1 parent 231e6ff commit 0c9d721

16 files changed

Lines changed: 219 additions & 21 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
LiteralPrimitive,
2626
NonNullAssert,
2727
PrefixNot,
28+
TypeofExpression,
2829
PropertyRead,
2930
PropertyWrite,
3031
SafeCall,
@@ -275,6 +276,13 @@ class AstTranslator implements AstVisitor {
275276
return node;
276277
}
277278

279+
visitTypeofExpresion(ast: TypeofExpression): ts.Expression {
280+
const expression = wrapForDiagnostics(this.translate(ast.expression));
281+
const node = ts.factory.createTypeOfExpression(expression);
282+
addParseSpanInfo(node, ast.sourceSpan);
283+
return node;
284+
}
285+
278286
visitPropertyRead(ast: PropertyRead): ts.Expression {
279287
// This is a normal property read - convert the receiver to an expression and emit the correct
280288
// TypeScript expression to read the property.
@@ -541,6 +549,9 @@ class VeSafeLhsInferenceBugDetector implements AstVisitor {
541549
visitPrefixNot(ast: PrefixNot): boolean {
542550
return ast.expression.visit(this);
543551
}
552+
visitTypeofExpresion(ast: PrefixNot): boolean {
553+
return ast.expression.visit(this);
554+
}
544555
visitNonNullAssert(ast: PrefixNot): boolean {
545556
return ast.expression.visit(this);
546557
}

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ describe('type check blocks', () => {
6161
);
6262
});
6363

64+
it('should handle typeof expressions', () => {
65+
expect(tcb('{{typeof a}}')).toContain('typeof (((this).a))');
66+
expect(tcb('{{!(typeof a)}}')).toContain('!(typeof (((this).a)))');
67+
expect(tcb('{{!(typeof a === "object")}}')).toContain(
68+
'!((typeof (((this).a))) === ("object"))',
69+
);
70+
});
71+
6472
it('should handle attribute values for directive inputs', () => {
6573
const TEMPLATE = `<div dir inputA="value"></div>`;
6674
const DIRECTIVES: TestDeclaration[] = [

packages/compiler-cli/test/compliance/test_cases/r3_view_compiler/GOLDEN_PARTIAL.js

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,48 +70,76 @@ export declare class TodoModule {
7070
/****************************************************************************************************
7171
* PARTIAL FILE: operators.js
7272
****************************************************************************************************/
73-
import { Component, NgModule } from '@angular/core';
73+
import { Component, NgModule, Pipe } from '@angular/core';
7474
import * as i0 from "@angular/core";
7575
export class MyApp {
76+
constructor() {
77+
this.foo = { bar: 'baz' };
78+
}
7679
}
7780
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
7881
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: false, selector: "ng-component", ngImport: i0, template: `
7982
{{ 1 + 2 }}
8083
{{ (1 % 2) + 3 / 4 * 5 }}
8184
{{ +1 }}
82-
`, isInline: true });
85+
{{ typeof {} === 'object' }}
86+
{{ !(typeof {} === 'object') }}
87+
{{ typeof foo?.bar === 'string' }}
88+
{{ typeof foo?.bar | identity }}
89+
`, isInline: true, dependencies: [{ kind: "pipe", type: i0.forwardRef(() => IdentityPipe), name: "identity" }] });
8390
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
8491
type: Component,
8592
args: [{
8693
template: `
8794
{{ 1 + 2 }}
8895
{{ (1 % 2) + 3 / 4 * 5 }}
8996
{{ +1 }}
97+
{{ typeof {} === 'object' }}
98+
{{ !(typeof {} === 'object') }}
99+
{{ typeof foo?.bar === 'string' }}
100+
{{ typeof foo?.bar | identity }}
90101
`,
91102
standalone: false
92103
}]
93104
}] });
105+
export class IdentityPipe {
106+
transform(value) { return value; }
107+
}
108+
IdentityPipe.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
109+
IdentityPipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, name: "identity" });
110+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, decorators: [{
111+
type: Pipe,
112+
args: [{ name: 'identity' }]
113+
}] });
94114
export class MyModule {
95115
}
96116
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
97-
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] });
117+
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp, IdentityPipe] });
98118
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
99119
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
100120
type: NgModule,
101-
args: [{ declarations: [MyApp] }]
121+
args: [{ declarations: [MyApp, IdentityPipe] }]
102122
}] });
103123

104124
/****************************************************************************************************
105125
* PARTIAL FILE: operators.d.ts
106126
****************************************************************************************************/
107127
import * as i0 from "@angular/core";
108128
export declare class MyApp {
129+
foo: {
130+
bar?: string;
131+
};
109132
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
110133
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
111134
}
135+
export declare class IdentityPipe {
136+
transform(value: any): any;
137+
static ɵfac: i0.ɵɵFactoryDeclaration<IdentityPipe, never>;
138+
static ɵpipe: i0.ɵɵPipeDeclaration<IdentityPipe, "identity", false>;
139+
}
112140
export declare class MyModule {
113141
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
114-
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
142+
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp, typeof IdentityPipe], never, never>;
115143
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
116144
}
117145

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1-
import {Component, NgModule} from '@angular/core';
1+
import {Component, NgModule, Pipe} from '@angular/core';
22

33
@Component({
44
template: `
55
{{ 1 + 2 }}
66
{{ (1 % 2) + 3 / 4 * 5 }}
77
{{ +1 }}
8+
{{ typeof {} === 'object' }}
9+
{{ !(typeof {} === 'object') }}
10+
{{ typeof foo?.bar === 'string' }}
11+
{{ typeof foo?.bar | identity }}
812
`,
913
standalone: false
1014
})
1115
export class MyApp {
16+
foo: {bar?: string} = {bar: 'baz'};
1217
}
1318

14-
@NgModule({declarations: [MyApp]})
19+
@Pipe ({name: 'identity'})
20+
export class IdentityPipe {
21+
transform(value: any) { return value; }
22+
}
23+
24+
@NgModule({declarations: [MyApp, IdentityPipe]})
1525
export class MyModule {
1626
}
Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
template: function MyApp_Template(rf, $ctx$) {
22
if (rf & 1) {
33
$i0$.ɵɵtext(0);
4+
i0.ɵɵpipe(1, "identity");
45
} if (rf & 2) {
5-
i0.ɵɵtextInterpolate3(" ", 1 + 2, " ", 1 % 2 + 3 / 4 * 5, " ", +1, "\n");
6+
i0.ɵɵtextInterpolate7(" ",
7+
1 + 2, " ",
8+
1 % 2 + 3 / 4 * 5, " ",
9+
+1, " ",
10+
typeof i0.ɵɵpureFunction0(9, _c0) === "object", " ",
11+
!(typeof i0.ɵɵpureFunction0(10, _c0) === "object"), " ",
12+
typeof (ctx.foo == null ? null : ctx.foo.bar) === "string", " ",
13+
i0.ɵɵpipeBind1(1, 7, typeof (ctx.foo == null ? null : ctx.foo.bar)), "\n"
14+
);
615
}
716
}
817

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,6 +700,47 @@ runInEachFileSystem(() => {
700700
expect(diags[0].messageText).toContain(`Property 'input' does not exist on type 'TestCmp'.`);
701701
});
702702

703+
it('should error on non valid typeof expressions', () => {
704+
env.write(
705+
'test.ts',
706+
`
707+
import {Component} from '@angular/core';
708+
709+
@Component({
710+
standalone: true,
711+
template: \` {{typeof {} === 'foobar'}} \`,
712+
})
713+
class TestCmp {
714+
}
715+
`,
716+
);
717+
718+
const diags = env.driveDiagnostics();
719+
expect(diags.length).toBe(1);
720+
expect(diags[0].messageText).toContain(`This comparison appears to be unintentional`);
721+
});
722+
723+
it('should error on misused logical not in typeof expressions', () => {
724+
env.write(
725+
'test.ts',
726+
`
727+
import {Component} from '@angular/core';
728+
729+
@Component({
730+
standalone: true,
731+
// should be !(typeof {} === 'object')
732+
template: \` {{!typeof {} === 'object'}} \`,
733+
})
734+
class TestCmp {
735+
}
736+
`,
737+
);
738+
739+
const diags = env.driveDiagnostics();
740+
expect(diags.length).toBe(1);
741+
expect(diags[0].messageText).toContain(`This comparison appears to be unintentional`);
742+
});
743+
703744
describe('strictInputTypes', () => {
704745
beforeEach(() => {
705746
env.write(

packages/compiler/src/expression_parser/ast.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,19 @@ export class PrefixNot extends AST {
373373
}
374374
}
375375

376+
export class TypeofExpression extends AST {
377+
constructor(
378+
span: ParseSpan,
379+
sourceSpan: AbsoluteSourceSpan,
380+
public expression: AST,
381+
) {
382+
super(span, sourceSpan);
383+
}
384+
override visit(visitor: AstVisitor, context: any = null): any {
385+
return visitor.visitTypeofExpresion(this, context);
386+
}
387+
}
388+
376389
export class NonNullAssert extends AST {
377390
constructor(
378391
span: ParseSpan,
@@ -534,6 +547,7 @@ export interface AstVisitor {
534547
visitLiteralPrimitive(ast: LiteralPrimitive, context: any): any;
535548
visitPipe(ast: BindingPipe, context: any): any;
536549
visitPrefixNot(ast: PrefixNot, context: any): any;
550+
visitTypeofExpresion(ast: TypeofExpression, context: any): any;
537551
visitNonNullAssert(ast: NonNullAssert, context: any): any;
538552
visitPropertyRead(ast: PropertyRead, context: any): any;
539553
visitPropertyWrite(ast: PropertyWrite, context: any): any;
@@ -601,6 +615,9 @@ export class RecursiveAstVisitor implements AstVisitor {
601615
visitPrefixNot(ast: PrefixNot, context: any): any {
602616
this.visit(ast.expression, context);
603617
}
618+
visitTypeofExpresion(ast: TypeofExpression, context: any) {
619+
this.visit(ast.expression, context);
620+
}
604621
visitNonNullAssert(ast: NonNullAssert, context: any): any {
605622
this.visit(ast.expression, context);
606623
}
@@ -715,6 +732,10 @@ export class AstTransformer implements AstVisitor {
715732
return new PrefixNot(ast.span, ast.sourceSpan, ast.expression.visit(this));
716733
}
717734

735+
visitTypeofExpresion(ast: TypeofExpression, context: any): AST {
736+
return new TypeofExpression(ast.span, ast.sourceSpan, ast.expression.visit(this));
737+
}
738+
718739
visitNonNullAssert(ast: NonNullAssert, context: any): AST {
719740
return new NonNullAssert(ast.span, ast.sourceSpan, ast.expression.visit(this));
720741
}
@@ -891,6 +912,14 @@ export class AstMemoryEfficientTransformer implements AstVisitor {
891912
return ast;
892913
}
893914

915+
visitTypeofExpresion(ast: TypeofExpression, context: any): AST {
916+
const expression = ast.expression.visit(this);
917+
if (expression !== ast.expression) {
918+
return new TypeofExpression(ast.span, ast.sourceSpan, expression);
919+
}
920+
return ast;
921+
}
922+
894923
visitNonNullAssert(ast: NonNullAssert, context: any): AST {
895924
const expression = ast.expression.visit(this);
896925
if (expression !== ast.expression) {

packages/compiler/src/expression_parser/lexer.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,19 @@ export enum TokenType {
1919
Error,
2020
}
2121

22-
const KEYWORDS = ['var', 'let', 'as', 'null', 'undefined', 'true', 'false', 'if', 'else', 'this'];
22+
const KEYWORDS = [
23+
'var',
24+
'let',
25+
'as',
26+
'null',
27+
'undefined',
28+
'true',
29+
'false',
30+
'if',
31+
'else',
32+
'this',
33+
'typeof',
34+
];
2335

2436
export class Lexer {
2537
tokenize(text: string): Token[] {
@@ -99,6 +111,10 @@ export class Token {
99111
return this.type == TokenType.Keyword && this.strValue == 'this';
100112
}
101113

114+
isKeywordTypeof(): boolean {
115+
return this.type === TokenType.Keyword && this.strValue === 'typeof';
116+
}
117+
102118
isError(): boolean {
103119
return this.type == TokenType.Error;
104120
}
@@ -436,18 +452,6 @@ function isIdentifierStart(code: number): boolean {
436452
);
437453
}
438454

439-
export function isIdentifier(input: string): boolean {
440-
if (input.length == 0) return false;
441-
const scanner = new _Scanner(input);
442-
if (!isIdentifierStart(scanner.peek)) return false;
443-
scanner.advance();
444-
while (scanner.peek !== chars.$EOF) {
445-
if (!isIdentifierPart(scanner.peek)) return false;
446-
scanner.advance();
447-
}
448-
return true;
449-
}
450-
451455
function isIdentifierPart(code: number): boolean {
452456
return chars.isAsciiLetter(code) || chars.isDigit(code) || code == chars.$_ || code == chars.$$;
453457
}

packages/compiler/src/expression_parser/parser.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
ParserError,
3838
ParseSpan,
3939
PrefixNot,
40+
TypeofExpression,
4041
PropertyRead,
4142
PropertyWrite,
4243
RecursiveAstVisitor,
@@ -960,6 +961,11 @@ class _ParseAST {
960961
result = this.parsePrefix();
961962
return new PrefixNot(this.span(start), this.sourceSpan(start), result);
962963
}
964+
} else if (this.next.isKeywordTypeof()) {
965+
this.advance();
966+
const start = this.inputIndex;
967+
let result = this.parsePrefix();
968+
return new TypeofExpression(this.span(start), this.sourceSpan(start), result);
963969
}
964970
return this.parseCallChain();
965971
}

packages/compiler/src/template/pipeline/src/ingest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,8 @@ function convertAst(
11501150
convertAst(ast.expression, job, baseSourceSpan),
11511151
convertSourceSpan(ast.span, baseSourceSpan),
11521152
);
1153+
} else if (ast instanceof e.TypeofExpression) {
1154+
return o.typeofExpr(convertAst(ast.expression, job, baseSourceSpan));
11531155
} else {
11541156
throw new Error(
11551157
`Unhandled expression type "${ast.constructor.name}" in file "${baseSourceSpan?.start.file.url}"`,

0 commit comments

Comments
 (0)