8000 Type checking support for host bindings by crisbeto · Pull Request #60267 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

Type checking support for host bindings #60267

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 8 commits into from
Prev Previous commit
Next Next commit
feat(compiler-cli): support type checking of host bindings
Historically Angular's type checking only extended to templates, however host bindings can contain expressions as well which can have type checking issues of their own. These changes expand the type checking infrastructure to cover the `host` object literal, `@HostBinding` decorators and `@HostListener` with full language service support coming in future commits.

Note that initially the new functionality is disabled by default and has to be enabled using the `typeCheckHostBindings` compiler flag.
  • Loading branch information
crisbeto committed Mar 17, 2025
commit d9d9f5f1242029813d516d4a47306f7677ef9bbc 10000
1 change: 1 addition & 0 deletions goldens/public-api/compiler-cli/compiler_options.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface MiscOptions {
compileNonExportedClasses?: boolean;
disableTypeScriptVersionCheck?: boolean;
forbidOrphanComponents?: boolean;
typeCheckHostBindings?: boolean;
}

// @public
Expand Down
51 changes: 48 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ParseSourceSpan,
SchemaMetadata,
TmplAstElement,
TmplAstHostElement,
} from '@angular/compiler';
import ts from 'typescript';

Expand Down Expand Up @@ -59,22 +60,39 @@ export interface DomSchemaChecker {
/**
* Check a property binding on an element and record any diagnostics about it.
*
* @param id the template ID, suitable for resolution with a `TcbSourceResolver`.
* @param id the type check ID, suitable for resolution with a `TcbSourceResolver`.
* @param element the element node in question.
* @param name the name of the property being checked.
* @param span the source span of the binding. This is redundant with `element.attributes` but is
* passed separately to avoid having to look up the particular property name.
* @param schemas any active schemas for the template, which might affect the validity of the
* property.
*/
checkProperty(
checkTemplateElementProperty(
id: string,
element: TmplAstElement,
name: string,
span: ParseSourceSpan,
schemas: SchemaMetadata[],
hostIsStandalone: boolean,
): void;

/**
* Check a property binding on a host element and record any diagnostics about it.
* @param id the type check ID, suitable for resolution with a `TcbSourceResolver`.
* @param element the element node in question.
* @param name the name of the property being checked.
* @param span the source span of the binding.
* @param schemas any active schemas for the template, which might affect the validity of the
* property.
*/
checkHostElementProperty(
id: string,
element: TmplAstHostElement,
name: string,
span: ParseSourceSpan,
schemas: SchemaMetadata[],
): void;
}

/**
Expand Down Expand Up @@ -129,7 +147,7 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker {
}
}

checkProperty(
checkTemplateElementProperty(
id: TypeCheckId,
element: TmplAstElement,
name: string,
Expand Down Expand Up @@ -171,4 +189,31 @@ export class RegistryDomSchemaChecker implements DomSchemaChecker {
this._diagnostics.push(diag);
}
}

checkHostElementProperty(
id: TypeCheckId,
element: TmplAstHostElement,
name: string,
span: ParseSourceSpan,
schemas: SchemaMetadata[],
): void {
for (const tagName of element.tagNames) {
if (REGISTRY.hasProperty(tagName, name, schemas)) {
continue;
}

const errorMessage = `Can't bind to '${name}' since it isn't a known property of '${tagName}'.`;
const mapping = this.resolver.getHostBindingsMapping(id);
const diag = makeTemplateDiagnostic(
id,
mapping,
span,
ts.DiagnosticCategory.Error,
ngErrorCode(ErrorCode.SCHEMA_INVALID_ATTRIBUTE),
errorMessage,
);
this._diagnostics.push(diag);
break;
}
}
}
50 changes: 50 additions & 0 deletions packages/compiler-cli/src/ngtsc/typecheck/src/host_bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ import ts from 'typescript';
import {createSourceSpan} from '../../annotations/common';
import {ClassDeclaration} from '../../reflection';

/**
* Comment attached to an AST node that serves as a guard to distinguish nodes
* used for type checking host bindings from ones used for templates.
*/
const GUARD_COMMENT_TEXT = 'hostBindingsBlockGuard';

/** Node that represent a static name of a member. */
type StaticName = ts.Identifier | ts.StringLiteralLike;

Expand Down Expand Up @@ -119,6 +125,50 @@ export function createHostElement(
return new TmplAstHostElement(tagNames, bindings, listeners, createSourceSpan(sourceNode.name));
}

/**
* Creates an AST node that can be used as a guard in `if` statements to distinguish TypeScript
* nodes used for checking host bindings from ones used for checking templates.
*/
export function createHostBindingsBlockGuard(): ts.Expression {
// Note that the comment text is quite generic. This doesn't really matter, because it is
// used only inside a TCB and there's no way for users to produce a comment there.
// `true /*hostBindings*/`.
const trueExpr = ts.addSyntheticTrailingComment(
ts.factory.createTrue(),
ts.SyntaxKind.MultiLineCommentTrivia,
GUARD_COMMENT_TEXT,
);
// Wrap the expression in parentheses to ensure that the comment is attached to the correct node.
return ts.factory.createParenthesizedExpression(trueExpr);
}

/**
* Determines if a given node is a guard that indicates that descendant nodes are used to check
* host bindings.
*/
export function isHostBindingsBlockGuard(node: ts.Node): boolean {
if (!ts.isIfStatement(node)) {
return false;
}

// Needs to be kept in sync with `createHostBindingsMarker`.
const expr = node.expression;
if (!ts.isParenthesizedExpression(expr) || expr.expression.kind !== ts.SyntaxKind.TrueKeyword) {
return false;
}

const text = expr.getSourceFile().text;
return (
ts.forEachTrailingCommentRange(
text,
expr.expression.getEnd(),
(pos, end, kind) =>
kind === ts.SyntaxKind.MultiLineCommentTrivia &&
text.substring(pos + 2, end - 2) === GUARD_COMMENT_TEXT,
) || false
);
}

/**
* If possible, creates and tracks the relevant AST node for a binding declared
* through a property on the `host` literal.
Expand Down
34 changes: 29 additions & 5 deletions packages/compiler-cli/src/ngtsc/typecheck/src/tcb_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {FullSourceMapping, SourceLocation, TypeCheckId, SourceMapping} from '../
import {hasIgnoreForDiagnosticsMarker, readSpanComment} from './comments';
import {ReferenceEmitEnvironment} from './reference_emit_environment';
import {TypeParameterEmitter} from './type_parameter_emitter';
import {isHostBindingsBlockGuard} from './host_bindings';

/**
* External modules/identifiers that always should exist for type check
Expand Down Expand Up @@ -129,14 +130,37 @@ export function getSourceMapping(
return null;
}

const mapping = resolver.getTemplateSourceMapping(sourceLocation.id);
if (isInHostBindingTcb(node)) {
const hostSourceMapping = resolver.getHostBindingsMapping(sourceLocation.id);
A36C const span = resolver.toHostParseSourceSpan(sourceLocation.id, sourceLocation.span);
if (span === null) {
return null;
}
return {sourceLocation, sourceMapping: hostSourceMapping, span};
}

const span = resolver.toTemplateParseSourceSpan(sourceLocation.id, sourceLocation.span);
if (span === null) {
return null;
}
// TODO(atscott): Consider adding a context span by walking up from `node` until we get a
// different span.
return {sourceLocation, sourceMapping: mapping, span};
return {
sourceLocation,
sourceMapping: resolver.getTemplateSourceMapping(sourceLocation.id),
span,
};
}

function isInHostBindingTcb(node: ts.Node): boolean {
let current = node;
while (current && !ts.isFunctionDeclaration(current)) {
if (isHostBindingsBlockGuard(current)) {
return true;
}
current = current.parent;
}
return false;
}

export function findTypeCheckBlock(
Expand All @@ -145,7 +169,7 @@ export function findTypeCheckBlock(
isDiagnosticRequest: boolean,
): ts.Node | null {
for (const stmt of file.statements) {
if (ts.isFunctionDeclaration(stmt) && getTemplateId(stmt, file, isDiagnosticRequest) === id) {
if (ts.isFunctionDeclaration(stmt) && getTypeCheckId(stmt, file, isDiagnosticRequest) === id) {
return stmt;
}
}
Expand Down Expand Up @@ -174,7 +198,7 @@ export function findSourceLocation(
if (span !== null) {
// Once the positional information has been extracted, search further up the TCB to extract
// the unique id that is attached with the TCB's function declaration.
const id = getTemplateId(node, sourceFile, isDiagnosticsRequest);
const id = getTypeCheckId(node, sourceFile, isDiagnosticsRequest);
if (id === null) {
return null;
}
Expand All @@ -187,7 +211,7 @@ export function findSourceLocation(
return null;
}

function getTemplateId(
function getTypeCheckId(
node: ts.Node,
sourceFile: ts.SourceFile,
isDiagnosticRequest: boolean,
Expand Down
21 changes: 19 additions & 2 deletions packages/compiler-cli/src/ngtsc/typecheck/src/ts_util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,32 @@ export function tsCastToAny(expr: ts.Expression): ts.Expression {
* Thanks to narrowing of `document.createElement()`, this expression will have its type inferred
* based on the tag name, including for custom elements that have appropriate .d.ts definitions.
*/
export function tsCreateElement(tagName: string): ts.Expression {
export function tsCreateElement(...tagNames: string[]): ts.Expression {
const createElement = ts.factory.createPropertyAccessExpression(
/* expression */ ts.factory.createIdentifier('document'),
'createElement',
);

let arg: ts.Expression;

if (tagNames.length === 1) {
// If there's only one tag name, we can pass it in directly.
arg = ts.factory.createStringLiteral(tagNames[0]);
} else {
// If there's more than one name, we have to generate a union of all the tag names. To do so,
// create an expression in the form of `null! as 'tag-1' | 'tag-2' | 'tag-3'`. This allows
// TypeScript to infer the type as a union of the differnet tags.
const assertedNullExpression = ts.factory.createNonNullExpression(ts.factory.createNull());
const type = ts.factory.createUnionTypeNode(
tagNames.map((tag) => ts.factory.createLiteralTypeNode(ts.factory.createStringLiteral(tag))),
);
arg = ts.factory.createAsExpression(assertedNullExpression, type);
}

return ts.factory.createCallExpression(
/* expression */ createElement,
/* typeArguments */ undefined,
/* argumentsArray */ [ts.factory.createStringLiteral(tagName)],
/* argumentsArray */ [arg],
);
}

Expand Down
Loading
0