feat(compiler): narrow types of expressions used in *ngIf (#20702)

Structural directives can now specify a type guard that describes
what types can be inferred for an input expression inside the
directive's template.

NgIf was modified to declare an input guard on ngIf.

After this change, `fullTemplateTypeCheck` will infer that
usage of `ngIf` expression inside it's template is truthy.

For example, if a component has a property `person?: Person`
and a template of `<div *ngIf="person"> {{person.name}} </div>`
the compiler will no longer report that `person` might be null or
undefined.

The template compiler will generate code similar to,

```
  if (NgIf.ngIfTypeGuard(instance.person)) {
    instance.person.name
  }
```

to validate the template's use of the interpolation expression.
Calling the type guard in this fashion allows TypeScript to infer
that `person` is non-null.

Fixes: #19756?

PR Close #20702
This commit is contained in:
Chuck Jazdzewski
2017-11-29 16:29:05 -08:00
committed by Jason Aden
parent e544742156
commit e7d9cb3e4c
19 changed files with 341 additions and 53 deletions

View File

@ -10,13 +10,15 @@ import {AotCompilerOptions} from '../aot/compiler_options';
import {StaticReflector} from '../aot/static_reflector';
import {StaticSymbol} from '../aot/static_symbol';
import {CompileDiDependencyMetadata, CompileDirectiveMetadata, CompilePipeSummary} from '../compile_metadata';
import {BuiltinConverter, EventHandlerVars, LocalResolver, convertActionBinding, convertPropertyBinding, convertPropertyBindingBuiltins} from '../compiler_util/expression_converter';
import {BindingForm, BuiltinConverter, EventHandlerVars, LocalResolver, convertActionBinding, convertPropertyBinding, convertPropertyBindingBuiltins} from '../compiler_util/expression_converter';
import {AST, ASTWithSource, Interpolation} from '../expression_parser/ast';
import {Identifiers} from '../identifiers';
import * as o from '../output/output_ast';
import {convertValueToOutputAst} from '../output/value_util';
import {ParseSourceSpan} from '../parse_util';
import {AttrAst, BoundDirectivePropertyAst, BoundElementPropertyAst, BoundEventAst, BoundTextAst, DirectiveAst, ElementAst, EmbeddedTemplateAst, NgContentAst, PropertyBindingType, ProviderAst, ProviderAstType, QueryMatch, ReferenceAst, TemplateAst, TemplateAstVisitor, TextAst, VariableAst, templateVisitAll} from '../template_parser/template_ast';
import {OutputContext} from '../util';
/**
* Generates code that is used to type check templates.
@ -34,27 +36,33 @@ export class TypeCheckCompiler {
*/
compileComponent(
componentId: string, component: CompileDirectiveMetadata, template: TemplateAst[],
usedPipes: CompilePipeSummary[],
externalReferenceVars: Map<StaticSymbol, string>): o.Statement[] {
usedPipes: CompilePipeSummary[], externalReferenceVars: Map<StaticSymbol, string>,
ctx: OutputContext): o.Statement[] {
const pipes = new Map<string, StaticSymbol>();
usedPipes.forEach(p => pipes.set(p.name, p.type.reference));
let embeddedViewCount = 0;
const viewBuilderFactory = (parent: ViewBuilder | null): ViewBuilder => {
const embeddedViewIndex = embeddedViewCount++;
return new ViewBuilder(
this.options, this.reflector, externalReferenceVars, parent, component.type.reference,
component.isHost, embeddedViewIndex, pipes, viewBuilderFactory);
};
const viewBuilderFactory =
(parent: ViewBuilder | null, guards: GuardExpression[]): ViewBuilder => {
const embeddedViewIndex = embeddedViewCount++;
return new ViewBuilder(
this.options, this.reflector, externalReferenceVars, parent, component.type.reference,
component.isHost, embeddedViewIndex, pipes, guards, ctx, viewBuilderFactory);
};
const visitor = viewBuilderFactory(null);
const visitor = viewBuilderFactory(null, []);
visitor.visitAll([], template);
return visitor.build(componentId);
}
}
interface GuardExpression {
guard: StaticSymbol;
expression: Expression;
}
interface ViewBuilderFactory {
(parent: ViewBuilder): ViewBuilder;
(parent: ViewBuilder, guards: GuardExpression[]): ViewBuilder;
}
// Note: This is used as key in Map and should therefore be
@ -94,6 +102,7 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
private externalReferenceVars: Map<StaticSymbol, string>, private parent: ViewBuilder|null,
private component: StaticSymbol, private isHostComponent: boolean,
private embeddedViewIndex: number, private pipes: Map<string, StaticSymbol>,
private guards: GuardExpression[], private ctx: OutputContext,
private viewBuilderFactory: ViewBuilderFactory) {}
private getOutputVar(type: o.BuiltinTypeName|StaticSymbol): string {
@ -112,6 +121,20 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
return varName;
}
private getTypeGuardExpressions(ast: EmbeddedTemplateAst): GuardExpression[] {
const result = [...this.guards];
for (let directive of ast.directives) {
for (let input of directive.inputs) {
const guard = directive.directive.guards[input.directiveName];
if (guard) {
result.push(
{guard, expression: {context: this.component, value: input.value} as Expression});
}
}
}
return result;
}
visitAll(variables: VariableAst[], astNodes: TemplateAst[]) {
this.variables = variables;
templateVisitAll(this, astNodes);
@ -119,7 +142,7 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
build(componentId: string, targetStatements: o.Statement[] = []): o.Statement[] {
this.children.forEach((child) => child.build(componentId, targetStatements));
const viewStmts: o.Statement[] =
let viewStmts: o.Statement[] =
[o.variable(DYNAMIC_VAR_NAME).set(o.NULL_EXPR).toDeclStmt(o.DYNAMIC_TYPE)];
let bindingCount = 0;
this.updates.forEach((expression) => {
@ -127,7 +150,8 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
const bindingId = `${bindingCount++}`;
const nameResolver = context === this.component ? this : defaultResolver;
const {stmts, currValExpr} = convertPropertyBinding(
nameResolver, o.variable(this.getOutputVar(context)), value, bindingId);
nameResolver, o.variable(this.getOutputVar(context)), value, bindingId,
BindingForm.General);
stmts.push(new o.ExpressionStatement(currValExpr));
viewStmts.push(...stmts.map(
(stmt: o.Statement) => o.applySourceSpanToStatementIfNeeded(stmt, sourceSpan)));
@ -142,6 +166,27 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
(stmt: o.Statement) => o.applySourceSpanToStatementIfNeeded(stmt, sourceSpan)));
});
if (this.guards.length) {
let guardExpression: o.Expression|undefined = undefined;
for (const guard of this.guards) {
const {context, value} = this.preprocessUpdateExpression(guard.expression);
const bindingId = `${bindingCount++}`;
const nameResolver = context === this.component ? this : defaultResolver;
// We only support support simple expressions and ignore others as they
// are unlikely to affect type narrowing.
const {stmts, currValExpr} = convertPropertyBinding(
nameResolver, o.variable(this.getOutputVar(context)), value, bindingId,
BindingForm.TrySimple);
if (stmts.length == 0) {
const callGuard = this.ctx.importExpr(guard.guard).callFn([currValExpr]);
guardExpression = guardExpression ? guardExpression.and(callGuard) : callGuard;
}
}
if (guardExpression) {
viewStmts = [new o.IfStmt(guardExpression, viewStmts)];
}
}
const viewName = `_View_${componentId}_${this.embeddedViewIndex}`;
const viewFactory = new o.DeclareFunctionStmt(viewName, [], viewStmts);
targetStatements.push(viewFactory);
@ -163,7 +208,12 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
// for the context in any embedded view.
// We keep this behaivor behind a flag for now.
if (this.options.fullTemplateTypeCheck) {
const childVisitor = this.viewBuilderFactory(this);
// Find any applicable type guards. For example, NgIf has a type guard on ngIf
// (see NgIf.ngIfTypeGuard) that can be used to indicate that a template is only
// stamped out if ngIf is truthy so any bindings in the template can assume that,
// if a nullable type is used for ngIf, that expression is not null or undefined.
const guards = this.getTypeGuardExpressions(ast);
const childVisitor = this.viewBuilderFactory(this, guards);
this.children.push(childVisitor);
childVisitor.visitAll(ast.variables, ast.children);
}

