8000 feat(compiler): add support for the `typeof` keyword in template expressions by JeanMeche · Pull Request #58183 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

feat(compiler): add support for the typeof keyword in template expressions #58183

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
LiteralPrimitive,
NonNullAssert,
PrefixNot,
TypeofExpression,
PropertyRead,
PropertyWrite,
SafeCall,
Expand Down Expand Up @@ -275,6 +276,13 @@ class AstTranslator implements AstVisitor {
return node;
}

visitTypeofExpresion(ast: TypeofExpression): ts.Expression {
const expression = wrapForDiagnostics(this.translate(ast.expression));
const node = ts.factory.createTypeOfExpression(expression);
addParseSpanInfo(node, ast.sourceSpan);
return node;
}

visitPropertyRead(ast: PropertyRead): ts.Expression {
// This is a normal property read - convert the receiver to an expression and emit the correct
// TypeScript expression to read the property.
Expand Down Expand Up @@ -541,6 +549,9 @@ class VeSafeLhsInferenceBugDetector implements AstVisitor {
visitPrefixNot(ast: PrefixNot): boolean {
return ast.expression.visit(this);
}
visitTypeofExpresion(ast: PrefixNot): boolean {
return ast.expression.visit(this);
}
visitNonNullAssert(ast: PrefixNot): boolean {
return ast.expression.visit(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ describe('type check blocks', () => {
);
});

it('should handle typeof expressions', () => {
expect(tcb('{{typeof a}}')).toContain('typeof (((this).a))');
expect(tcb('{{!(typeof a)}}')).toContain('!(typeof (((this).a)))');
expect(tcb('{{!(typeof a === "object")}}')).toContain(
'!((typeof (((this).a))) === ("object"))',
);
});

it('should handle attribute values for directive inputs', () => {
const TEMPLATE = `<div dir inputA="value"></div>`;
const DIRECTIVES: TestDeclaration[] = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,48 +70,76 @@ export declare class TodoModule {
/****************************************************************************************************
* PARTIAL FILE: operators.js
****************************************************************************************************/
import { Component, NgModule } from '@angular/core';
import { Component, NgModule, Pipe } from '@angular/core';
import * as i0 from "@angular/core";
export class MyApp {
constructor() {
this.foo = { bar: 'baz' };
}
}
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: `
{{ 1 + 2 }}
{{ (1 % 2) + 3 / 4 * 5 }}
{{ +1 }}
`, isInline: true });
{{ typeof {} === 'object' }}
{{ !(typeof {} === 'object') }}
{{ typeof foo?.bar === 'string' }}
{{ typeof foo?.bar | identity }}
`, isInline: true, dependencies: [{ kind: "pipe", type: i0.forwardRef(() => IdentityPipe), name: "identity" }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
type: Component,
args: [{
template: `
{{ 1 + 2 }}
{{ (1 % 2) + 3 / 4 * 5 }}
{{ +1 }}
{{ typeof {} === 'object' }}
{{ !(typeof {} === 'object') }}
{{ typeof foo?.bar === 'string' }}
{{ typeof foo?.bar | identity }}
`,
standalone: false
}]
}] });
export class IdentityPipe {
transform(value) { return value; }
}
IdentityPipe.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
IdentityPipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, name: "identity" });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: IdentityPipe, decorators: [{
type: Pipe,
args: [{ name: 'identity' }]
}] });
export class MyModule {
}
MyModule.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp] });
MyModule.ɵmod = i0.ɵɵngDeclareNgModule({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, declarations: [MyApp, IdentityPipe] });
MyModule.ɵinj = i0.ɵɵngDeclareInjector({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyModule, decorators: [{
type: NgModule,
args: [{ declarations: [MyApp] }]
args: [{ declarations: [MyApp, IdentityPipe] }]
}] });

/****************************************************************************************************
* PARTIAL FILE: operators.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class MyApp {
foo: {
bar?: string;
};
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "ng-component", never, {}, {}, never, never, false, never>;
}
export declare class IdentityPipe {
transform(value: any): any;
static ɵfac: i0.ɵɵFactoryDeclaration<IdentityPipe, never>;
static ɵpipe: i0.ɵɵPipeDeclaration<IdentityPipe, "identity", false>;
}
export declare class MyModule {
static ɵfac: i0.ɵɵFactoryDeclaration<MyModule, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp], never, never>;
static ɵmod: i0.ɵɵNgModuleDeclaration<MyModule, [typeof MyApp, typeof IdentityPipe], never, never>;
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
import {Component, NgModule} from '@angular/core';
import {Component, NgModule, Pipe} from '@angular/core';

@Component({
template: `
{{ 1 + 2 }}
{{ (1 % 2) + 3 / 4 * 5 }}
{{ +1 }}
{{ typeof {} === 'object' }}
{{ !(typeof {} === 'object') }}
{{ typeof foo?.bar === 'string' }}
{{ typeof foo?.bar | identity }}
`,
standalone: false
})
export class MyApp {
foo: {bar?: string} = {bar: 'baz'};
}

@NgModule({declarations: [MyApp]})
@Pipe ({name: 'identity'})
export class IdentityPipe {
transform(value: any) { return value; }
}

@NgModule({declarations: [MyApp, IdentityPipe]})
export class MyModule {
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
template: function MyApp_Template(rf, $ctx$) {
if (rf & 1) {
$i0$.ɵɵtext(0);
i0.ɵɵpipe(1, "identity");
} if (rf & 2) {
i0.ɵɵtextInterpolate3(" ", 1 + 2, " ", 1 % 2 + 3 / 4 * 5, " ", +1, "\n");
i0.ɵɵtextInterpolate7(" ",
1 + 2, " ",
1 % 2 + 3 / 4 * 5, " ",
+1, " ",
typeof i0.ɵɵpureFunction0(9, _c0) === "object", " ",
!(typeof i0.ɵɵpureFunction0(10, _c0) === "object"), " ",
typeof (ctx.foo == null ? null : ctx.foo.bar) === "string", " ",
i0.ɵɵpipeBind1(1, 7, typeof (ctx.foo == null ? null : ctx.foo.bar)), "\n"
);
}
}

41 changes: 41 additions & 0 deletions packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,47 @@ runInEachFileSystem(() => {
expect(diags[0].messageText).toContain(`Property 'input' does not exist on type 'TestCmp'.`);
});

it('should error on non valid typeof expressions', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';

@Component({
standalone: true,
template: \` {{typeof {} === 'foobar'}} \`,
})
class TestCmp {
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toContain(`This comparison appears to be unintentional`);
});

it('should error on misused logical not in typeof expressions', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';

@Component({
standalone: true,
// should be !(typeof {} === 'object')
template: \` {{!typeof {} === 'object'}} \`,
})
class TestCmp {
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toContain(`This comparison appears to be unintentional`);
});

describe('strictInputTypes', () => {
beforeEach(() => {
env.write(
Expand Down
10000
29 changes: 29 additions & 0 deletions packages/compiler/src/expression_parser/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,19 @@ export class PrefixNot extends AST {
}
}

export class TypeofExpression extends AST {
constructor(
span: ParseSpan,
sourceSpan: AbsoluteSourceSpan,
public expression: AST,
) {
super(span, sourceSpan);
}
override visit(visitor: AstVisitor, context: any = null): any {
return visitor.visitTypeofExpresion(this, context);
}
}

export class NonNullAssert extends AST {
constructor(
span: ParseSpan,
Expand Down Expand Up @@ -534,6 +547,7 @@ export interface AstVisitor {
visitLiteralPrimitive(ast: LiteralPrimitive, context: any): any;
visitPipe(ast: BindingPipe, context: any): any;
visitPrefixNot(ast: PrefixNot, context: any): any;
visitTypeofExpresion(ast: TypeofExpression, context: any): any;
visitNonNullAssert(ast: NonNullAssert, context: any): any;
visitPropertyRead(ast: PropertyRead, context: any): any;
visitPropertyWrite(ast: PropertyWrite, context: any): any;
Expand Down Expand Up @@ -601,6 +615,9 @@ export class RecursiveAstVisitor implements AstVisitor {
visitPrefixNot(ast: PrefixNot, context: any): any {
this.visit(ast.expression, context);
}
visitTypeofExpresion(ast: TypeofExpression, context: any) {
this.visit(ast.expression, context);
}
visitNonNullAssert(ast: NonNullAssert, context: any): any {
this.visit(ast.expression, context);
}
Expand Down Expand Up @@ -715,6 +732,10 @@ export class AstTransformer implements AstVisitor {
return new PrefixNot(ast.span, ast.sourceSpan, ast.expression.visit(this));
}

visitTypeofExpresion(ast: TypeofExpression, context: any): AST {
return new TypeofExpression(ast.span, ast.sourceSpan, ast.expression.visit(this));
}

visitNonNullAssert(ast: NonNullAssert, context: any): AST {
return new NonNullAssert(ast.span, ast.sourceSpan, ast.expression.visit(this));
}
Expand Down Expand Up @@ -891,6 +912,14 @@ export class AstMemoryEfficientTransformer implements AstVisitor {
return ast;
}

visitTypeofExpresion(ast: TypeofExpression, context: any): AST {
const expression = ast.expression.visit(this);
if (expression !== ast.expression) {
return new TypeofExpression(ast.span, ast.sourceSpan, expression);
}
return ast;
}

visitNonNullAssert(ast: NonNullAssert, context: any): AST {
const expression = ast.expression.visit(this);
if (expression !== ast.expression) {
Expand Down
30 changes: 17 additions & 13 deletions packages/compiler/src/expression_parser/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,19 @@ export enum TokenType {
Error,
}

const KEYWORDS = ['var', 'let', 'as', 'null', 'undefined', 'true', 'false', 'if', 'else', 'this'];
const KEYWORDS = [
'var',
'let',
'as',
'null',
'undefined',
'true',
'false',
'if',
'else',
'this',
'typeof',
];

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

isKeywordTypeof(): boolean {
return this.type === TokenType.Keyword && this.strValue === 'typeof';
}

isError(): boolean {
return this.type == TokenType.Error;
}
Expand Down Expand Up @@ -436,18 +452,6 @@ function isIdentifierStart(code: number): boolean {
);
}

export function isIdentifier(input: string): boolean {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was unused.

if (input.length == 0) return false;
const scanner = new _Scanner(input);
if (!isIdentifierStart(scanner.peek)) return false;
scanner.advance();
while (scanner.peek !== chars.$EOF) {
if (!isIdentifierPart(scanner.peek)) return false;
scanner.advance();
}
return true;
}

function isIdentifierPart(code: number): boolean {
return chars.isAsciiLetter(code) || chars.isDigit(code) || code == chars.$_ || code == chars.$$;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/src/expression_parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
ParserError,
ParseSpan,
PrefixNot,
TypeofExpression,
PropertyRead,
PropertyWrite,
RecursiveAstVisitor,
Expand Down Expand Up @@ -960,6 +961,11 @@ class _ParseAST {
result = this.parsePrefix();
return new PrefixNot(this.span(start), this.sourceSpan(start), result);
}
} else if (this.next.isKeywordTypeof()) {
this.advance();
const start = this.inputIndex;
let result = this.parsePrefix();
return new TypeofExpression(this.span(start), this.sourceSpan(start), result);
}
return this.parseCallChain();
}
Expand Down
2 changes: 2 additions & 0 deletions packages/compiler/src/template/pipeline/src/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1150,6 +1150,8 @@ function convertAst(
convertAst(ast.expression, job, baseSourceSpan),
convertSourceSpan(ast.span, baseSourceSpan),
);
} else if (ast instanceof e.TypeofExpression) {
return o.typeofExpr(convertAst(ast.expression, job, baseSourceSpan));
} else {
throw new Error(
`Unhandled expression type "${ast.constructor.name}" in file "${baseSourceSpan?.start.file.url}"`,
Expand Down
6 changes: 6 additions & 0 deletions packages/compiler/test/expression_parser/lexer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ describe('lexer', () => {
expect(tokens[0].isKeywordUndefined()).toBe(true);
});

it('should tokenize typeof', () => {
const tokens: Token[] = lex('typeof');
expectKeywordToken(tokens[0], 0, 6, 'typeof');
expect(tokens[0].isKeywordTypeof()).toBe(true);
});

it('should ignore whitespace', () => {
const tokens: Token[] = lex('a \t \n \r b');
expectIdentifierToken(tokens[0], 0, 1, 'a');
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/test/expression_parser/parser_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ describe('parser', () => {
checkAction('null ?? undefined ?? 0');
});

it('should parse typeof expression', () => {
checkAction(`typeof {} === "object"`);
checkAction('(!(typeof {} === "number"))', '!typeof {} === "number"');
});

it('should parse grouped expressions', () => {
checkAction('(1 + 2) * 3', '1 + 2 * 3');
});
Expand Down
Loading
0