diff --git a/packages/compiler-cli/src/ngtsc/program.ts b/packages/compiler-cli/src/ngtsc/program.ts
index f4c59ae89f..c3d0aea40b 100644
--- a/packages/compiler-cli/src/ngtsc/program.ts
+++ b/packages/compiler-cli/src/ngtsc/program.ts
@@ -405,6 +405,13 @@ export class NgtscProgram implements api.Program {
strictNullInputBindings: true,
// Even in full template type-checking mode, DOM binding checks are not quite ready yet.
checkTypeOfDomBindings: false,
+ checkTypeOfOutputEvents: true,
+ checkTypeOfAnimationEvents: true,
+ // Checking of DOM events currently has an adverse effect on developer experience,
+ // e.g. for `` enabling this check results in:
+ // - error TS2531: Object is possibly 'null'.
+ // - error TS2339: Property 'value' does not exist on type 'EventTarget'.
+ checkTypeOfDomEvents: false,
checkTypeOfPipes: true,
strictSafeNavigationTypes: true,
};
@@ -416,6 +423,9 @@ export class NgtscProgram implements api.Program {
checkTypeOfInputBindings: false,
strictNullInputBindings: false,
checkTypeOfDomBindings: false,
+ checkTypeOfOutputEvents: false,
+ checkTypeOfAnimationEvents: false,
+ checkTypeOfDomEvents: false,
checkTypeOfPipes: false,
strictSafeNavigationTypes: false,
};
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts
index 9653d492c1..4c9138d4a3 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts
@@ -107,6 +107,32 @@ export interface TypeCheckingConfig {
*/
checkTypeOfDomBindings: boolean;
+ /**
+ * Whether to infer the type of the `$event` variable in event bindings for directive outputs.
+ *
+ * If this is `true`, the type of `$event` will be inferred based on the generic type of
+ * `EventEmitter`/`Subject` of the output. If set to `false`, the `$event` variable will be of
+ * type `any`.
+ */
+ checkTypeOfOutputEvents: boolean;
+
+ /**
+ * Whether to infer the type of the `$event` variable in event bindings for animations.
+ *
+ * If this is `true`, the type of `$event` will be `AnimationEvent` from `@angular/animations`.
+ * If set to `false`, the `$event` variable will be of type `any`.
+ */
+ checkTypeOfAnimationEvents: boolean;
+
+ /**
+ * Whether to infer the type of the `$event` variable in event bindings to DOM events.
+ *
+ * If this is `true`, the type of `$event` will be inferred based on TypeScript's
+ * `HTMLElementEventMap`, with a fallback to the native `Event` type. If set to `false`, the
+ * `$event` variable will be of type `any`.
+ */
+ checkTypeOfDomEvents: boolean;
+
/**
* Whether to include type information from pipes in the type-checking operation.
*
@@ -186,4 +212,4 @@ export interface ExternalTemplateSourceMapping {
node: ts.Expression;
template: string;
templateUrl: string;
-}
\ No newline at end of file
+}
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts
index 3f6c1a002e..dae298b33f 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/diagnostics.ts
@@ -110,6 +110,8 @@ export function shouldReportDiagnostic(diagnostic: ts.Diagnostic): boolean {
return false;
} else if (code === 2695 /* Left side of comma operator is unused and has no side effects. */) {
return false;
+ } else if (code === 7006 /* Parameter '$event' implicitly has an 'any' type. */) {
+ return false;
}
return true;
}
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts
index e14b8a777c..b6052c6f83 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {DYNAMIC_TYPE, ExpressionType, ExternalExpr, Type} from '@angular/compiler';
+import {ExpressionType, ExternalExpr, ReadVarExpr, Type} from '@angular/compiler';
import * as ts from 'typescript';
import {NOOP_DEFAULT_IMPORT_RECORDER, Reference, ReferenceEmitter} from '../../imports';
@@ -40,6 +40,9 @@ export class Environment {
private pipeInsts = new Map();
protected pipeInstStatements: ts.Statement[] = [];
+ private outputHelperIdent: ts.Identifier|null = null;
+ protected helperStatements: ts.Statement[] = [];
+
constructor(
readonly config: TypeCheckingConfig, protected importManager: ImportManager,
private refEmitter: ReferenceEmitter, protected contextFile: ts.SourceFile) {}
@@ -105,6 +108,92 @@ export class Environment {
return pipeInstId;
}
+ /**
+ * Declares a helper function to be able to cast directive outputs of type `EventEmitter` to
+ * have an accurate `subscribe()` method that properly carries over the generic type `T` into the
+ * listener function passed as argument to `subscribe`. This is done to work around a typing
+ * deficiency in `EventEmitter.subscribe`, where the listener function is typed as any.
+ */
+ declareOutputHelper(): ts.Expression {
+ if (this.outputHelperIdent !== null) {
+ return this.outputHelperIdent;
+ }
+
+ const eventEmitter = this.referenceExternalType(
+ '@angular/core', 'EventEmitter', [new ExpressionType(new ReadVarExpr('T'))]);
+
+ const outputHelperIdent = ts.createIdentifier('_outputHelper');
+ const genericTypeDecl = ts.createTypeParameterDeclaration('T');
+ const genericTypeRef = ts.createTypeReferenceNode('T', /* typeParameters */ undefined);
+
+ // Declare a type that has a `subscribe` method that carries over type `T` as parameter
+ // into the callback. The below code generates the following type literal:
+ // `{subscribe(cb: (event: T) => any): void;}`
+ const observableLike = ts.createTypeLiteralNode([ts.createMethodSignature(
+ /* typeParameters */ undefined,
+ /* parameters */[ts.createParameter(
+ /* decorators */ undefined,
+ /* modifiers */ undefined,
+ /* dotDotDotToken */ undefined,
+ /* name */ 'cb',
+ /* questionToken */ undefined,
+ /* type */ ts.createFunctionTypeNode(
+ /* typeParameters */ undefined,
+ /* parameters */[ts.createParameter(
+ /* decorators */ undefined,
+ /* modifiers */ undefined,
+ /* dotDotDotToken */ undefined,
+ /* name */ 'event',
+ /* questionToken */ undefined,
+ /* type */ genericTypeRef)],
+ /* type */ ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)))],
+ /* type */ ts.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword),
+ /* name */ 'subscribe',
+ /* questionToken */ undefined)]);
+
+ // Declares the first signature of `_outputHelper` that matches arguments of type
+ // `EventEmitter`, to convert them into `observableLike` defined above. The following
+ // statement is generated:
+ // `declare function _outputHelper(output: EventEmitter): observableLike;`
+ this.helperStatements.push(ts.createFunctionDeclaration(
+ /* decorators */ undefined,
+ /* modifiers */[ts.createModifier(ts.SyntaxKind.DeclareKeyword)],
+ /* asteriskToken */ undefined,
+ /* name */ outputHelperIdent,
+ /* typeParameters */[genericTypeDecl],
+ /* parameters */[ts.createParameter(
+ /* decorators */ undefined,
+ /* modifiers */ undefined,
+ /* dotDotDotToken */ undefined,
+ /* name */ 'output',
+ /* questionToken */ undefined,
+ /* type */ eventEmitter)],
+ /* type */ observableLike,
+ /* body */ undefined));
+
+ // Declares the second signature of `_outputHelper` that matches all other argument types,
+ // i.e. ensures type identity for output types other than `EventEmitter`. This corresponds
+ // with the following statement:
+ // `declare function _outputHelper(output: T): T;`
+ this.helperStatements.push(ts.createFunctionDeclaration(
+ /* decorators */ undefined,
+ /* modifiers */[ts.createModifier(ts.SyntaxKind.DeclareKeyword)],
+ /* asteriskToken */ undefined,
+ /* name */ outputHelperIdent,
+ /* typeParameters */[genericTypeDecl],
+ /* parameters */[ts.createParameter(
+ /* decorators */ undefined,
+ /* modifiers */ undefined,
+ /* dotDotDotToken */ undefined,
+ /* name */ 'output',
+ /* questionToken */ undefined,
+ /* type */ genericTypeRef)],
+ /* type */ genericTypeRef,
+ /* body */ undefined));
+
+ return this.outputHelperIdent = outputHelperIdent;
+ }
+
/**
* Generate a `ts.Expression` that references the given node.
*
@@ -131,28 +220,19 @@ export class Environment {
}
/**
- * Generate a `ts.TypeNode` that references a given type from '@angular/core'.
+ * Generate a `ts.TypeNode` that references a given type from the provided module.
*
- * This will involve importing the type into the file, and will also add a number of generic type
- * parameters (using `any`) as requested.
+ * This will involve importing the type into the file, and will also add type parameters if
+ * provided.
*/
- referenceCoreType(name: string, typeParamCount: number = 0): ts.TypeNode {
- const external = new ExternalExpr({
- moduleName: '@angular/core',
- name,
- });
- let typeParams: Type[]|null = null;
- if (typeParamCount > 0) {
- typeParams = [];
- for (let i = 0; i < typeParamCount; i++) {
- typeParams.push(DYNAMIC_TYPE);
- }
- }
+ referenceExternalType(moduleName: string, name: string, typeParams?: Type[]): ts.TypeNode {
+ const external = new ExternalExpr({moduleName, name});
return translateType(new ExpressionType(external, null, typeParams), this.importManager);
}
getPreludeStatements(): ts.Statement[] {
return [
+ ...this.helperStatements,
...this.pipeInstStatements,
...this.typeCtorStatements,
];
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
index 1f98c37cd2..7b42cef1f3 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
@@ -86,7 +86,12 @@ class AstTranslator implements AstVisitor {
return node;
}
- visitChain(ast: Chain): never { throw new Error('Method not implemented.'); }
+ visitChain(ast: Chain): ts.Expression {
+ const elements = ast.expressions.map(expr => this.translate(expr));
+ const node = wrapForDiagnostics(ts.createCommaList(elements));
+ addParseSpanInfo(node, this.translateSpan(ast.span));
+ return node;
+ }
visitConditional(ast: Conditional): ts.Expression {
const condExpr = this.translate(ast.condition);
@@ -97,7 +102,13 @@ class AstTranslator implements AstVisitor {
return node;
}
- visitFunctionCall(ast: FunctionCall): never { throw new Error('Method not implemented.'); }
+ visitFunctionCall(ast: FunctionCall): ts.Expression {
+ const receiver = wrapForDiagnostics(this.translate(ast.target !));
+ const args = ast.args.map(expr => this.translate(expr));
+ const node = ts.createCall(receiver, undefined, args);
+ addParseSpanInfo(node, this.translateSpan(ast.span));
+ return node;
+ }
visitImplicitReceiver(ast: ImplicitReceiver): never {
throw new Error('Method not implemented.');
@@ -120,7 +131,16 @@ class AstTranslator implements AstVisitor {
return node;
}
- visitKeyedWrite(ast: KeyedWrite): never { throw new Error('Method not implemented.'); }
+ visitKeyedWrite(ast: KeyedWrite): ts.Expression {
+ const receiver = wrapForDiagnostics(this.translate(ast.obj));
+ const left = ts.createElementAccess(receiver, this.translate(ast.key));
+ // TODO(joost): annotate `left` with the span of the element access, which is not currently
+ // available on `ast`.
+ const right = this.translate(ast.value);
+ const node = wrapForDiagnostics(ts.createBinary(left, ts.SyntaxKind.EqualsToken, right));
+ addParseSpanInfo(node, this.translateSpan(ast.span));
+ return node;
+ }
visitLiteralArray(ast: LiteralArray): ts.Expression {
const elements = ast.expressions.map(expr => this.translate(expr));
@@ -186,7 +206,16 @@ class AstTranslator implements AstVisitor {
return node;
}
- visitPropertyWrite(ast: PropertyWrite): never { throw new Error('Method not implemented.'); }
+ visitPropertyWrite(ast: PropertyWrite): ts.Expression {
+ const receiver = wrapForDiagnostics(this.translate(ast.receiver));
+ const left = ts.createPropertyAccess(receiver, ast.name);
+ // TODO(joost): annotate `left` with the span of the property access, which is not currently
+ // available on `ast`.
+ const right = this.translate(ast.value);
+ const node = wrapForDiagnostics(ts.createBinary(left, ts.SyntaxKind.EqualsToken, right));
+ addParseSpanInfo(node, this.translateSpan(ast.span));
+ return node;
+ }
visitQuote(ast: Quote): never { throw new Error('Method not implemented.'); }
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
index ed37665032..49484e9a20 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {AST, BindingPipe, BindingType, BoundTarget, ImplicitReceiver, MethodCall, ParseSourceSpan, ParseSpan, PropertyRead, SchemaMetadata, TmplAstBoundAttribute, TmplAstBoundText, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
+import {AST, BindingPipe, BindingType, BoundTarget, DYNAMIC_TYPE, ImplicitReceiver, MethodCall, ParseSourceSpan, ParseSpan, ParsedEventType, PropertyRead, SchemaMetadata, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstBoundText, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
import * as ts from 'typescript';
import {Reference} from '../../imports';
@@ -421,6 +421,127 @@ class TcbUnclaimedInputsOp extends TcbOp {
}
}
+/**
+ * A `TcbOp` which generates code to check event bindings on an element that correspond with the
+ * outputs of a directive.
+ *
+ * Executing this operation returns nothing.
+ */
+class TcbDirectiveOutputsOp extends TcbOp {
+ constructor(
+ private tcb: Context, private scope: Scope, private node: TmplAstTemplate|TmplAstElement,
+ private dir: TypeCheckableDirectiveMeta) {
+ super();
+ }
+
+ execute(): null {
+ const dirId = this.scope.resolve(this.node, this.dir);
+
+ // `dir.outputs` is an object map of field names on the directive class to event names.
+ // This is backwards from what's needed to match event handlers - a map of event names to field
+ // names is desired. Invert `dir.outputs` into `fieldByEventName` to create this map.
+ const fieldByEventName = new Map();
+ const outputs = this.dir.outputs;
+ for (const key of Object.keys(outputs)) {
+ fieldByEventName.set(outputs[key], key);
+ }
+
+ for (const output of this.node.outputs) {
+ if (output.type !== ParsedEventType.Regular || !fieldByEventName.has(output.name)) {
+ continue;
+ }
+ const field = fieldByEventName.get(output.name) !;
+
+ if (this.tcb.env.config.checkTypeOfOutputEvents) {
+ // For strict checking of directive events, generate a call to the `subscribe` method
+ // on the directive's output field to let type information flow into the handler function's
+ // `$event` parameter.
+ //
+ // Note that the `EventEmitter` type from '@angular/core' that is typically used for
+ // outputs has a typings deficiency in its `subscribe` method. The generic type `T` is not
+ // carried into the handler function, which is vital for inference of the type of `$event`.
+ // As a workaround, the directive's field is passed into a helper function that has a
+ // specially crafted set of signatures, to effectively cast `EventEmitter` to something
+ // that has a `subscribe` method that properly carries the `T` into the handler function.
+ const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer);
+
+ const outputField = ts.createPropertyAccess(dirId, field);
+ const outputHelper =
+ ts.createCall(this.tcb.env.declareOutputHelper(), undefined, [outputField]);
+ const subscribeFn = ts.createPropertyAccess(outputHelper, 'subscribe');
+ const call = ts.createCall(subscribeFn, /* typeArguments */ undefined, [handler]);
+ addParseSpanInfo(call, output.sourceSpan);
+ this.scope.addStatement(ts.createExpressionStatement(call));
+ } else {
+ // If strict checking of directive events is disabled, emit a handler function where the
+ // `$event` parameter has an explicit `any` type.
+ const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any);
+ this.scope.addStatement(ts.createExpressionStatement(handler));
+ }
+ }
+
+ return null;
+ }
+}
+
+/**
+ * A `TcbOp` which generates code to check "unclaimed outputs" - event bindings on an element which
+ * were not attributed to any directive or component, and are instead processed against the HTML
+ * element itself.
+ *
+ * Executing this operation returns nothing.
+ */
+class TcbUnclaimedOutputsOp extends TcbOp {
+ constructor(
+ private tcb: Context, private scope: Scope, private element: TmplAstElement,
+ private claimedOutputs: Set) {
+ super();
+ }
+
+ execute(): null {
+ const elId = this.scope.resolve(this.element);
+
+ // TODO(alxhub): this could be more efficient.
+ for (const output of this.element.outputs) {
+ if (this.claimedOutputs.has(output.name)) {
+ // Skip this event handler as it was claimed by a directive.
+ continue;
+ }
+
+ if (output.type === ParsedEventType.Animation) {
+ // Animation output bindings always have an `$event` parameter of type `AnimationEvent`.
+ const eventType = this.tcb.env.config.checkTypeOfAnimationEvents ?
+ this.tcb.env.referenceExternalType('@angular/animations', 'AnimationEvent') :
+ EventParamType.Any;
+
+ const handler = tcbCreateEventHandler(output, this.tcb, this.scope, eventType);
+ this.scope.addStatement(ts.createExpressionStatement(handler));
+ } else if (this.tcb.env.config.checkTypeOfDomEvents) {
+ // If strict checking of DOM events is enabled, generate a call to `addEventListener` on
+ // the element instance so that TypeScript's type inference for
+ // `HTMLElement.addEventListener` using `HTMLElementEventMap` to infer an accurate type for
+ // `$event` depending on the event name. For unknown event names, TypeScript resorts to the
+ // base `Event` type.
+ const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer);
+
+ const call = ts.createCall(
+ /* expression */ ts.createPropertyAccess(elId, 'addEventListener'),
+ /* typeArguments */ undefined,
+ /* arguments */[ts.createStringLiteral(output.name), handler]);
+ addParseSpanInfo(call, output.sourceSpan);
+ this.scope.addStatement(ts.createExpressionStatement(call));
+ } else {
+ // If strict checking of DOM inputs is disabled, emit a handler function where the `$event`
+ // parameter has an explicit `any` type.
+ const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Any);
+ this.scope.addStatement(ts.createExpressionStatement(handler));
+ }
+ }
+
+ return null;
+ }
+}
+
/**
* Value used to break a circular reference between `TcbOp`s.
*
@@ -672,12 +793,14 @@ class Scope {
const opIndex = this.opQueue.push(new TcbElementOp(this.tcb, this, node)) - 1;
this.elementOpMap.set(node, opIndex);
this.appendDirectivesAndInputsOfNode(node);
+ this.appendOutputsOfNode(node);
for (const child of node.children) {
this.appendNode(child);
}
} else if (node instanceof TmplAstTemplate) {
// Template children are rendered in a child scope.
this.appendDirectivesAndInputsOfNode(node);
+ this.appendOutputsOfNode(node);
if (this.tcb.env.config.checkTemplateBodies) {
const ctxIndex = this.opQueue.push(new TcbTemplateContextOp(this.tcb, this)) - 1;
this.templateCtxOpMap.set(node, ctxIndex);
@@ -730,6 +853,38 @@ class Scope {
this.opQueue.push(new TcbDomSchemaCheckerOp(this.tcb, node, checkElement, claimedInputs));
}
}
+
+ private appendOutputsOfNode(node: TmplAstElement|TmplAstTemplate): void {
+ // Collect all the outputs on the element.
+ const claimedOutputs = new Set();
+ const directives = this.tcb.boundTarget.getDirectivesOfNode(node);
+ if (directives === null || directives.length === 0) {
+ // If there are no directives, then all outputs are unclaimed outputs, so queue an operation
+ // to add them if needed.
+ if (node instanceof TmplAstElement) {
+ this.opQueue.push(new TcbUnclaimedOutputsOp(this.tcb, this, node, claimedOutputs));
+ }
+ return;
+ }
+
+ // Queue operations for all directives to check the relevant outputs for a directive.
+ for (const dir of directives) {
+ this.opQueue.push(new TcbDirectiveOutputsOp(this.tcb, this, node, dir));
+ }
+
+ // After expanding the directives, we might need to queue an operation to check any unclaimed
+ // outputs.
+ if (node instanceof TmplAstElement) {
+ // Go through the directives and register any outputs that it claims in `claimedOutputs`.
+ for (const dir of directives) {
+ for (const outputField of Object.keys(dir.outputs)) {
+ claimedOutputs.add(dir.outputs[outputField]);
+ }
+ }
+
+ this.opQueue.push(new TcbUnclaimedOutputsOp(this.tcb, this, node, claimedOutputs));
+ }
+ }
}
/**
@@ -763,13 +918,122 @@ function tcbCtxParam(
*/
function tcbExpression(
ast: AST, tcb: Context, scope: Scope, sourceSpan: ParseSourceSpan): ts.Expression {
- const translateSpan = (span: ParseSpan) => toAbsoluteSpan(span, sourceSpan);
+ const translator = new TcbExpressionTranslator(tcb, scope, sourceSpan);
+ return translator.translate(ast);
+}
- // `astToTypescript` actually does the conversion. A special resolver `tcbResolve` is passed which
- // interprets specific expression nodes that interact with the `ImplicitReceiver`. These nodes
- // actually refer to identifiers within the current scope.
- return astToTypescript(
- ast, (ast) => tcbResolve(ast, tcb, scope, sourceSpan), tcb.env.config, translateSpan);
+class TcbExpressionTranslator {
+ constructor(
+ protected tcb: Context, protected scope: Scope, protected sourceSpan: ParseSourceSpan) {}
+
+ translate(ast: AST): ts.Expression {
+ // `astToTypescript` actually does the conversion. A special resolver `tcbResolve` is passed
+ // which interprets specific expression nodes that interact with the `ImplicitReceiver`. These
+ // nodes actually refer to identifiers within the current scope.
+ return astToTypescript(
+ ast, ast => this.resolve(ast), this.tcb.env.config,
+ (span: ParseSpan) => toAbsoluteSpan(span, this.sourceSpan));
+ }
+
+ /**
+ * Resolve an `AST` expression within the given scope.
+ *
+ * Some `AST` expressions refer to top-level concepts (references, variables, the component
+ * context). This method assists in resolving those.
+ */
+ protected resolve(ast: AST): ts.Expression|null {
+ if (ast instanceof PropertyRead && ast.receiver instanceof ImplicitReceiver) {
+ // Check whether the template metadata has bound a target for this expression. If so, then
+ // resolve that target. If not, then the expression is referencing the top-level component
+ // context.
+ const binding = this.tcb.boundTarget.getExpressionTarget(ast);
+ if (binding !== null) {
+ // This expression has a binding to some variable or reference in the template. Resolve it.
+ if (binding instanceof TmplAstVariable) {
+ const expr = ts.getMutableClone(this.scope.resolve(binding));
+ addParseSpanInfo(expr, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return expr;
+ } else if (binding instanceof TmplAstReference) {
+ const target = this.tcb.boundTarget.getReferenceTarget(binding);
+ if (target === null) {
+ throw new Error(`Unbound reference? ${binding.name}`);
+ }
+
+ // The reference is either to an element, an node, or to a directive on an
+ // element or template.
+
+ if (target instanceof TmplAstElement) {
+ const expr = ts.getMutableClone(this.scope.resolve(target));
+ addParseSpanInfo(expr, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return expr;
+ } else if (target instanceof TmplAstTemplate) {
+ // Direct references to an node simply require a value of type
+ // `TemplateRef`. To get this, an expression of the form
+ // `(null as any as TemplateRef)` is constructed.
+ let value: ts.Expression = ts.createNull();
+ value =
+ ts.createAsExpression(value, ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
+ value = ts.createAsExpression(
+ value,
+ this.tcb.env.referenceExternalType('@angular/core', 'TemplateRef', [DYNAMIC_TYPE]));
+ value = ts.createParen(value);
+ addParseSpanInfo(value, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return value;
+ } else {
+ const expr = ts.getMutableClone(this.scope.resolve(target.node, target.directive));
+ addParseSpanInfo(expr, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return expr;
+ }
+ } else {
+ throw new Error(`Unreachable: ${binding}`);
+ }
+ } else {
+ // This is a PropertyRead(ImplicitReceiver) and probably refers to a property access on the
+ // component context. Let it fall through resolution here so it will be caught when the
+ // ImplicitReceiver is resolved in the branch below.
+ return null;
+ }
+ } else if (ast instanceof ImplicitReceiver) {
+ // AST instances representing variables and references look very similar to property reads
+ // from the component context: both have the shape
+ // PropertyRead(ImplicitReceiver, 'propertyName').
+ //
+ // `tcbExpression` will first try to `tcbResolve` the outer PropertyRead. If this works, it's
+ // because the `BoundTarget` found an expression target for the whole expression, and
+ // therefore `tcbExpression` will never attempt to `tcbResolve` the ImplicitReceiver of that
+ // PropertyRead.
+ //
+ // Therefore if `tcbResolve` is called on an `ImplicitReceiver`, it's because no outer
+ // PropertyRead resolved to a variable or reference, and therefore this is a property read on
+ // the component context itself.
+ return ts.createIdentifier('ctx');
+ } else if (ast instanceof BindingPipe) {
+ const expr = this.translate(ast.exp);
+ let pipe: ts.Expression;
+ if (this.tcb.env.config.checkTypeOfPipes) {
+ pipe = this.tcb.getPipeByName(ast.name);
+ } else {
+ pipe = ts.createParen(ts.createAsExpression(
+ ts.createNull(), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)));
+ }
+ const args = ast.args.map(arg => this.translate(arg));
+ const result = tsCallMethod(pipe, 'transform', [expr, ...args]);
+ addParseSpanInfo(result, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return result;
+ } else if (
+ ast instanceof MethodCall && ast.receiver instanceof ImplicitReceiver &&
+ ast.name === '$any' && ast.args.length === 1) {
+ const expr = this.translate(ast.args[0]);
+ const exprAsAny =
+ ts.createAsExpression(expr, ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
+ const result = ts.createParen(exprAsAny);
+ addParseSpanInfo(result, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return result;
+ } else {
+ // This AST isn't special after all.
+ return null;
+ }
+ }
}
/**
@@ -889,99 +1153,82 @@ function tcbGetDirectiveInputs(
}
}
+const EVENT_PARAMETER = '$event';
+
+const enum EventParamType {
+ /* Generates code to infer the type of `$event` based on how the listener is registered. */
+ Infer,
+
+ /* Declares the type of the `$event` parameter as `any`. */
+ Any,
+}
+
/**
- * Resolve an `AST` expression within the given scope.
+ * Creates an arrow function to be used as handler function for event bindings. The handler
+ * function has a single parameter `$event` and the bound event's handler `AST` represented as a
+ * TypeScript expression as its body.
*
- * Some `AST` expressions refer to top-level concepts (references, variables, the component
- * context). This method assists in resolving those.
+ * When `eventType` is set to `Infer`, the `$event` parameter will not have an explicit type. This
+ * allows for the created handler function to have its `$event` parameter's type inferred based on
+ * how it's used, to enable strict type checking of event bindings. When set to `Any`, the `$event`
+ * parameter will have an explicit `any` type, effectively disabling strict type checking of event
+ * bindings. Alternatively, an explicit type can be passed for the `$event` parameter.
*/
-function tcbResolve(
- ast: AST, tcb: Context, scope: Scope, sourceSpan: ParseSourceSpan): ts.Expression|null {
- if (ast instanceof PropertyRead && ast.receiver instanceof ImplicitReceiver) {
- // Check whether the template metadata has bound a target for this expression. If so, then
- // resolve that target. If not, then the expression is referencing the top-level component
- // context.
- const binding = tcb.boundTarget.getExpressionTarget(ast);
- if (binding !== null) {
- // This expression has a binding to some variable or reference in the template. Resolve it.
- if (binding instanceof TmplAstVariable) {
- const expr = ts.getMutableClone(scope.resolve(binding));
- addParseSpanInfo(expr, toAbsoluteSpan(ast.span, sourceSpan));
- return expr;
- } else if (binding instanceof TmplAstReference) {
- const target = tcb.boundTarget.getReferenceTarget(binding);
- if (target === null) {
- throw new Error(`Unbound reference? ${binding.name}`);
- }
+function tcbCreateEventHandler(
+ event: TmplAstBoundEvent, tcb: Context, scope: Scope,
+ eventType: EventParamType | ts.TypeNode): ts.ArrowFunction {
+ const handler = tcbEventHandlerExpression(event.handler, tcb, scope, event.handlerSpan);
- // The reference is either to an element, an node, or to a directive on an
- // element or template.
-
- if (target instanceof TmplAstElement) {
- const expr = ts.getMutableClone(scope.resolve(target));
- addParseSpanInfo(expr, toAbsoluteSpan(ast.span, sourceSpan));
- return expr;
- } else if (target instanceof TmplAstTemplate) {
- // Direct references to an node simply require a value of type
- // `TemplateRef`. To get this, an expression of the form
- // `(null as any as TemplateRef)` is constructed.
- let value: ts.Expression = ts.createNull();
- value = ts.createAsExpression(value, ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
- value = ts.createAsExpression(value, tcb.env.referenceCoreType('TemplateRef', 1));
- value = ts.createParen(value);
- addParseSpanInfo(value, toAbsoluteSpan(ast.span, sourceSpan));
- return value;
- } else {
- const expr = ts.getMutableClone(scope.resolve(target.node, target.directive));
- addParseSpanInfo(expr, toAbsoluteSpan(ast.span, sourceSpan));
- return expr;
- }
- } else {
- throw new Error(`Unreachable: ${binding}`);
- }
- } else {
- // This is a PropertyRead(ImplicitReceiver) and probably refers to a property access on the
- // component context. Let it fall through resolution here so it will be caught when the
- // ImplicitReceiver is resolved in the branch below.
- return null;
- }
- } else if (ast instanceof ImplicitReceiver) {
- // AST instances representing variables and references look very similar to property reads from
- // the component context: both have the shape PropertyRead(ImplicitReceiver, 'propertyName').
- //
- // `tcbExpression` will first try to `tcbResolve` the outer PropertyRead. If this works, it's
- // because the `BoundTarget` found an expression target for the whole expression, and therefore
- // `tcbExpression` will never attempt to `tcbResolve` the ImplicitReceiver of that PropertyRead.
- //
- // Therefore if `tcbResolve` is called on an `ImplicitReceiver`, it's because no outer
- // PropertyRead resolved to a variable or reference, and therefore this is a property read on
- // the component context itself.
- return ts.createIdentifier('ctx');
- } else if (ast instanceof BindingPipe) {
- const expr = tcbExpression(ast.exp, tcb, scope, sourceSpan);
- let pipe: ts.Expression;
- if (tcb.env.config.checkTypeOfPipes) {
- pipe = tcb.getPipeByName(ast.name);
- } else {
- pipe = ts.createParen(ts.createAsExpression(
- ts.createNull(), ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)));
- }
- const args = ast.args.map(arg => tcbExpression(arg, tcb, scope, sourceSpan));
- const result = tsCallMethod(pipe, 'transform', [expr, ...args]);
- addParseSpanInfo(result, toAbsoluteSpan(ast.span, sourceSpan));
- return result;
- } else if (
- ast instanceof MethodCall && ast.receiver instanceof ImplicitReceiver &&
- ast.name === '$any' && ast.args.length === 1) {
- const expr = tcbExpression(ast.args[0], tcb, scope, sourceSpan);
- const exprAsAny =
- ts.createAsExpression(expr, ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
- const result = ts.createParen(exprAsAny);
- addParseSpanInfo(result, toAbsoluteSpan(ast.span, sourceSpan));
- return result;
+ let eventParamType: ts.TypeNode|undefined;
+ if (eventType === EventParamType.Infer) {
+ eventParamType = undefined;
+ } else if (eventType === EventParamType.Any) {
+ eventParamType = ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword);
} else {
- // This AST isn't special after all.
- return null;
+ eventParamType = eventType;
+ }
+
+ const eventParam = ts.createParameter(
+ /* decorators */ undefined,
+ /* modifiers */ undefined,
+ /* dotDotDotToken */ undefined,
+ /* name */ EVENT_PARAMETER,
+ /* questionToken */ undefined,
+ /* type */ eventParamType);
+ return ts.createArrowFunction(
+ /* modifier */ undefined,
+ /* typeParameters */ undefined,
+ /* parameters */[eventParam],
+ /* type */ undefined,
+ /* equalsGreaterThanToken*/ undefined,
+ /* body */ handler);
+}
+
+/**
+ * Similar to `tcbExpression`, this function converts the provided `AST` expression into a
+ * `ts.Expression`, with special handling of the `$event` variable that can be used within event
+ * bindings.
+ */
+function tcbEventHandlerExpression(
+ ast: AST, tcb: Context, scope: Scope, sourceSpan: ParseSourceSpan): ts.Expression {
+ const translator = new TcbEventHandlerTranslator(tcb, scope, sourceSpan);
+ return translator.translate(ast);
+}
+
+class TcbEventHandlerTranslator extends TcbExpressionTranslator {
+ protected resolve(ast: AST): ts.Expression|null {
+ // Recognize a property read on the implicit receiver corresponding with the event parameter
+ // that is available in event bindings. Since this variable is a parameter of the handler
+ // function that the converted expression becomes a child of, just create a reference to the
+ // parameter by its name.
+ if (ast instanceof PropertyRead && ast.receiver instanceof ImplicitReceiver &&
+ ast.name === EVENT_PARAMETER) {
+ const event = ts.createIdentifier(EVENT_PARAMETER);
+ addParseSpanInfo(event, toAbsoluteSpan(ast.span, this.sourceSpan));
+ return event;
+ }
+
+ return super.resolve(ast);
}
}
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts
index a2ee6c787b..5875911fd2 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_check_file.ts
@@ -51,6 +51,9 @@ export class TypeCheckFile extends Environment {
'\n\n';
const printer = ts.createPrinter();
source += '\n';
+ for (const stmt of this.helperStatements) {
+ source += printer.printNode(ts.EmitHint.Unspecified, stmt, this.contextFile) + '\n';
+ }
for (const stmt of this.pipeInstStatements) {
source += printer.printNode(ts.EmitHint.Unspecified, stmt, this.contextFile) + '\n';
}
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts
index cc3ae3584e..8d0fddd32a 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/diagnostics_spec.ts
@@ -185,6 +185,62 @@ runInEachFileSystem(() => {
expect(messages).toEqual([]);
});
+ describe('outputs', () => {
+ it('should produce a diagnostic for directive outputs', () => {
+ const messages = diagnose(
+ ``, `
+ import {EventEmitter} from '@angular/core';
+ class Dir {
+ out = new EventEmitter();
+ }
+ class TestComponent {
+ handleEvent(event: string): void {}
+ }`,
+ [{type: 'directive', name: 'Dir', selector: '[dir]', outputs: {'out': 'event'}}]);
+
+ expect(messages).toEqual([
+ `synthetic.html(1, 31): Argument of type 'number' is not assignable to parameter of type 'string'.`,
+ ]);
+ });
+
+ it('should produce a diagnostic for animation events', () => {
+ const messages = diagnose(``, `
+ class TestComponent {
+ handleEvent(event: string): void {}
+ }`);
+
+ expect(messages).toEqual([
+ `synthetic.html(1, 41): Argument of type 'AnimationEvent' is not assignable to parameter of type 'string'.`,
+ ]);
+ });
+
+ it('should produce a diagnostic for element outputs', () => {
+ const messages = diagnose(``, `
+ import {EventEmitter} from '@angular/core';
+ class TestComponent {
+ handleEvent(event: string): void {}
+ }`);
+
+ expect(messages).toEqual([
+ `synthetic.html(1, 27): Argument of type 'MouseEvent' is not assignable to parameter of type 'string'.`,
+ ]);
+ });
+
+ it('should not produce a diagnostic when $event implicitly has an any type', () => {
+ const messages = diagnose(
+ ``, `
+ class Dir {
+ out: any;
+ }
+ class TestComponent {
+ handleEvent(event: string): void {}
+ }`,
+ [{type: 'directive', name: 'Dir', selector: '[dir]', outputs: {'out': 'event'}}]);
+
+ expect(messages).toEqual([]);
+ });
+ });
+
describe('strict null checks', () => {
it('produces diagnostic for unchecked property access', () => {
const messages =
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts
index 863f37d2e6..28d843dcce 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/span_comments_spec.ts
@@ -59,16 +59,35 @@ describe('type check blocks diagnostics', () => {
.toContain('(ctx).method((ctx).a /*10,11*/, (ctx).b /*13,14*/) /*3,16*/;');
});
+ it('should annotate function calls', () => {
+ const TEMPLATE = `{{ method(a)(b, c) }}`;
+ expect(tcbWithSpans(TEMPLATE))
+ .toContain(
+ '((ctx).method((ctx).a /*10,11*/) /*3,12*/)((ctx).b /*13,14*/, (ctx).c /*16,17*/) /*3,19*/;');
+ });
+
it('should annotate property access', () => {
const TEMPLATE = `{{ a.b.c }}`;
expect(tcbWithSpans(TEMPLATE)).toContain('(((ctx).a /*3,4*/).b /*3,6*/).c /*3,9*/;');
});
+ it('should annotate property writes', () => {
+ const TEMPLATE = ``;
+ expect(tcbWithSpans(TEMPLATE))
+ .toContain('((((ctx).a /*14,15*/).b /*14,17*/).c = (ctx).d /*22,23*/) /*14,23*/');
+ });
+
it('should annotate keyed property access', () => {
const TEMPLATE = `{{ a[b] }}`;
expect(tcbWithSpans(TEMPLATE)).toContain('((ctx).a /*3,4*/)[(ctx).b /*5,6*/] /*3,8*/;');
});
+ it('should annotate keyed property writes', () => {
+ const TEMPLATE = ``;
+ expect(tcbWithSpans(TEMPLATE))
+ .toContain('(((ctx).a /*14,15*/)[(ctx).b /*16,17*/] = (ctx).c /*21,22*/) /*14,22*/');
+ });
+
it('should annotate safe property access', () => {
const TEMPLATE = `{{ a?.b }}`;
expect(tcbWithSpans(TEMPLATE))
@@ -87,6 +106,12 @@ describe('type check blocks diagnostics', () => {
expect(tcbWithSpans(TEMPLATE)).toContain('((ctx).a /*8,9*/ as any) /*3,11*/;');
});
+ it('should annotate chained expressions', () => {
+ const TEMPLATE = ``;
+ expect(tcbWithSpans(TEMPLATE))
+ .toContain('((ctx).a /*14,15*/, (ctx).b /*17,18*/, (ctx).c /*20,21*/) /*14,21*/');
+ });
+
it('should annotate pipe usages', () => {
const TEMPLATE = `{{ a | test:b }}`;
const PIPES: TestDeclaration[] = [{
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
index 7d8281d5fd..9d443eb2bd 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
@@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {CssSelector, ParseSourceFile, ParseSourceSpan, R3TargetBinder, SchemaMetadata, SelectorMatcher, TmplAstElement, parseTemplate} from '@angular/compiler';
+import {CssSelector, ParseSourceFile, ParseSourceSpan, R3TargetBinder, SchemaMetadata, SelectorMatcher, TmplAstElement, Type, parseTemplate} from '@angular/compiler';
import * as ts from 'typescript';
import {AbsoluteFsPath, LogicalFileSystem, absoluteFrom} from '../../file_system';
@@ -40,7 +40,21 @@ export function typescriptLibDts(): TestFile {
length: number;
}
- declare interface HTMLElement {}
+ declare interface Event {
+ preventDefault(): void;
+ }
+ declare interface MouseEvent extends Event {
+ readonly x: number;
+ readonly y: number;
+ }
+
+ declare interface HTMLElementEventMap {
+ "click": MouseEvent;
+ }
+ declare interface HTMLElement {
+ addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any): void;
+ addEventListener(type: string, listener: (evt: Event): void;): void;
+ }
declare interface HTMLDivElement extends HTMLElement {}
declare interface HTMLImageElement extends HTMLElement {
src: string;
@@ -73,6 +87,21 @@ export function angularCoreDts(): TestFile {
abstract readonly elementRef: unknown;
abstract createEmbeddedView(context: C): unknown;
}
+
+ export declare class EventEmitter {
+ subscribe(generatorOrNext?: any, error?: any, complete?: any): unknown;
+ }
+ `
+ };
+}
+
+export function angularAnimationsDts(): TestFile {
+ return {
+ name: absoluteFrom('/node_modules/@angular/animations/index.d.ts'),
+ contents: `
+ export declare class AnimationEvent {
+ element: any;
+ }
`
};
}
@@ -123,6 +152,9 @@ export const ALL_ENABLED_CONFIG: TypeCheckingConfig = {
// Feature is still in development.
// TODO(alxhub): enable when DOM checking via lib.dom.d.ts is further along.
checkTypeOfDomBindings: false,
+ checkTypeOfOutputEvents: true,
+ checkTypeOfAnimationEvents: true,
+ checkTypeOfDomEvents: true,
checkTypeOfPipes: true,
strictSafeNavigationTypes: true,
};
@@ -163,6 +195,9 @@ export function tcb(
checkTypeOfInputBindings: true,
strictNullInputBindings: true,
checkTypeOfDomBindings: false,
+ checkTypeOfOutputEvents: true,
+ checkTypeOfAnimationEvents: true,
+ checkTypeOfDomEvents: true,
checkTypeOfPipes: true,
checkTemplateBodies: true,
strictSafeNavigationTypes: true,
@@ -187,13 +222,15 @@ export function typecheck(
const files = [
typescriptLibDts(),
angularCoreDts(),
+ angularAnimationsDts(),
// Add the typecheck file to the program, as the typecheck program is created with the
// assumption that the typecheck file was already a root file in the original program.
{name: typeCheckFilePath, contents: 'export const TYPECHECK = true;'},
{name: absoluteFrom('/main.ts'), contents: source},
...additionalSources,
];
- const {program, host, options} = makeProgram(files, {strictNullChecks: true}, undefined, false);
+ const {program, host, options} =
+ makeProgram(files, {strictNullChecks: true, noImplicitAny: true}, undefined, false);
const sf = program.getSourceFile(absoluteFrom('/main.ts')) !;
const checker = program.getTypeChecker();
const logicalFs = new LogicalFileSystem(getRootDirs(host, options));
@@ -294,6 +331,8 @@ class FakeEnvironment /* implements Environment */ {
return ts.createParen(ts.createAsExpression(ts.createNull(), this.referenceType(ref)));
}
+ declareOutputHelper(): ts.Expression { return ts.createIdentifier('_outputHelper'); }
+
reference(ref: Reference>): ts.Expression {
return ref.node.name;
}
@@ -302,14 +341,17 @@ class FakeEnvironment /* implements Environment */ {
return ts.createTypeReferenceNode(ref.node.name, /* typeArguments */ undefined);
}
- referenceCoreType(name: string, typeParamCount: number = 0): ts.TypeNode {
+ referenceExternalType(moduleName: string, name: string, typeParams?: Type[]): ts.TypeNode {
const typeArgs: ts.TypeNode[] = [];
- for (let i = 0; i < typeParamCount; i++) {
- typeArgs.push(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
+ if (typeParams !== undefined) {
+ for (let i = 0; i < typeParams.length; i++) {
+ typeArgs.push(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword));
+ }
}
- const qName = ts.createQualifiedName(ts.createIdentifier('ng'), name);
- return ts.createTypeReferenceNode(qName, typeParamCount > 0 ? typeArgs : undefined);
+ const ns = ts.createIdentifier(moduleName.replace('@angular/', ''));
+ const qName = ts.createQualifiedName(ns, name);
+ return ts.createTypeReferenceNode(qName, typeArgs.length > 0 ? typeArgs : undefined);
}
getPreludeStatements(): ts.Statement[] { return []; }
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts
index d2bac46b00..030395fb41 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts
@@ -235,6 +235,42 @@ describe('type check blocks', () => {
});
});
+ describe('outputs', () => {
+
+ it('should emit subscribe calls for directive outputs', () => {
+ const DIRECTIVES: TestDeclaration[] = [{
+ type: 'directive',
+ name: 'Dir',
+ selector: '[dir]',
+ outputs: {'outputField': 'dirOutput'},
+ }];
+ const TEMPLATE = ``;
+ const block = tcb(TEMPLATE, DIRECTIVES);
+ expect(block).toContain(
+ '_outputHelper(_t2.outputField).subscribe($event => (ctx).foo($event));');
+ });
+
+ it('should emit a listener function with AnimationEvent for animation events', () => {
+ const TEMPLATE = ``;
+ const block = tcb(TEMPLATE);
+ expect(block).toContain('($event: animations.AnimationEvent) => (ctx).foo($event);');
+ });
+
+ it('should emit addEventListener calls for unclaimed outputs', () => {
+ const TEMPLATE = ``;
+ const block = tcb(TEMPLATE);
+ expect(block).toContain('_t1.addEventListener("event", $event => (ctx).foo($event));');
+ });
+
+ it('should allow to cast $event using $any', () => {
+ const TEMPLATE = ``;
+ const block = tcb(TEMPLATE);
+ expect(block).toContain(
+ '_t1.addEventListener("event", $event => (ctx).foo(($event as any)));');
+ });
+
+ });
+
describe('config', () => {
const DIRECTIVES: TestDeclaration[] = [{
type: 'directive',
@@ -242,6 +278,7 @@ describe('type check blocks', () => {
selector: '[dir]',
exportAs: ['dir'],
inputs: {'dirInput': 'dirInput'},
+ outputs: {'outputField': 'dirOutput'},
hasNgTemplateContextGuard: true,
}];
const BASE_CONFIG: TypeCheckingConfig = {
@@ -251,6 +288,9 @@ describe('type check blocks', () => {
checkTypeOfInputBindings: true,
strictNullInputBindings: true,
checkTypeOfDomBindings: false,
+ checkTypeOfOutputEvents: true,
+ checkTypeOfAnimationEvents: true,
+ checkTypeOfDomEvents: true,
checkTypeOfPipes: true,
strictSafeNavigationTypes: true,
};
@@ -319,6 +359,63 @@ describe('type check blocks', () => {
});
});
+ describe('config.checkTypeOfOutputEvents', () => {
+ const TEMPLATE = ``;
+
+ it('should check types of directive outputs when enabled', () => {
+ const block = tcb(TEMPLATE, DIRECTIVES);
+ expect(block).toContain(
+ '_outputHelper(_t2.outputField).subscribe($event => (ctx).foo($event));');
+ expect(block).toContain(
+ '_t1.addEventListener("nonDirOutput", $event => (ctx).foo($event));');
+ });
+ it('should not check types of directive outputs when disabled', () => {
+ const DISABLED_CONFIG:
+ TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfOutputEvents: false};
+ const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG);
+ expect(block).toContain('($event: any) => (ctx).foo($event);');
+ // Note that DOM events are still checked, that is controlled by `checkTypeOfDomEvents`
+ expect(block).toContain(
+ '_t1.addEventListener("nonDirOutput", $event => (ctx).foo($event));');
+ });
+ });
+
+ describe('config.checkTypeOfAnimationEvents', () => {
+ const TEMPLATE = ``;
+
+ it('should check types of animation events when enabled', () => {
+ const block = tcb(TEMPLATE, DIRECTIVES);
+ expect(block).toContain('($event: animations.AnimationEvent) => (ctx).foo($event);');
+ });
+ it('should not check types of animation events when disabled', () => {
+ const DISABLED_CONFIG:
+ TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfAnimationEvents: false};
+ const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG);
+ expect(block).toContain('($event: any) => (ctx).foo($event);');
+ });
+ });
+
+ describe('config.checkTypeOfDomEvents', () => {
+ const TEMPLATE = ``;
+
+ it('should check types of DOM events when enabled', () => {
+ const block = tcb(TEMPLATE, DIRECTIVES);
+ expect(block).toContain(
+ '_outputHelper(_t2.outputField).subscribe($event => (ctx).foo($event));');
+ expect(block).toContain(
+ '_t1.addEventListener("nonDirOutput", $event => (ctx).foo($event));');
+ });
+ it('should not check types of DOM events when disabled', () => {
+ const DISABLED_CONFIG: TypeCheckingConfig = {...BASE_CONFIG, checkTypeOfDomEvents: false};
+ const block = tcb(TEMPLATE, DIRECTIVES, DISABLED_CONFIG);
+ // Note that directive outputs are still checked, that is controlled by
+ // `checkTypeOfOutputEvents`
+ expect(block).toContain(
+ '_outputHelper(_t2.outputField).subscribe($event => (ctx).foo($event));');
+ expect(block).toContain('($event: any) => (ctx).foo($event);');
+ });
+ });
+
describe('config.checkTypeOfPipes', () => {
const TEMPLATE = `{{a | test:b:c}}`;
diff --git a/packages/compiler-cli/test/ngtsc/fake_core/index.ts b/packages/compiler-cli/test/ngtsc/fake_core/index.ts
index ddc353bf51..4e494f06d1 100644
--- a/packages/compiler-cli/test/ngtsc/fake_core/index.ts
+++ b/packages/compiler-cli/test/ngtsc/fake_core/index.ts
@@ -80,3 +80,7 @@ export enum ChangeDetectionStrategy {
export const CUSTOM_ELEMENTS_SCHEMA: any = false;
export const NO_ERRORS_SCHEMA: any = false;
+
+export class EventEmitter {
+ subscribe(generatorOrNext?: any, error?: any, complete?: any): unknown { return null; }
+}
diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
index f6b5dd4da9..2791395b1b 100644
--- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
@@ -106,6 +106,42 @@ export declare class CommonModule {
expect(diags[0].messageText).toEqual(`Type 'string' is not assignable to type 'number'.`);
});
+ it('should check event bindings', () => {
+ env.write('test.ts', `
+ import {Component, Directive, EventEmitter, NgModule, Output} from '@angular/core';
+
+ @Component({
+ selector: 'test',
+ template: '',
+ })
+ class TestCmp {
+ update(data: string) {}
+ }
+
+ @Directive({selector: '[dir]'})
+ class TestDir {
+ @Output() update = new EventEmitter();
+ }
+
+ @NgModule({
+ declarations: [TestCmp, TestDir],
+ })
+ class Module {}
+ `);
+
+ const diags = env.driveDiagnostics();
+ expect(diags.length).toBe(3);
+ expect(diags[0].messageText)
+ .toEqual(`Argument of type 'number' is not assignable to parameter of type 'string'.`);
+ expect(diags[1].messageText)
+ .toEqual(`Property 'updated' does not exist on type 'TestCmp'. Did you mean 'update'?`);
+ // Disabled because `checkTypeOfDomEvents` is disabled by default
+ // expect(diags[2].messageText)
+ // .toEqual(
+ // `Argument of type 'FocusEvent' is not assignable to parameter of type 'string'.`);
+ expect(diags[2].messageText).toEqual(`Property 'focused' does not exist on type 'TestCmp'.`);
+ });
+
it('should check basic usage of NgIf', () => {
env.write('test.ts', `
import {CommonModule} from '@angular/common';
diff --git a/packages/core/test/bundling/todo/index.ts b/packages/core/test/bundling/todo/index.ts
index a16c76ecd3..532f3077c5 100644
--- a/packages/core/test/bundling/todo/index.ts
+++ b/packages/core/test/bundling/todo/index.ts
@@ -87,9 +87,9 @@ class TodoStore {
diff --git a/packages/core/test/bundling/todo_i18n/index.ts b/packages/core/test/bundling/todo_i18n/index.ts
index 648da7d67f..d57b51649b 100644
--- a/packages/core/test/bundling/todo_i18n/index.ts
+++ b/packages/core/test/bundling/todo_i18n/index.ts
@@ -82,9 +82,9 @@ class TodoStore {