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 3 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 @@ -79,6 +79,8 @@ MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-
{{ 1 + 2 }}
{{ (1 % 2) + 3 / 4 * 5 }}
{{ +1 }}
{{ typeof {} === 'object' }}
{{ !(typeof {} === 'object') }}
`, isInline: true });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
type: Component,
Expand All @@ -87,6 +89,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
{{ 1 + 2 }}
{{ (1 % 2) + 3 / 4 * 5 }}
{{ +1 }}
{{ typeof {} === 'object' }}
{{ !(typeof {} === 'object') }}
`,
standalone: false
}]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {Component, NgModule} from '@angular/core';
{{ 1 + 2 }}
{{ (1 % 2) + 3 / 4 * 5 }}
{{ +1 }}
{{ typeof {} === 'object' }}
{{ !(typeof {} === 'object') }}
`,
standalone: false
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ template: function MyApp_Template(rf, $ctx$) {
if (rf & 1) {
$i0$.ɵɵtext(0);
} if (rf & 2) {
i0.ɵɵtextInterpolate3(" ", 1 + 2, " ", 1 % 2 + 3 / 4 * 5, " ", +1, "\n");
i0.ɵɵtextInterpolate5(" ", 1 + 2, " ", 1 % 2 + 3 / 4 * 5, " ", +1, " ", typeof i0.ɵɵpureFunction0(5, _c0) === "object", " ", !(typeof i0.ɵɵpureFunction0(6, _c0) === "object"), "\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
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
10000
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
6 changes: 6 additions & 0 deletions packages/compiler/test/expression_parser/utils/unparser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
LiteralPrimitive,
NonNullAssert,
PrefixNot,
TypeofExpression,
PropertyRead,
PropertyWrite,
RecursiveAstVisitor,
Expand Down Expand Up @@ -192,6 +193,11 @@ class Unparser implements AstVisitor {
this._visit(ast.expression);
}

visitTypeofExpresion(ast: TypeofExpression, context: any) {
this._expression += 'typeof ';
this._visit(ast.expression);
}

visitNonNullAssert(ast: NonNullAssert, context: any) {
this._visit(ast.expression);
this._expression += '!';
Expand Down
5 changes: 5 additions & 0 deletions packages/compiler/test/expression_parser/utils/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
LiteralPrimitive,
ParseSpan,
PrefixNot,
TypeofExpression,
PropertyRead,
PropertyWrite,
RecursiveAstVisitor,
Expand Down Expand Up @@ -112,6 +113,10 @@ class ASTValidator extends RecursiveAstVisitor {
this.validate(ast, () => super.visitPrefixNot(ast, context));
}

override visitTypeofExpresion(ast: TypeofExpression, context: any): any {
this.validate(ast, () => super.visitTypeofExpresion(ast, context));
}

override visitPropertyRead(ast: PropertyRead, context: any): any {
this.validate(ast, () => super.visitPropertyRead(ast, context));
}
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler/test/render3/util/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ class ExpressionSourceHumanizer extends e.RecursiveAstVisitor implements t.Visit
this.recordAst(ast);
super.visitPrefixNot(ast, null);
}
override visitTypeofExpresion(ast: e.TypeofExpression) {
this.recordAst(ast);
super.visitTypeofExpresion(ast, null);
}
override visitPropertyRead(ast: e.PropertyRead) {
this.recordAst(ast);
super.visitPropertyRead(ast, null);
Expand Down
24 changes: 24 additions & 0 deletions packages/core/test/acceptance/control_flow_if_spec.ts
A575
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,30 @@ describe('control flow - if', () => {
expect(fixture.nativeElement.textContent).toBe('Something');
});

it('should support a condition with the a typeof expression', () => {
@Component({
standalone: true,
template: `
@if (typeof value === 'string') {
{{value.length}}
} @else {
{{value}}
}
`,
})
class TestComponent {
value: string | number = 'string';
}

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent.trim()).toBe('6');

fixture.componentInstance.value = 42;
fixture.detectChanges();
expect(fixture.nativeElement.textContent.trim()).toBe('42');
});

describe('content projection', () => {
it('should project an @if with a single root node into the root node slot', () => {
@Component({
Expand Down
0