Permalink
Browse files

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
  • Loading branch information...
chuckjaz authored and jasonaden committed Nov 30, 2017
1 parent e544742 commit e7d9cb3e4ccbd491e5bde166f6d2f292d7da9223
@@ -151,6 +151,8 @@ export class NgIf {
}
}
}
public static ngIfTypeGuard: <T>(v: T|null|undefined|false) => v is T;
}
/**
@@ -81,6 +81,141 @@ describe('ng type checker', () => {
});
});
describe('type narrowing', () => {
const a = (files: MockFiles, options: object = {}) => {
accept(files, {fullTemplateTypeCheck: true, ...options});
};
it('should narrow an *ngIf like directive', () => {
a({
'src/app.component.ts': '',
'src/lib.ts': '',
'src/app.module.ts': `
import {NgModule, Component, Directive, HostListener, TemplateRef, Input} from '@angular/core';
export interface Person {
name: string;
}
@Component({
selector: 'comp',
template: '<div *myIf="person"> {{person.name}} </div>'
})
export class MainComp {
person?: Person;
}
export class MyIfContext {
public $implicit: any = null;
public myIf: any = null;
}
@Directive({selector: '[myIf]'})
export class MyIf {
constructor(templateRef: TemplateRef<MyIfContext>) {}
@Input()
set myIf(condition: any) {}
static myIfTypeGuard: <T>(v: T | null | undefined | false) => v is T;
}
@NgModule({
declarations: [MainComp, MyIf],
})
export class MainModule {}`
});
});
it('should narrow a renamed *ngIf like directive', () => {
a({
'src/app.component.ts': '',
'src/lib.ts': '',
'src/app.module.ts': `
import {NgModule, Component, Directive, HostListener, TemplateRef, Input} from '@angular/core';
export interface Person {
name: string;
}
@Component({
selector: 'comp',
template: '<div *my-if="person"> {{person.name}} </div>'
})
export class MainComp {
person?: Person;
}
export class MyIfContext {
public $implicit: any = null;
public myIf: any = null;
}
@Directive({selector: '[my-if]'})
export class MyIf {
constructor(templateRef: TemplateRef<MyIfContext>) {}
@Input('my-if')
set myIf(condition: any) {}
static myIfTypeGuard: <T>(v: T | null | undefined | false) => v is T;
}
@NgModule({
declarations: [MainComp, MyIf],
})
export class MainModule {}`
});
});
it('should narrow a type in a nested *ngIf like directive', () => {
a({
'src/app.component.ts': '',
'src/lib.ts': '',
'src/app.module.ts': `
import {NgModule, Component, Directive, HostListener, TemplateRef, Input} from '@angular/core';
export interface Address {
street: string;
}
export interface Person {
name: string;
address?: Address;
}
@Component({
selector: 'comp',
template: '<div *myIf="person"> {{person.name}} <span *myIf="person.address">{{person.address.street}}</span></div>'
})
export class MainComp {
person?: Person;
}
export class MyIfContext {
public $implicit: any = null;
public myIf: any = null;
}
@Directive({selector: '[myIf]'})
export class MyIf {
constructor(templateRef: TemplateRef<MyIfContext>) {}
@Input()
set myIf(condition: any) {}
static myIfTypeGuard: <T>(v: T | null | undefined | false) => v is T;
}
@NgModule({
declarations: [MainComp, MyIf],
})
export class MainModule {}`
});
});
});
describe('regressions ', () => {
const a = (files: MockFiles, options: object = {}) => {
accept(files, {fullTemplateTypeCheck: true, ...options});
@@ -1038,6 +1038,25 @@ describe('Collector', () => {
expect(metadata).toBeUndefined();
});
it('should collect type guards', () => {
const metadata = collectSource(`
import {Directive, Input, TemplateRef} from '@angular/core';
@Directive({selector: '[myIf]'})
export class MyIf {
constructor(private templateRef: TemplateRef) {}
@Input() myIf: any;
static typeGuard: <T>(v: T | null | undefined): v is T;
}
`);
expect((metadata.metadata.MyIf as any).statics.typeGuard)
.not.toBeUndefined('typeGuard was not collected');
});
it('should be able to collect an invalid access expression', () => {
const source = createSource(`
import {Component} from '@angular/core';
@@ -271,7 +271,7 @@ export class AotCompiler {
const {template: parsedTemplate, pipes: usedPipes} =
this._parseTemplate(compMeta, moduleMeta, directives);
ctx.statements.push(...this._typeCheckCompiler.compileComponent(
componentId, compMeta, parsedTemplate, usedPipes, externalReferenceVars));
componentId, compMeta, parsedTemplate, usedPipes, externalReferenceVars, ctx));
}
emitMessageBundle(analyzeResult: NgAnalyzedModules, locale: string|null): MessageBundle {
@@ -29,6 +29,7 @@ const IGNORE = {
const USE_VALUE = 'useValue';
const PROVIDE = 'provide';
const REFERENCE_SET = new Set([USE_VALUE, 'useFactory', 'data']);
const TYPEGUARD_POSTFIX = 'TypeGuard';
function shouldIgnore(value: any): boolean {
return value && value.__symbolic == 'ignore';
@@ -43,6 +44,7 @@ export class StaticReflector implements CompileReflector {
private propertyCache = new Map<StaticSymbol, {[key: string]: any[]}>();
private parameterCache = new Map<StaticSymbol, any[]>();
private methodCache = new Map<StaticSymbol, {[key: string]: boolean}>();
private staticCache = new Map<StaticSymbol, string[]>();
private conversionMap = new Map<StaticSymbol, (context: StaticSymbol, args: any[]) => any>();
private injectionToken: StaticSymbol;
private opaqueToken: StaticSymbol;
@@ -251,6 +253,18 @@ export class StaticReflector implements CompileReflector {
return methodNames;
}
private _staticMembers(type: StaticSymbol): string[] {
let staticMembers = this.staticCache.get(type);
if (!staticMembers) {
const classMetadata = this.getTypeMetadata(type);
const staticMemberData = classMetadata['statics'] || {};
staticMembers = Object.keys(staticMemberData);
this.staticCache.set(type, staticMembers);
}
return staticMembers;
}
private findParentType(type: StaticSymbol, classMetadata: any): StaticSymbol|undefined {
const parentType = this.trySimplify(type, classMetadata['extends']);
if (parentType instanceof StaticSymbol) {
@@ -273,6 +287,21 @@ export class StaticReflector implements CompileReflector {
}
}
guards(type: any): {[key: string]: StaticSymbol} {
if (!(type instanceof StaticSymbol)) {
this.reportError(
new Error(`guards received ${JSON.stringify(type)} which is not a StaticSymbol`), type);
return {};
}
const staticMembers = this._staticMembers(type);
const result: {[key: string]: StaticSymbol} = {};
for (let name of staticMembers) {
result[name.substr(0, name.length - TYPEGUARD_POSTFIX.length)] =
this.getStaticSymbol(type.filePath, type.name, [name]);
}
return result;
}
private _registerDecoratorOrConstructor(type: StaticSymbol, ctor: any): void {
this.conversionMap.set(type, (context: StaticSymbol, args: any[]) => new ctor(...args));
}
@@ -254,6 +254,7 @@ export interface CompileDirectiveSummary extends CompileTypeSummary {
providers: CompileProviderMetadata[];
viewProviders: CompileProviderMetadata[];
queries: CompileQueryMetadata[];
guards: {[key: string]: any};
viewQueries: CompileQueryMetadata[];
entryComponents: CompileEntryComponentMetadata[];
changeDetection: ChangeDetectionStrategy|null;
@@ -268,8 +269,8 @@ export interface CompileDirectiveSummary extends CompileTypeSummary {
*/
export class CompileDirectiveMetadata {
static create({isHost, type, isComponent, selector, exportAs, changeDetection, inputs, outputs,
host, providers, viewProviders, queries, viewQueries, entryComponents, template,
componentViewType, rendererType, componentFactory}: {
host, providers, viewProviders, queries, guards, viewQueries, entryComponents,
template, componentViewType, rendererType, componentFactory}: {
isHost: boolean,
type: CompileTypeMetadata,
isComponent: boolean,
@@ -282,6 +283,7 @@ export class CompileDirectiveMetadata {
providers: CompileProviderMetadata[],
viewProviders: CompileProviderMetadata[],
queries: CompileQueryMetadata[],
guards: {[key: string]: any};
viewQueries: CompileQueryMetadata[],
entryComponents: CompileEntryComponentMetadata[],
template: CompileTemplateMetadata,
@@ -336,6 +338,7 @@ export class CompileDirectiveMetadata {
providers,
viewProviders,
queries,
guards,
viewQueries,
entryComponents,
template,
@@ -358,6 +361,7 @@ export class CompileDirectiveMetadata {
providers: CompileProviderMetadata[];
viewProviders: CompileProviderMetadata[];
queries: CompileQueryMetadata[];
guards: {[key: string]: any};
viewQueries: CompileQueryMetadata[];
entryComponents: CompileEntryComponentMetadata[];
@@ -367,10 +371,27 @@ export class CompileDirectiveMetadata {
rendererType: StaticSymbol|object|null;
componentFactory: StaticSymbol|object|null;
constructor({isHost, type, isComponent, selector, exportAs,
changeDetection, inputs, outputs, hostListeners, hostProperties,
hostAttributes, providers, viewProviders, queries, viewQueries,
entryComponents, template, componentViewType, rendererType, componentFactory}: {
constructor({isHost,
type,
isComponent,
selector,
exportAs,
changeDetection,
inputs,
outputs,
hostListeners,
hostProperties,
hostAttributes,
providers,
viewProviders,
queries,
guards,
viewQueries,
entryComponents,
template,
componentViewType,
rendererType,
componentFactory}: {
isHost: boolean,
type: CompileTypeMetadata,
isComponent: boolean,
@@ -385,6 +406,7 @@ export class CompileDirectiveMetadata {
providers: CompileProviderMetadata[],
viewProviders: CompileProviderMetadata[],
queries: CompileQueryMetadata[],
guards: {[key: string]: any},
viewQueries: CompileQueryMetadata[],
entryComponents: CompileEntryComponentMetadata[],
template: CompileTemplateMetadata|null,
@@ -406,6 +428,7 @@ export class CompileDirectiveMetadata {
this.providers = _normalizeArray(providers);
this.viewProviders = _normalizeArray(viewProviders);
this.queries = _normalizeArray(queries);
this.guards = guards;
this.viewQueries = _normalizeArray(viewQueries);
this.entryComponents = _normalizeArray(entryComponents);
this.template = template;
@@ -430,6 +453,7 @@ export class CompileDirectiveMetadata {
providers: this.providers,
viewProviders: this.viewProviders,
queries: this.queries,
guards: this.guards,
viewQueries: this.viewQueries,
entryComponents: this.entryComponents,
changeDetection: this.changeDetection,
@@ -17,6 +17,7 @@ export abstract class CompileReflector {
abstract annotations(typeOrFunc: /*Type*/ any): any[];
abstract propMetadata(typeOrFunc: /*Type*/ any): {[key: string]: any[]};
abstract hasLifecycleHook(type: any, lcProperty: string): boolean;
abstract guards(typeOrFunc: /* Type */ any): {[key: string]: any};
abstract componentModuleUrl(type: /*Type*/ any, cmpMetadata: Component): string;
abstract resolveExternalReference(ref: o.ExternalReference): any;
}
@@ -90,14 +90,23 @@ export class ConvertPropertyBindingResult {
constructor(public stmts: o.Statement[], public currValExpr: o.Expression) {}
}
export enum BindingForm {
// The general form of binding expression, supports all expressions.
General,
// Try to generate a simple binding (no temporaries or statements)
// otherise generate a general binding
TrySimple,
}
/**
* Converts the given expression AST into an executable output AST, assuming the expression
* is used in property binding. The expression has to be preprocessed via
* `convertPropertyBindingBuiltins`.
*/
export function convertPropertyBinding(
localResolver: LocalResolver | null, implicitReceiver: o.Expression,
expressionWithoutBuiltins: cdAst.AST, bindingId: string): ConvertPropertyBindingResult {
expressionWithoutBuiltins: cdAst.AST, bindingId: string,
form: BindingForm): ConvertPropertyBindingResult {
if (!localResolver) {
localResolver = new DefaultLocalResolver();
}
@@ -110,6 +119,8 @@ export function convertPropertyBinding(
for (let i = 0; i < visitor.temporaryCount; i++) {
stmts.push(temporaryDeclaration(bindingId, i));
}
} else if (form == BindingForm.TrySimple) {
return new ConvertPropertyBindingResult([], outputExpr);
}
stmts.push(currValExpr.set(outputExpr).toDeclStmt(null, [o.StmtModifier.Final]));
@@ -51,6 +51,7 @@ export interface Directive {
providers?: Provider[];
exportAs?: string;
queries?: {[key: string]: any};
guards?: {[key: string]: any};
}
export const createDirective =
makeMetadataFactory<Directive>('Directive', (dir: Directive = {}) => dir);
Oops, something went wrong.

0 comments on commit e7d9cb3

Please sign in to comment.