diff --git a/aio/content/guide/aot-compiler.md b/aio/content/guide/aot-compiler.md
index 35b6dec4f2..91977414c1 100644
--- a/aio/content/guide/aot-compiler.md
+++ b/aio/content/guide/aot-compiler.md
@@ -677,6 +677,80 @@ In this example it is recommended to include the checking of `address` in the `*
}
```
+### Input setter coercion
+
+Occasionally it is desirable for the `@Input` of a directive or component to alter the value bound to it, typically using a getter/setter pair for the input. As an example, consider this custom button component:
+
+Consider the following directive:
+
+```typescript
+@Component({
+ selector: 'submit-button',
+ template: `
+
+ Submit '
+
+ `,
+})
+class SubmitButton {
+ private _disabled: boolean;
+
+ get disabled(): boolean {
+ return this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ this._disabled = value;
+ }
+}
+```
+
+Here, the `disabled` input of the component is being passed on to the `` in the template. All of this works as expected, as long as a `boolean` value is bound to the input. But, suppose a consumer uses this input in the template as an attribute:
+
+```html
+
+```
+
+This has the same effect as the binding:
+
+```html
+
+```
+
+At runtime, the input will be set to the empty string, which is not a `boolean` value. Angular component libraries that deal with this problem often "coerce" the value into the right type in the setter:
+
+```typescript
+set disabled(value: boolean) {
+ this._disabled = (value === '') || value;
+}
+```
+
+It would be ideal to change the type of `value` here, from `boolean` to `boolean|''`, to match the set of values which are actually accepted by the setter. Unfortunately, TypeScript requires that both the getter and setter have the same type, so if the getter should return a `boolean` then the setter is stuck with the narrower type.
+
+If the consumer has Angular's strictest type checking for templates enabled, this creates a problem: the empty string `''` is not actually assignable to the `disabled` field, which will create a type error when the attribute form is used.
+
+As a workaround for this problem, Angular supports checking a wider, more permissive type for `@Input`s than is declared for the input field itself. This is enabled by adding a static property with the `ngAcceptInputType_` prefix to the component class:
+
+```typescript
+class SubmitButton {
+ private _disabled: boolean;
+
+ get disabled(): boolean {
+ return this._disabled;
+ }
+
+ set disabled(value: boolean) {
+ this._disabled = (value === '') || value;
+ }
+
+ static ngAcceptInputType_disabled: boolean|'';
+}
+```
+
+This field does not need to have a value. Its existence communicates to the Angular type checker that the `disabled` input should be considered as accepting bindings that match the type `boolean|''`. The suffix should be the `@Input` _field_ name.
+
+Care should be taken that if an `ngAcceptInputType_` override is present for a given input, then the setter should be able to handle any values of the overridden type.
+
### Disabling type checking using `$any()`
Disable checking of a binding expression by surrounding the expression in a call to the [`$any()` cast pseudo-function](guide/template-syntax).
diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts
index 34b4202c81..6739226478 100644
--- a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts
+++ b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts
@@ -34,6 +34,7 @@ export interface DirectiveMeta extends T2DirectiveMeta {
queries: string[];
ngTemplateGuards: TemplateGuardMeta[];
hasNgTemplateContextGuard: boolean;
+ coercedInputFields: Set;
/**
* A `Reference` to the base class for the directive, if one was detected.
diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts
index 7f8a9d2678..d49a954013 100644
--- a/packages/compiler-cli/src/ngtsc/metadata/src/util.ts
+++ b/packages/compiler-cli/src/ngtsc/metadata/src/util.ts
@@ -82,20 +82,25 @@ export function readStringArrayType(type: ts.TypeNode): string[] {
export function extractDirectiveGuards(node: ClassDeclaration, reflector: ReflectionHost): {
ngTemplateGuards: TemplateGuardMeta[],
hasNgTemplateContextGuard: boolean,
+ coercedInputFields: Set,
} {
const staticMembers = reflector.getMembersOfClass(node).filter(member => member.isStatic);
const ngTemplateGuards = staticMembers.map(extractTemplateGuard)
.filter((guard): guard is TemplateGuardMeta => guard !== null);
const hasNgTemplateContextGuard = staticMembers.some(
member => member.kind === ClassMemberKind.Method && member.name === 'ngTemplateContextGuard');
- return {hasNgTemplateContextGuard, ngTemplateGuards};
+
+ const coercedInputFields =
+ new Set(staticMembers.map(extractCoercedInput)
+ .filter((inputName): inputName is string => inputName !== null));
+ return {hasNgTemplateContextGuard, ngTemplateGuards, coercedInputFields};
}
function extractTemplateGuard(member: ClassMember): TemplateGuardMeta|null {
if (!member.name.startsWith('ngTemplateGuard_')) {
return null;
}
- const inputName = member.name.split('_', 2)[1];
+ const inputName = afterUnderscore(member.name);
if (member.kind === ClassMemberKind.Property) {
let type: string|null = null;
if (member.type !== null && ts.isLiteralTypeNode(member.type) &&
@@ -115,6 +120,13 @@ function extractTemplateGuard(member: ClassMember): TemplateGuardMeta|null {
}
}
+function extractCoercedInput(member: ClassMember): string|null {
+ if (member.kind !== ClassMemberKind.Property || !member.name.startsWith('ngAcceptInputType_')) {
+ return null !;
+ }
+ return afterUnderscore(member.name);
+}
+
/**
* A `MetadataReader` that reads from an ordered set of child readers until it obtains the requested
* metadata.
@@ -158,3 +170,11 @@ export class CompoundMetadataReader implements MetadataReader {
return null;
}
}
+
+function afterUnderscore(str: string): string {
+ const pos = str.indexOf('_');
+ if (pos === -1) {
+ throw new Error(`Expected '${str}' to contain '_'`);
+ }
+ return str.substr(pos + 1);
+}
diff --git a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
index 008f17199b..f0b6b6eb01 100644
--- a/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
+++ b/packages/compiler-cli/src/ngtsc/scope/test/local_spec.ts
@@ -228,6 +228,7 @@ function fakeDirective(ref: Reference): DirectiveMeta {
queries: [],
hasNgTemplateContextGuard: false,
ngTemplateGuards: [],
+ coercedInputFields: new Set(),
baseClass: null,
};
}
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts
index a74e030a5b..c2ccc6bab3 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/api.ts
@@ -21,6 +21,7 @@ export interface TypeCheckableDirectiveMeta extends DirectiveMeta {
ref: Reference;
queries: string[];
ngTemplateGuards: TemplateGuardMeta[];
+ coercedInputFields: Set;
hasNgTemplateContextGuard: boolean;
}
@@ -67,6 +68,11 @@ export interface TypeCtorMetadata {
* Input, output, and query field names in the type which should be included as constructor input.
*/
fields: {inputs: string[]; outputs: string[]; queries: string[];};
+
+ /**
+ * `Set` of field names which have type coercion enabled.
+ */
+ coercedInputFields: Set;
}
export interface TypeCheckingConfig {
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts
index 318d8006f9..d7c281983a 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/context.ts
@@ -90,6 +90,7 @@ export class TypeCheckContext {
// TODO(alxhub): support queries
queries: dir.queries,
},
+ coercedInputFields: dir.coercedInputFields,
});
}
}
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts
index b6052c6f83..895a2258b5 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/environment.ts
@@ -81,7 +81,8 @@ export class Environment {
outputs: Object.keys(dir.outputs),
// TODO: support queries
queries: dir.queries,
- }
+ },
+ coercedInputFields: dir.coercedInputFields,
};
const typeCtor = generateTypeCtorDeclarationFn(node, meta, nodeTypeRef.typeName, this.config);
this.typeCtorStatements.push(typeCtor);
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts
index 5607044447..96160a9690 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/src/type_constructor.ts
@@ -22,7 +22,7 @@ export function generateTypeCtorDeclarationFn(
const rawTypeArgs =
node.typeParameters !== undefined ? generateGenericArgs(node.typeParameters) : undefined;
- const rawType: ts.TypeNode = ts.createTypeReferenceNode(nodeTypeRef, rawTypeArgs);
+ const rawType = ts.createTypeReferenceNode(nodeTypeRef, rawTypeArgs);
const initParam = constructTypeCtorParameter(node, meta, rawType);
@@ -97,7 +97,7 @@ export function generateInlineTypeCtor(
// `FooDirective`, its rawType would be `FooDirective`.
const rawTypeArgs =
node.typeParameters !== undefined ? generateGenericArgs(node.typeParameters) : undefined;
- const rawType: ts.TypeNode = ts.createTypeReferenceNode(node.name, rawTypeArgs);
+ const rawType = ts.createTypeReferenceNode(node.name, rawTypeArgs);
const initParam = constructTypeCtorParameter(node, meta, rawType);
@@ -126,7 +126,7 @@ export function generateInlineTypeCtor(
function constructTypeCtorParameter(
node: ClassDeclaration, meta: TypeCtorMetadata,
- rawType: ts.TypeNode): ts.ParameterDeclaration {
+ rawType: ts.TypeReferenceNode): ts.ParameterDeclaration {
// initType is the type of 'init', the single argument to the type constructor method.
// If the Directive has any inputs, its initType will be:
//
@@ -136,20 +136,42 @@ function constructTypeCtorParameter(
// directive will be inferred.
//
// In the special case there are no inputs, initType is set to {}.
- let initType: ts.TypeNode;
+ let initType: ts.TypeNode|null = null;
const keys: string[] = meta.fields.inputs;
- if (keys.length === 0) {
- // Special case - no inputs, outputs, or other fields which could influence the result type.
- initType = ts.createTypeLiteralNode([]);
- } else {
+ const plainKeys: ts.LiteralTypeNode[] = [];
+ const coercedKeys: ts.PropertySignature[] = [];
+ for (const key of keys) {
+ if (!meta.coercedInputFields.has(key)) {
+ plainKeys.push(ts.createLiteralTypeNode(ts.createStringLiteral(key)));
+ } else {
+ coercedKeys.push(ts.createPropertySignature(
+ /* modifiers */ undefined,
+ /* name */ key,
+ /* questionToken */ undefined,
+ /* type */ ts.createTypeQueryNode(
+ ts.createQualifiedName(rawType.typeName, `ngAcceptInputType_${key}`)),
+ /* initializer */ undefined));
+ }
+ }
+ if (plainKeys.length > 0) {
// Construct a union of all the field names.
- const keyTypeUnion = ts.createUnionTypeNode(
- keys.map(key => ts.createLiteralTypeNode(ts.createStringLiteral(key))));
+ const keyTypeUnion = ts.createUnionTypeNode(plainKeys);
// Construct the Pick.
initType = ts.createTypeReferenceNode('Pick', [rawType, keyTypeUnion]);
}
+ if (coercedKeys.length > 0) {
+ const coercedLiteral = ts.createTypeLiteralNode(coercedKeys);
+
+ initType =
+ initType !== null ? ts.createUnionTypeNode([initType, coercedLiteral]) : coercedLiteral;
+ }
+
+ if (initType === null) {
+ // Special case - no inputs, outputs, or other fields which could influence the result type.
+ initType = ts.createTypeLiteralNode([]);
+ }
// Create the 'init' parameter itself.
return ts.createParameter(
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 e6bca0d025..6305d3b81d 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts
@@ -162,8 +162,14 @@ export const ALL_ENABLED_CONFIG: TypeCheckingConfig = {
// Remove 'ref' from TypeCheckableDirectiveMeta and add a 'selector' instead.
export type TestDirective =
- Partial>>&
- {selector: string, name: string, file?: AbsoluteFsPath, type: 'directive'};
+ Partial>>&
+ {
+ selector: string,
+ name: string, file?: AbsoluteFsPath,
+ type: 'directive', coercedInputFields?: string[],
+ };
export type TestPipe = {
name: string,
file?: AbsoluteFsPath,
@@ -297,6 +303,7 @@ function prepareDeclarations(
inputs: decl.inputs || {},
isComponent: decl.isComponent || false,
ngTemplateGuards: decl.ngTemplateGuards || [],
+ coercedInputFields: new Set(decl.coercedInputFields || []),
outputs: decl.outputs || {},
queries: decl.queries || [],
};
diff --git a/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts b/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts
index 8fa863013e..1d45b8c8af 100644
--- a/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts
+++ b/packages/compiler-cli/src/ngtsc/typecheck/test/type_constructor_spec.ts
@@ -81,6 +81,7 @@ TestClass.ngTypeCtor({value: 'test'});
outputs: [],
queries: [],
},
+ coercedInputFields: new Set(),
});
ctx.calculateTemplateDiagnostics(program, host, options);
});
@@ -113,6 +114,7 @@ TestClass.ngTypeCtor({value: 'test'});
outputs: [],
queries: ['queryField'],
},
+ coercedInputFields: new Set(),
});
const res = ctx.calculateTemplateDiagnostics(program, host, options);
const TestClassWithCtor =
@@ -121,6 +123,47 @@ TestClass.ngTypeCtor({value: 'test'});
expect(typeCtor.getText()).not.toContain('queryField');
});
});
+
+ describe('input type coercion', () => {
+ it('should coerce input types', () => {
+ const files: TestFile[] = [
+ LIB_D_TS, TYPE_CHECK_TS, {
+ name: _('/main.ts'),
+ contents: `class TestClass { value: any; }`,
+ }
+ ];
+ const {program, host, options} = makeProgram(files, undefined, undefined, false);
+ const checker = program.getTypeChecker();
+ const reflectionHost = new TypeScriptReflectionHost(checker);
+ const logicalFs = new LogicalFileSystem(getRootDirs(host, options));
+ const emitter = new ReferenceEmitter([
+ new LocalIdentifierStrategy(),
+ new AbsoluteModuleStrategy(program, checker, options, host, reflectionHost),
+ new LogicalProjectStrategy(reflectionHost, logicalFs),
+ ]);
+ const ctx = new TypeCheckContext(ALL_ENABLED_CONFIG, emitter, _('/_typecheck_.ts'));
+ const TestClass =
+ getDeclaration(program, _('/main.ts'), 'TestClass', isNamedClassDeclaration);
+ ctx.addInlineTypeCtor(
+ getSourceFileOrError(program, _('/main.ts')), new Reference(TestClass), {
+ fnName: 'ngTypeCtor',
+ body: true,
+ fields: {
+ inputs: ['foo', 'bar'],
+ outputs: [],
+ queries: [],
+ },
+ coercedInputFields: new Set(['bar']),
+ });
+ const res = ctx.calculateTemplateDiagnostics(program, host, options);
+ const TestClassWithCtor =
+ getDeclaration(res.program, _('/main.ts'), 'TestClass', isNamedClassDeclaration);
+ const typeCtor = TestClassWithCtor.members.find(isTypeCtor) !;
+ const ctorText = typeCtor.getText().replace(/[ \n]+/g, ' ');
+ expect(ctorText).toContain(
+ 'init: Pick | { bar: typeof TestClass.ngAcceptInputType_bar; }');
+ });
+ });
});
function isTypeCtor(el: ts.ClassElement): el is ts.MethodDeclaration {
diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
index 2791395b1b..85fe421304 100644
--- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
+++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
@@ -406,6 +406,76 @@ export declare class CommonModule {
expect(diags[1].length).toEqual(15);
});
+ describe('input coercion', () => {
+ beforeEach(() => {
+ env.tsconfig({
+ 'fullTemplateTypeCheck': true,
+ });
+ env.write('node_modules/@angular/material/index.d.ts', `
+ import * as i0 from '@angular/core';
+
+ export declare class MatInput {
+ value: string;
+ static ɵdir: i0.ɵɵDirectiveDefWithMeta;
+ static ngAcceptInputType_value: string|number;
+ }
+
+ export declare class MatInputModule {
+ static ɵmod: i0.ɵɵNgModuleDefWithMeta;
+ }
+ `);
+ });
+
+ it('should coerce an input using a coercion function if provided', () => {
+ env.write('test.ts', `
+ import {Component, NgModule} from '@angular/core';
+ import {MatInputModule} from '@angular/material';
+
+ @Component({
+ selector: 'blah',
+ template: ' ',
+ })
+ export class FooCmp {
+ someNumber = 3;
+ }
+
+ @NgModule({
+ declarations: [FooCmp],
+ imports: [MatInputModule],
+ })
+ export class FooModule {}
+ `);
+ const diags = env.driveDiagnostics();
+ expect(diags.length).toBe(0);
+ });
+
+ it('should give an error if the binding expression type is not accepted by the coercion function',
+ () => {
+ env.write('test.ts', `
+ import {Component, NgModule} from '@angular/core';
+ import {MatInputModule} from '@angular/material';
+
+ @Component({
+ selector: 'blah',
+ template: ' ',
+ })
+ export class FooCmp {
+ invalidType = true;
+ }
+
+ @NgModule({
+ declarations: [FooCmp],
+ imports: [MatInputModule],
+ })
+ export class FooModule {}
+ `);
+ const diags = env.driveDiagnostics();
+ expect(diags.length).toBe(1);
+ expect(diags[0].messageText)
+ .toBe(`Type 'boolean' is not assignable to type 'string | number'.`);
+ });
+ });
+
describe('legacy schema checking with the DOM schema', () => {
beforeEach(
() => { env.tsconfig({ivyTemplateTypeCheck: true, fullTemplateTypeCheck: false}); });