View File

@ -8,7 +8,7 @@
import {CompileDirectiveMetadata, CompilePipeSummary, rendererTypeName, tokenReference, viewClassName} from '../compile_metadata';
import {CompileReflector} from '../compile_reflector';
import {BuiltinConverter, EventHandlerVars, LocalResolver, convertActionBinding, convertPropertyBinding, convertPropertyBindingBuiltins} from '../compiler_util/expression_converter';
import {BindingForm, BuiltinConverter, EventHandlerVars, LocalResolver, convertActionBinding, convertPropertyBinding, convertPropertyBindingBuiltins} from '../compiler_util/expression_converter';
import {ArgumentType, BindingFlags, ChangeDetectionStrategy, NodeFlags, QueryBindingType, QueryValueType, ViewFlags} from '../core';
import {AST, ASTWithSource, Interpolation} from '../expression_parser/ast';
import {Identifiers} from '../identifiers';
@ -859,7 +859,7 @@ class ViewBuilder implements TemplateAstVisitor, LocalResolver {
const bindingId = `${updateBindingCount++}`;
const nameResolver = context === COMP_VAR ? self : null;
const {stmts, currValExpr} =
convertPropertyBinding(nameResolver, context, value, bindingId);
convertPropertyBinding(nameResolver, context, value, bindingId, BindingForm.General);
updateStmts.push(...stmts.map(
(stmt: o.Statement) => o.applySourceSpanToStatementIfNeeded(stmt, sourceSpan)));
return o.applySourceSpanToExpressionIfNeeded(currValExpr, sourceSpan);