diff --git a/packages/compiler-cli/src/ngtsc/metadata/src/api.ts b/packages/compiler-cli/src/ngtsc/metadata/src/api.ts index 34b4202c81..04dab52199 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; + coercedInputs: 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..70b662f868 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, + coercedInputs: 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 coercedInputs = + new Set(staticMembers.map(extractCoercedInput) + .filter((inputName): inputName is string => inputName !== null)); + return {hasNgTemplateContextGuard, ngTemplateGuards, coercedInputs}; } 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.name.startsWith('ngCoerceInput_')) { + 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..ad6813d7d0 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: [], + coercedInputs: 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 4c9138d4a3..d78f18638e 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[]; + coercedInputs: Set; hasNgTemplateContextGuard: boolean; } 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 49484e9a20..2584aa6338 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 @@ -1144,6 +1144,16 @@ function tcbGetDirectiveInputs( expr = ts.createStringLiteral(attr.value); } + // Wrap the expression if the directive has a coercion function provided. + if (dir.coercedInputs.has(attr.name)) { + const dirId = tcb.env.reference(dir.ref as Reference>); + const coercionFn = ts.createPropertyAccess(dirId, `ngCoerceInput_${attr.name}`); + expr = ts.createCall( + /* expression */ coercionFn, + /* typeArguments */ undefined, + /* argumentsArray */[expr]); + } + directiveInputs.push({ type: 'binding', field: field, 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 9d443eb2bd..534858ccd6 100644 --- a/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts +++ b/packages/compiler-cli/src/ngtsc/typecheck/test/test_utils.ts @@ -160,9 +160,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'}; +export type TestDirective = Partial>>& + { + selector: string, + name: string, file?: AbsoluteFsPath, + type: 'directive', coercedInputs?: string[], + }; export type TestPipe = { name: string, file?: AbsoluteFsPath, @@ -295,6 +300,7 @@ function prepareDeclarations( inputs: decl.inputs || {}, isComponent: decl.isComponent || false, ngTemplateGuards: decl.ngTemplateGuards || [], + coercedInputs: new Set(decl.coercedInputs || []), outputs: decl.outputs || {}, queries: decl.queries || [], }; 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 030395fb41..7a1a092c7d 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 @@ -268,7 +268,34 @@ describe('type check blocks', () => { expect(block).toContain( '_t1.addEventListener("event", $event => (ctx).foo(($event as any)));'); }); + }); + describe('input coercion', () => { + it('should coerce a basic input', () => { + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'MatInput', + selector: '[matInput]', + inputs: {'value': 'value'}, + coercedInputs: ['value'], + }]; + const TEMPLATE = ``; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).toContain('value: (MatInput.ngCoerceInput_value((ctx).expr))'); + }); + + it('should coerce based on input name, not field name', () => { + const DIRECTIVES: TestDeclaration[] = [{ + type: 'directive', + name: 'MatInput', + selector: '[matInput]', + inputs: {'field': 'value'}, + coercedInputs: ['value'], + }]; + const TEMPLATE = ``; + const block = tcb(TEMPLATE, DIRECTIVES); + expect(block).toContain('field: (MatInput.ngCoerceInput_value((ctx).expr))'); + }); }); describe('config', () => { diff --git a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts index 2791395b1b..871e62815d 100644 --- a/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts +++ b/packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts @@ -406,6 +406,77 @@ 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 ngCoerceInput_value(v: string|number): string; + } + + 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( + `Argument of type 'boolean' is not assignable to parameter of type 'string | number'.`); + }); + }); + describe('legacy schema checking with the DOM schema', () => { beforeEach( () => { env.tsconfig({ivyTemplateTypeCheck: true, fullTemplateTypeCheck: false}); });