Check How the Angular Compiler Works…..
The Angular Compiler (which we call ngc
) is the tool used to compile Angular applications and libraries. ngc
is built on the TypeScript compiler (called tsc
) and extends the process of compiling TypeScript code to add additional code generation related to Angular’s capabilities.
Angular’s compiler serves as a bridge between developer experience and run time performance: Angular users author applications against an ergonomic, decorator-based API, and ngc
translates this code into more efficient runtime instructions.
For example, a basic Angular component may look something like this:
import {Component} from '@angular/core';
@Component({
selector: 'app-cmp',
template: '<span>Your name is {{name}}</span>',
})
export class AppCmp {
name = 'Alex';
}
After compilation by ngc
, this component instead looks like:
import { Component } from '@angular/core';
import * as i0 from "@angular/core";
export class AppCmp {
constructor() {
this.name = 'Alex';
}
}
AppCmp.ɵfac = function AppCmp_Factory(t) { return new (t || AppCmp)(); };
AppCmp.ɵcmp = i0.ɵɵdefineComponent({
type: AppCmp,
selectors: [["app-cmp"]],
decls: 2,
vars: 1,
template: function AppCmp_Template(rf, ctx) {
if (rf & 1) {
i0.ɵɵelementStart(0, "span");
i0.ɵɵtext(1);
i0.ɵɵelementEnd();
}
if (rf & 2) {
i0.ɵɵadvance(1);
i0.ɵɵtextInterpolate1("Your name is ", ctx.name, "");
}
},
encapsulation: 2
});
(function () { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassMetadata(AppCmp, [{
type: Component,
args: [{
selector: 'app-cmp',
template: '<span>Your name is {{name}}</span>',
}]
}], null, null); })();
The @Component
decorator has been replaced with several static properties (ɵfac
and ɵcmp
), which describe this component to the Angular runtime and implement rendering and change detection for its template.
In this way, ngc
can be considered an extended TypeScript compiler which also knows how to “execute” Angular decorators, applying their effects to the decorated classes at build time (as opposed to run time).
Inside ngc
ngc
has several important goals:
- Compile Angular decorators, including components and their templates.
- Apply TypeScript’s type-checking rules to component templates.
- Re-compile quickly when the developer makes a change.
Let’s examine how ngc
manages each of these goals.
Compilation Flow
The main goal of ngc
is to compile TypeScript code while transforming recognized Angular decorated classes into more efficient representations for run time. The main flow of Angular compilation proceeds as follows:
- Create an instance of the TypeScript compiler, with some additional Angular functionality.
- Scan every file in the project for decorated classes, and build a model of which components, directives, pipes, NgModules, etc. need to be compiled.
- Make connections between decorated classes (e.g. which directives are used in which component templates).
- Leverage TypeScript to type-check expressions in component templates.
- Compile the whole program, including generating extra Angular code for every decorated class.
Step 1: Creating the TypeScript program
In TypeScript’s compiler, a program to be compiled is represented by a ts.Program
instance. This instance combines the set of files to be compiled, type information from dependencies, and the particular set of compiler options to be used.
Identifying the set of files and dependencies is not straightforward. Often, the user specifies one “entrypoint” file (for example, main.ts
), and TypeScript must look at the imports in that file to discover other files that need to be compiled. Those files have additional imports, which expand to even more files, and so on. Some of these imports point to dependencies: references to code that’s not being compiled, but is used in some way and needs to be known to TypeScript’s type system. These dependency imports are to .d.ts
files, usually in node_modules
.
At this step, the Angular compiler does something special: it adds additional input files to the ts.Program
. For every file written by the user (e.g. my.component.ts
), ngc adds a “shadow” file with an .ngtypecheck
suffix (e.g., my.component.ngtypecheck.ts
). These files are used internally for template type-checking (more on that later).
Depending on compiler options, ngc
may add other files to the ts.Program
, such as .ngfactory
files for backwards compatibility with the previous View Engine architecture.
Step 2: Individual Analysis
In the analysis phase of compilation, ngc
looks for classes with Angular decorators, and attempts to statically understand each decorator. For example, if it encounters an @Component
decorated class, it looks at the decorator and attempts to determine the component’s template, its selector, view encapsulation settings, and any other information about the component which might be needed to generate code for it. This requires the compiler to be capable of an operation known as partial evaluation: reading expressions within decorator metadata and attempting to interpret those expressions without actually running them.
Partial Evaluation
Sometimes information in an Angular decorator is hidden behind an expression. For example, a selector for a component is given as a literal string, but it could also be a constant:
const MY_SELECTOR = 'my-cmp';
@Component({
selector: MY_SELECTOR,
template: '...',
})
export class MyCmp {}
ngc
uses TypeScript’s APIs for navigating code to evaluate the expression MY_SELECTOR
, tracing it back to its declaration and eventually resolving it to the string ‘my-cmp’
. The partial evaluator can understand simple constants; object and array literals; property accesses; imports/exports; arithmetic and other binary operations; and even evaluate calls to simple functions. This feature gives Angular developers more flexibility in how they describe components and other Angular types to the compiler.
Output of Analysis
At the end of the analysis phase, the compiler already has a good picture of what components, directives, pipes, injectables, and NgModules are in the input program. For each of these, the compiler constructs a “metadata” object describing everything it learned from the class’ decorators. By this point, components have had their templates and stylesheets loaded from disk (if necessary), and the compiler may already have produced errors (known in TypeScript as “diagnostics”) if semantic errors are detected in any part of the input so far.
Step 3: Global Analysis
Before it can type-check or generate code, the compiler needs to understand how the various decorated types in the program relate to each other. The primary goal of this step is to understand the NgModule structure of the program.
NgModules
To type-check and generate code, the compiler needs to know which directives, components, and pipes are used in each component’s template. This isn’t straightforward because Angular components don’t directly import their dependencies. Instead, Angular components describe templates using HTML, and potential dependencies are matched against elements in those templates using CSS-style selectors. This enables a powerful abstraction layer: Angular components do not need to know exactly how their dependencies are structured. Instead, each component has a set of potential dependencies (its “template compilation scope”), only a subset of which will end up matching elements in its template.
This indirection is resolved through the Angular @NgModule
abstraction. NgModules can be thought of as composable units of template scope. A basic NgModule may look like:
@NgModule({
declarations: [ImageViewerComponent, ImageResizeDirective],
imports: [CommonModule],
exports: [ImageViewerComponent],
})
export class ImageViewerModule {}
NgModules can be understood as each declaring two different scopes:
- A “compilation scope”, representing the set of potential dependencies that are available to any components declared in the NgModule itself.
- An “export scope”, representing a set of potential dependencies that are made available in the compilation scope of any NgModules which imports the given NgModule.
In the above example, ImageViewerComponent
is a component declared in this NgModule, so its potential dependencies are given by the compilation scope of the NgModule. This compilation scope is the union of all declarations and the export scopes of any NgModules which are imported. Because of this, it’s an error in Angular to declare a component in multiple NgModules. Additionally, a component and its NgModule must be compiled at the same time.
In this case, CommonModule
is imported, so the compilation scope of ImageViewerModule
(and thus ImageViewerComponent
) includes all of the directives and pipes exported by CommonModule
— NgIf
, NgForOf
, AsyncPipe
, and a half dozen others. The compilation scope also includes both declared directives — ImageViewerComponent
and ImageResizeDirective
.
Note that for components, their relationship to the NgModule which declares them is bi-directional: the NgModule both defines the component’s template scope as well as makes that component available in the template scopes of other components.
The above NgModule also declares an “export scope” consisting of the ImageViewerComponent
alone. Other NgModules which import this one will have ImageViewerComponent
added to their compilation scopes. In this way, the NgModule allows for encapsulation of the implementation details of ImageViewerComponent
— internally, it might use the ImageResizeDirective
, but this directive is not made available to consumers of ImageViewerComponent
.
To determine these scopes, the compiler builds a graph of NgModules, their declarations, and their imports and exports, using the information it learned about each class individually from the prior step. It also requires knowledge about dependencies: components and NgModules imported from libraries and not declared in the current program. Angular encodes this information in the .d.ts
files of those dependencies.
.d.ts metadata
During the global analysis phase, the Angular compiler needs to fully enumerate the compilation scope of NgModules declared in the compilation. However, these NgModules may import other NgModules from outside the compilation, from libraries and other dependencies. TypeScript learns about types from such dependencies through declaration files, which have the extension .d.ts
. The Angular compiler uses these .d.ts
declarations to pass along information about Angular types within those dependencies.
For example, the above ImageViewerModule
imports CommonModule
from the @angular/common
package. Partial evaluation of the imports list will resolve the classes named in imports to declarations within the .d.ts
files from those dependencies.
Just knowing the symbol of imported NgModules is not sufficient. To build its graph, the compiler passes information about the declarations, imports, and exports of NgModules through the .d.ts
files in a special metadata type. For example, in the generated declaration file for Angular’s CommonModule
, this (simplified) metadata looks like:
export declare class CommonModule {
static mod: ng.NgModuleDeclaration<CommonModule, [typeof NgClass, typeof NgComponentOutlet, typeof NgForOf, typeof NgIf, typeof NgTemplateOutlet, typeof NgStyle, typeof NgSwitch, typeof NgSwitchCase, typeof NgSwitchDefault, typeof AsyncPipe, ...]>;
// …
}
This type declaration is not intended for type-checking by TypeScript, but instead embeds information (references and other metadata) about Angular’s understanding of the class in question into the type system. From these special types, ngc
can determine the export scope of CommonModule
. Using TypeScript’s APIs to resolve the references within this metadata to those class definitions, it can extract useful metadata regarding the directives/components/pipes themselves:
export declare class NgIf<T> {
// …
static dir: ng.DirectiveDeclaration<NgIf<any>, "[ngIf]", never, { "ngIf": "ngIf"; "ngIfThen": "ngIfThen"; "ngIfElse": "ngIfElse"; }, {}, never>;
}
This gives ngc
sufficient information about the structure of the program to proceed with compilation.
Step 4: Template Type-Checking
ngc
is capable of reporting type errors within Angular templates. For example, if a template attempts to bind a value {{name.first}}
but the name object does not have a first
property, ngc
can surface this issue as a type error. Performing this checking efficiently is a significant challenge for ngc
.
TypeScript by itself has no understanding of Angular template syntax and cannot type-check it directly. To perform this checking, the Angular compiler converts Angular templates into TypeScript code (known as a “Type Check Block”, or TCB) that expresses equivalent operations at the type level, and feeds this code to TypeScript for semantic checking. Any generated diagnostics are then mapped back and reported to the user in the context of the original template.
For example, consider a component with a template that uses ngFor
:
<span *ngFor="let user of users">{{user.name}}</span>
For this template, the compiler wants to check that the user.name
property access is legal. To do this, it must first understand how the type of the loop variable user
is derived through the NgFor
from the input array of users
.
The Type Check Block that the compiler generates for this component’s template looks like:
import * as i0 from './test';
import * as i1 from '@angular/common';
import * as i2 from '@angular/core';
const _ctor1: <T = any, U extends i2.NgIterable<T> = any>(init: Pick<i1.NgForOf<T, U>, "ngForOf" | "ngForTrackBy" | "ngForTemplate">) => i1.NgForOf<T, U> = null!;
/*tcb1*/
function _tcb1(ctx: i0.TestCmp) { if (true) {
var _t1 /*T:DIR*/ /*165,197*/ = _ctor1({ "ngForOf": (((ctx).users /*190,195*/) /*190,195*/) /*187,195*/, "ngForTrackBy": null as any, "ngForTemplate": null as any }) /*D:ignore*/;
_t1.ngForOf /*187,189*/ = (((ctx).users /*190,195*/) /*190,195*/) /*187,195*/;
var _t2: any = null!;
if (i1.NgForOf.ngTemplateContextGuard(_t1, _t2) /*165,216*/) {
var _t3 /*182,186*/ = _t2.$implicit /*178,187*/;
"" + (((_t3 /*199,203*/).name /*204,208*/) /*199,208*/);
}
} }
The complexity here appears to be high, but fundamentally this TCB is performing a specific sequence of operations:
- It first infers the actual type of the
NgForOf
directive (which is generic) from its input bindings. This is named_t1
. - It validates that the users property of the component is assignable to the
NgForOf
input, via the assignment statement_t1.ngForOf = ctx.users
. - Next, it declares a type for the embedded view context of the
*ngFor
row template, named_t2
, with an initial type of any. - Using an
if
with a type guard call, it usesNgForOf
’sngTemplateContextGuard
helper function to narrow the type of_t2
according to howNgForOf
works. - The implicit loop variable (
user
in the template) is extracted from this context and given the name_t3
. - Finally, the access
_t3.name
is expressed.
If the access _t3.name
is not legal by TypeScript’s rules, TypeScript will produce a diagnostic error for this code. Angular’s template type-checker can look at the location of this error in the TCB and use the embedded comments to map the error back to the original template before showing it to the developer.
Since Angular templates contain references to component class properties, they have types from the user’s program. Thus, template type-checking code cannot be checked independently, and must be checked within the context of the user’s whole program (in the above example, the component type is imported from the user’s test.ts
file). ngc
accomplishes this by adding the generated TCBs to the user’s program through an TypeScript incremental build step (generating a new ts.Program
). To avoid thrashing the incremental build cache, type-checking code is added to separate .ngtypecheck.ts
files that the compiler adds to the ts.Program
on creation rather than directly to user files.
Step 5: Emit
When this step begins, ngc
has both understood the program and validated that there are no fatal errors. TypeScript’s compiler is then told to generate JavaScript code for the program. During the generation process, Angular decorators are stripped away and several static fields are added to the classes instead, with the generated Angular code ready to be written out into JavaScript.
If the program being compiled is a library, .d.ts
files are also produced. The files contain embedded Angular metadata that describes how a future compilation can use those types as dependencies.
Being Fast Incrementally
If the above sounds like a lot of work to go through prior to generating code, that’s because it is. While TypeScript and Angular’s logic is efficient, it can still take several seconds to perform all of the parsing, analysis, and synthesis required to produce JavaScript output for the input program. For this reason, both TypeScript and Angular support an incremental compilation mode, where work done previously is reused to more efficiently update a compiled program when a small change is made in the input.
The main problem of incremental compilation is: given a specific change in an input file, the compiler needs to determine which outputs may have changed, and which outputs are safe to reuse. The compiler must be perfect and err on the side of recompiling an output if it cannot be certain that it has not changed.
To solve this problem, the Angular compiler has two main tools: the import graph and the semantic dependency graph.
Import graph
As the compiler is performing partial evaluation operations while analyzing the program for the first time, it builds a graph of critical imports between files. This allows the compiler to understand dependencies between files when something changes.
For example, if the file my.component.ts
has a component, and that component’s selector is defined by a constant imported from selector.ts
, the import graph shows that my.component.ts
depends on selector.ts
. If selector.ts
changes, the compiler can consult this graph and know that my.component.ts
’s analysis results are no longer correct and must be re-done.
The import graph is important for understanding what might change, but it has two major issues:
- It’s overly sensitive to unrelated changes. If
selector.ts
is changed, but that change only adds a comment, thenmy.component.ts
doesn’t really need to be recompiled. - Not all dependencies in Angular applications are expressed via imports. If the selector of
MyCmp
does change, then other components which useMyCmp
in their template might be affected, even though they never importMyCmp
directly.
Both of these issues are addressed via the compiler’s second incremental tool:
Semantic dependency graph
The semantic dependency graph picks up where the import graph leaves off. This graph captures the actual semantics of compilation: how components and directives relate to each other. Its job is to know which semantic changes would require a given output to be reproduced.
For example, if selector.ts
is changed, but the selector of MyCmp
doesn’t change, then the semantic dep graph will know that nothing which semantically affects MyCmp
has changed, and MyCmp
’s previous output can be reused. Conversely, if the selector does change, then the set of components/directives used in other components may change, and the semantic graph will know that those components need re-compiling.
Incrementality
Both graphs therefore work together to provide fast incremental compilation. The import graph is used to determine which analysis needs to be re-done, and the semantic graph is then applied to understand how changes in analysis data propagate through the program and require outputs to be recompiled. The result is a compiler that can efficiently react to changes in inputs, and only do the minimum amount of work to correctly update its outputs in response.
Summary
Angular’s compiler leverages the flexibility of TypeScript’s compiler APIs to deliver correct and performant compilation of Angular templates and classes. Compiling Angular applications allows us to offer a desirable developer experience in the IDE, to give build-time feedback about issues in the code, and to transform that code during the build process into the most efficient JavaScript to run in the browser.