diff --git a/packages/compiler-cli/src/ngtsc/program.ts b/packages/compiler-cli/src/ngtsc/program.ts index 780fc1a93adc4c..cd5093a2e942bc 100644 --- a/packages/compiler-cli/src/ngtsc/program.ts +++ b/packages/compiler-cli/src/ngtsc/program.ts @@ -453,6 +453,33 @@ export class NgtscProgram implements api.Program { }; } + // Apply explicitly configured strictness flags on top of the default configuration + // based on "fullTemplateTypeCheck". + if (this.options.strictInputTypes !== undefined) { + typeCheckingConfig.checkTypeOfInputBindings = this.options.strictInputTypes; + } + if (this.options.strictNullInputTypes !== undefined) { + typeCheckingConfig.strictNullInputBindings = this.options.strictNullInputTypes; + } + if (this.options.strictOutputEventTypes !== undefined) { + typeCheckingConfig.checkTypeOfOutputEvents = this.options.strictOutputEventTypes; + } + if (this.options.strictAnimationEventTypes !== undefined) { + typeCheckingConfig.checkTypeOfAnimationEvents = this.options.strictAnimationEventTypes; + } + if (this.options.strictDomEventTypes !== undefined) { + typeCheckingConfig.checkTypeOfDomEvents = this.options.strictDomEventTypes; + } + if (this.options.strictSafeNavigationTypes !== undefined) { + typeCheckingConfig.strictSafeNavigationTypes = this.options.strictSafeNavigationTypes; + } + if (this.options.strictLocalRefTypes !== undefined) { + typeCheckingConfig.checkTypeOfReferences = this.options.strictLocalRefTypes; + } + if (this.options.strictAttributeTypes !== undefined) { + typeCheckingConfig.checkTypeOfAttributes = this.options.strictAttributeTypes; + } + // Execute the typeCheck phase of each decorator in the program. const prepSpan = this.perfRecorder.start('typeCheckPrep'); const ctx = new TypeCheckContext(typeCheckingConfig, this.refEmitter !, this.typeCheckFilePath); diff --git a/packages/compiler-cli/src/transformers/api.ts b/packages/compiler-cli/src/transformers/api.ts index 4df350040a96fc..a7aa422b4bf40e 100644 --- a/packages/compiler-cli/src/transformers/api.ts +++ b/packages/compiler-cli/src/transformers/api.ts @@ -112,6 +112,100 @@ export interface CompilerOptions extends ts.CompilerOptions { // This will be true be default in Angular 6. fullTemplateTypeCheck?: boolean; + /** + * Whether to check the type of a binding to a directive/component input against the type of the + * field on the directive/component. + * + * For example, if this is `false` then the expression `[input]="expr"` will have `expr` type- + * checked, but not the assignment of the resulting type to the `input` property of whichever + * directive or component is receiving the binding. If set to `true`, both sides of the assignment + * are checked. + * + * Defaults to `true` if "fullTemplateTypeCheck" is set, `false` otherwise. + */ + strictInputTypes?: boolean; + + /** + * Whether to use strict null types for input bindings for directives. + * + * If this is `true`, applications that are compiled with TypeScript's `strictNullChecks` enabled + * will produce type errors for bindings which can evaluate to `undefined` or `null` where the + * inputs's type does not include `undefined` or `null` in its type. If set to `false`, all + * binding expressions are wrapped in a non-null assertion operator to effectively disable strict + * null checks. + * + * Defaults to `true` if "fullTemplateTypeCheck" is set, `false` otherwise. Note that if + * `strictInputTypes` is set to `false`, this flag has no effect. + */ + strictNullInputTypes?: boolean; + + /** + * Whether to check text attributes that happen to be consumed by a directive or component. + * + * For example, in a template containing `` the `disabled` attribute ends + * up being consumed as an input with type `boolean` by the `matInput` directive. At runtime the + * input will be set to the attribute's string value, which is the empty string for attributes + * without a value, so with this flag set to `true` an error would be reported. If set to `false`, + * text attributes will never report an error. + * + * Note that if `strictInputTypes` is set to `false`, this flag has no effect. + */ + strictAttributeTypes?: boolean; + + /** + * Whether to use a strict type for null-safe navigation operations. + * + * If this is `false`, then the return type of `a?.b` or `a?()` will be `any`. If set to `true`, + * then the return type of `a?.b` for example will be the same as the type of the ternary + * expression `a != null ? a.b : a`. + * + * Defaults to `true` if "fullTemplateTypeCheck" is set, `false` otherwise. + */ + strictSafeNavigationTypes?: boolean; + + /** + * Whether to infer the type of local references. + * + * If this is `true`, the type of `#ref` variables in the template will be determined by the + * referenced entity (either a directive or a DOM element). If set to `false`, the type of `ref` + * will be `any`. + * + * Defaults to `true` if "fullTemplateTypeCheck" is set, `false` otherwise. + */ + strictLocalRefTypes?: 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`. + * + * Defaults to `true` if "fullTemplateTypeCheck" is set, `false` otherwise. + */ + strictOutputEventTypes?: 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`. + * + * Defaults to `true` if "fullTemplateTypeCheck" is set, `false` otherwise. + */ + strictAnimationEventTypes?: 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`. + * + * Defaults to `false`. + */ + strictDomEventTypes?: boolean; + // Whether to use the CompilerHost's fileNameToModuleName utility (if available) to generate // import module specifiers. This is false by default, and exists to support running ngtsc // within Google. This option is internal and is used by the ng_module.bzl rule to switch diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 2791395b1b4051..467667fd538b34 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -58,6 +58,11 @@ export declare class NgIf { export declare class CommonModule { static ɵmod: i0.ɵɵNgModuleDefWithMeta; } +`); + env.write('node_modules/@angular/animations/index.d.ts', ` +export declare class AnimationEvent { + element: any; +} `); }); @@ -142,6 +147,347 @@ export declare class CommonModule { expect(diags[2].messageText).toEqual(`Property 'focused' does not exist on type 'TestCmp'.`); }); + describe('strictInputTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp {} + + @Directive({selector: '[dir]'}) + class TestDir { + @Input() foo: string; + } + + @NgModule({ + declarations: [TestCmp, TestDir], + }) + class Module {} + `); + }); + + it('should check expressions and their type when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText).toEqual(`Type 'number' is not assignable to type 'string'.`); + expect(diags[1].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + + it('should check expressions but not their type when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictInputTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + }); + + describe('strictNullInputTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp { + nullable: string | null | undefined; + } + + @Directive({selector: '[dir]'}) + class TestDir { + @Input() foo: string; + } + + @NgModule({ + declarations: [TestCmp, TestDir], + }) + class Module {} + `); + }); + + it('should check expressions and their nullability when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictNullInputTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect((diags[0].messageText as ts.DiagnosticMessageChain).messageText) + .toEqual(`Type 'string | null | undefined' is not assignable to type 'string'.`); + expect(diags[1].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + + it('should check expressions but not their nullability when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictNullInputTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + }); + + describe('strictSafeNavigationTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp { + user?: {name: string}; + } + + @Directive({selector: '[dir]'}) + class TestDir { + @Input() foo: string; + } + + @NgModule({ + declarations: [TestCmp, TestDir], + }) + class Module {} + `); + }); + + it('should infer result type for safe navigation expressions when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictSafeNavigationTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect((diags[0].messageText as ts.DiagnosticMessageChain).messageText) + .toEqual(`Type 'string | undefined' is not assignable to type 'string'.`); + expect(diags[1].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + + it('should not infer result type for safe navigation expressions when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictSafeNavigationTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + }); + + describe('strictOutputEventTypes', () => { + beforeEach(() => { + 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 {} + `); + }); + + it('should expressions and infer type of $event when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictOutputEventTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + expect(diags[1].messageText) + .toEqual(`Argument of type 'number' is not assignable to parameter of type 'string'.`); + }); + + it('should check expressions but not infer type of $event when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictOutputEventTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + }); + + describe('strictAnimationEventTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp { + update(data: string) {} + } + + @NgModule({ + declarations: [TestCmp], + }) + class Module {} + `); + }); + + it('should check expressions and let $event be of type AnimationEvent when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictAnimationEventTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + expect(diags[1].messageText) + .toEqual( + `Argument of type 'AnimationEvent' is not assignable to parameter of type 'string'.`); + }); + + it('should check expressions and let $event be of type any when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictAnimationEventTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + }); + + describe('strictLocalRefTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'test', + template: '{{ref.does_not_exist}}', + }) + class TestCmp {} + + @NgModule({ + declarations: [TestCmp], + }) + class Module {} + `); + }); + + it('should infer the type of references when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictLocalRefTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'does_not_exist' does not exist on type 'HTMLInputElement'.`); + }); + + it('should let the type of reference be any when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictLocalRefTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); + + describe('strictAttributeTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, Directive, NgModule, Input} from '@angular/core'; + + @Component({ + selector: 'test', + template: '', + }) + class TestCmp {} + + @Directive({selector: '[dir]'}) + class TestDir { + @Input() disabled: boolean; + @Input() cols: number; + } + + @NgModule({ + declarations: [TestCmp, TestDir], + }) + class Module {} + `); + }); + + it('should produce an error for text attributes when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictAttributeTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText).toEqual(`Type 'string' is not assignable to type 'boolean'.`); + expect(diags[1].messageText).toEqual(`Type 'string' is not assignable to type 'number'.`); + }); + + it('should not produce an error for text attributes when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictAttributeTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(0); + }); + }); + + describe('strictDomEventTypes', () => { + beforeEach(() => { + env.write('test.ts', ` + import {Component, NgModule} from '@angular/core'; + + @Component({ + selector: 'test', + template: '
', + }) + class TestCmp { + update(data: string) {} + } + + @NgModule({ + declarations: [TestCmp], + }) + class Module {} + `); + }); + + it('should check expressions and infer type of $event when enabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictDomEventTypes: true}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(2); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + expect(diags[1].messageText) + .toEqual( + `Argument of type 'FocusEvent' is not assignable to parameter of type 'string'.`); + }); + + it('should check expressions but not infer type of $event when disabled', () => { + env.tsconfig({fullTemplateTypeCheck: true, strictDomEventTypes: false}); + + const diags = env.driveDiagnostics(); + expect(diags.length).toBe(1); + expect(diags[0].messageText) + .toEqual(`Property 'invalid' does not exist on type 'TestCmp'.`); + }); + }); + it('should check basic usage of NgIf', () => { env.write('test.ts', ` import {CommonModule} from '@angular/common';