feat(ivy): input type coercion for template type-checking (#33243)

Often the types of an `@Input`'s field don't fully reflect the types of
assignable values. This can happen when an input has a getter/setter pair
where the getter always returns a narrow type, and the setter coerces a
wider value down to the narrow type.

For example, you could imagine an input of the form:

```typescript
@Input() get value(): string {
  return this._value;
}

set value(v: {toString(): string}) {
  this._value = v.toString();
}
```

Here, the getter always returns a `string`, but the setter accepts any value
that can be `toString()`'d, and coerces it to a string.

Unfortunately TypeScript does not actually support this syntax, and so
Angular users are forced to type their setters as narrowly as the getters,
even though at runtime the coercion works just fine.

To support these kinds of patterns (e.g. as used by Material), this commit
adds a compiler feature called "input coercion". When a binding is made to
the 'value' input of a directive like MatInput, the compiler will look for a
static function with the name ngCoerceInput_value. If such a function is
found, the type-checking expression for the input will be wrapped in a call
to the function, allowing for the expression of a type conversion between
the binding expression and the value being written to the input's field.

To solve the case above, for example, MatInput might write:

```typescript
class MatInput {
  // rest of the directive...

  static ngCoerceInput_value(value: {toString(): string}): string {
    return null!;
  }
}
```

FW-1475 #resolve

PR Close #33243
This commit is contained in:
Alex Rickabaugh 2019-10-17 16:02:21 -07:00 committed by Matias Niemelä
parent d4db746898
commit 1b4eaea6d4
8 changed files with 142 additions and 5 deletions

View File

@ -34,6 +34,7 @@ export interface DirectiveMeta extends T2DirectiveMeta {
queries: string[]; queries: string[];
ngTemplateGuards: TemplateGuardMeta[]; ngTemplateGuards: TemplateGuardMeta[];
hasNgTemplateContextGuard: boolean; hasNgTemplateContextGuard: boolean;
coercedInputs: Set<string>;
/** /**
* A `Reference` to the base class for the directive, if one was detected. * A `Reference` to the base class for the directive, if one was detected.

View File

@ -82,20 +82,25 @@ export function readStringArrayType(type: ts.TypeNode): string[] {
export function extractDirectiveGuards(node: ClassDeclaration, reflector: ReflectionHost): { export function extractDirectiveGuards(node: ClassDeclaration, reflector: ReflectionHost): {
ngTemplateGuards: TemplateGuardMeta[], ngTemplateGuards: TemplateGuardMeta[],
hasNgTemplateContextGuard: boolean, hasNgTemplateContextGuard: boolean,
coercedInputs: Set<string>,
} { } {
const staticMembers = reflector.getMembersOfClass(node).filter(member => member.isStatic); const staticMembers = reflector.getMembersOfClass(node).filter(member => member.isStatic);
const ngTemplateGuards = staticMembers.map(extractTemplateGuard) const ngTemplateGuards = staticMembers.map(extractTemplateGuard)
.filter((guard): guard is TemplateGuardMeta => guard !== null); .filter((guard): guard is TemplateGuardMeta => guard !== null);
const hasNgTemplateContextGuard = staticMembers.some( const hasNgTemplateContextGuard = staticMembers.some(
member => member.kind === ClassMemberKind.Method && member.name === 'ngTemplateContextGuard'); 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 { function extractTemplateGuard(member: ClassMember): TemplateGuardMeta|null {
if (!member.name.startsWith('ngTemplateGuard_')) { if (!member.name.startsWith('ngTemplateGuard_')) {
return null; return null;
} }
const inputName = member.name.split('_', 2)[1]; const inputName = afterUnderscore(member.name);
if (member.kind === ClassMemberKind.Property) { if (member.kind === ClassMemberKind.Property) {
let type: string|null = null; let type: string|null = null;
if (member.type !== null && ts.isLiteralTypeNode(member.type) && 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 * A `MetadataReader` that reads from an ordered set of child readers until it obtains the requested
* metadata. * metadata.
@ -158,3 +170,11 @@ export class CompoundMetadataReader implements MetadataReader {
return null; 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);
}

View File

@ -228,6 +228,7 @@ function fakeDirective(ref: Reference<ClassDeclaration>): DirectiveMeta {
queries: [], queries: [],
hasNgTemplateContextGuard: false, hasNgTemplateContextGuard: false,
ngTemplateGuards: [], ngTemplateGuards: [],
coercedInputs: new Set<string>(),
baseClass: null, baseClass: null,
}; };
} }

View File

@ -21,6 +21,7 @@ export interface TypeCheckableDirectiveMeta extends DirectiveMeta {
ref: Reference<ClassDeclaration>; ref: Reference<ClassDeclaration>;
queries: string[]; queries: string[];
ngTemplateGuards: TemplateGuardMeta[]; ngTemplateGuards: TemplateGuardMeta[];
coercedInputs: Set<string>;
hasNgTemplateContextGuard: boolean; hasNgTemplateContextGuard: boolean;
} }

View File

@ -1144,6 +1144,16 @@ function tcbGetDirectiveInputs(
expr = ts.createStringLiteral(attr.value); 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<ClassDeclaration<ts.ClassDeclaration>>);
const coercionFn = ts.createPropertyAccess(dirId, `ngCoerceInput_${attr.name}`);
expr = ts.createCall(
/* expression */ coercionFn,
/* typeArguments */ undefined,
/* argumentsArray */[expr]);
}
directiveInputs.push({ directiveInputs.push({
type: 'binding', type: 'binding',
field: field, field: field,

View File

@ -160,9 +160,14 @@ export const ALL_ENABLED_CONFIG: TypeCheckingConfig = {
}; };
// Remove 'ref' from TypeCheckableDirectiveMeta and add a 'selector' instead. // Remove 'ref' from TypeCheckableDirectiveMeta and add a 'selector' instead.
export type TestDirective = export type TestDirective = Partial<Pick<
Partial<Pick<TypeCheckableDirectiveMeta, Exclude<keyof TypeCheckableDirectiveMeta, 'ref'>>>& TypeCheckableDirectiveMeta,
{selector: string, name: string, file?: AbsoluteFsPath, type: 'directive'}; Exclude<keyof TypeCheckableDirectiveMeta, 'ref'|'coercedInputs'>>>&
{
selector: string,
name: string, file?: AbsoluteFsPath,
type: 'directive', coercedInputs?: string[],
};
export type TestPipe = { export type TestPipe = {
name: string, name: string,
file?: AbsoluteFsPath, file?: AbsoluteFsPath,
@ -295,6 +300,7 @@ function prepareDeclarations(
inputs: decl.inputs || {}, inputs: decl.inputs || {},
isComponent: decl.isComponent || false, isComponent: decl.isComponent || false,
ngTemplateGuards: decl.ngTemplateGuards || [], ngTemplateGuards: decl.ngTemplateGuards || [],
coercedInputs: new Set<string>(decl.coercedInputs || []),
outputs: decl.outputs || {}, outputs: decl.outputs || {},
queries: decl.queries || [], queries: decl.queries || [],
}; };

View File

@ -268,7 +268,34 @@ describe('type check blocks', () => {
expect(block).toContain( expect(block).toContain(
'_t1.addEventListener("event", $event => (ctx).foo(($event as any)));'); '_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 = `<input matInput [value]="expr">`;
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 = `<input matInput [value]="expr">`;
const block = tcb(TEMPLATE, DIRECTIVES);
expect(block).toContain('field: (MatInput.ngCoerceInput_value((ctx).expr))');
});
}); });
describe('config', () => { describe('config', () => {

View File

@ -406,6 +406,77 @@ export declare class CommonModule {
expect(diags[1].length).toEqual(15); 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<MatInput, '[matInput]', never, {'value': 'value'}, {}, never>;
static ngCoerceInput_value(v: string|number): string;
}
export declare class MatInputModule {
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatInputModule, [typeof MatInput], never, [typeof MatInput]>;
}
`);
});
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: '<input matInput [value]="someNumber">',
})
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: '<input matInput [value]="invalidType">',
})
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', () => { describe('legacy schema checking with the DOM schema', () => {
beforeEach( beforeEach(
() => { env.tsconfig({ivyTemplateTypeCheck: true, fullTemplateTypeCheck: false}); }); () => { env.tsconfig({ivyTemplateTypeCheck: true, fullTemplateTypeCheck: false}); });