Distributing an Angular Library - The Brief Guide

Edit · Jan 21, 2017 · 10 minutes read · Angular TypeScript

In this post I’ll quickly explain the minimum you need to know in order to publish an Angular component to npm. By the end of the post you’ll know how your module can:

  • Be platform independent (i.e. run in Web Workers, Universal).
  • Should be bundled and distributed.
  • Work with the Angular’s Ahead-of-Time compiler.
  • Play well with TypeScript by allowing autocompletion and compile-time type checking.

If you’re only interested in a quick checklist of things you need to consider for distributing your Angular library, go directly to the “Distributing an Angular Library - Checklist” section.

Note that this article doesn’t aim to provide complete guidelines for developing an npm module. If you’re looking for this, I can recommend you:

Along the way, I’ll provide examples from a module I recently released called ngresizable. ngresizable is a simple component which can make a DOM element resizable.

If you find anything important missing, please don’t hesitate to comment below.

Writing platform independent components

One of the greatest strengths of Angular is that the framework is platform agnostic. Basically, all the modules which have to interact with the underlying platform depend on an abstraction. This is the abstract Renderer. The code you write should also depend on an abstraction, instead of a concrete platform APIs (see the dependency inversion principle). In short, if you’re building your library for the Web, you should not touch the DOM directly because this will make it unable to work in Web Workers and on the server and most developers need that!

Decouple package

Lets take a look at an example:

// ANTI-PATTERN

@Component({
  selector: 'my-zippy',
  template: `
    <section class="zippy">
      <header #header class="zippy-header">{{ title }}</header>
      <section class="zippy-content" id="zippy-content">
        <ng-content></ng-content>
      </section>
    </section>
  `
})
class ZippyComponent {
  @Input() title = '';
  @Input() toggle = true;
  @ViewChild('header') header: ElementRef;

  ngAfterViewInit() {
    this.header.nativeElement.addEventListener('click', () => {
      this.toggle = !this.toggle;
      document.querySelector('#zippy-content').hidden = !this.toggle;
      if (this.toggle) {
        this.header.nativeElement.classList.add('toggled');
      } else {
        this.header.nativeElement.classList.remove('toggled');
      }
    });
  }
}

This snippet is quite coupled to the underlaying platform, and contains plenty of other anti-patterns, for instance we:

  1. Direclty interact with the header DOM element by invoking it’s method addEventListener.
  2. Do not clear the event listener we add to the header element.
  3. Access the classList property of the native header element.
  4. Access property of the global object document, however, document is not available on other platforms.

Lets refactor the code, in order to make it platform agnostic:

// Alright...

@Component({
  selector: 'my-zippy',
  template: `
    <section class="zippy">
      <header #header class="zippy-header">{{ title }}</header>
      <section #content class="zippy-content" id="zippy-content">
        <ng-content></ng-content>
      </section>
    </section>
  `
})
class ZippyComponent implements AfterViewInit, OnDestroy {
  @ViewChild('header') header: ElementRef;
  @ViewChild('content') content: ElementRef;
  @Input() title = '';
  @Input() toggle = true;
  
  private cleanCallback: any;

  constructor(private renderer: Renderer) {}

  ngAfterViewInit() {
    this.cleanCallback = this.renderer.listen(this.header.nativeElement, 'click', () => {
      this.toggle = !this.toggle;
      this.renderer.setElementProperty(this.content.nativeElement, 'hidden', !this.toggle);
      this.renderer.setElementClass(this.header.nativeElement, 'toggled', this.toggle);
    });
  }

  ngOnDestroy() {
    if (typeof this.cleanCallback === 'function')
      this.cleanCallback();
  }
}

The code above looks better. It’ll work on multiple platforms because we use the Renderer instead of directly manipulating the DOM and accessing globals.

Although it works, we can do a bit better. We do a lot of manual things, for instance, imperatively listening for the click event and after that removing the listener:

// Best

@Component({
  selector: 'my-zippy',
  template: `
    <section class="zippy">
      <header (click)="toggleZippy()" [class.toggled]="toggle"
        class="zippy-header">{{ title }}</header>
      <section class="zippy-content" [hidden]="!toggle">
        <ng-content></ng-content>
      </section>
    </section>
  `
})
class ZippyComponent implements AfterViewInit, OnDestroy {
  @Input() title = '';
  @Input() toggle = true;

  toggleZippy() {
    this.toggle = !this.toggle;
  }
}

The code above contains a better implementation, which is platform agnostic and also testable (we toggle the visibility of the content in the method toggleZippy so we can write tests for this easier).

Distributing the components

The components’ distribution is not a trivial problem. Even Angular went through several different structures of their npm modules. The things we need to consider for our packages are:

  1. They should be tree-shakable. Tree-shaking is exclusively used for producing a production bundle, because it allows us to drop unused exports.
  2. Developers should be able to use them as easy as possible in development mode, i.e. no transpilation of any kind should be required.
  3. We need to keep the package lean to save network bandwidth and download time.
Distribute package

In order to keep the module tree-shakable, we need to distribute it in a way that it uses ES2015 modules (also known as esm). By having it in this format bundlers, such as Rollup and Webpack, will be able to get rid of unused exports.

For this purpose we can use tsc so our tsconfig.json should look something like:

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "sourceMap": true,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "outDir": "./dist",
    "lib": ["es2015", "dom"]
  },
  "files": [
    "./lib/ngresizable.module.ts"
  ]
}

This is a configuration file copied from ngresizable.

If we want to make the life of people who are using our package easier, we should also provide ES5 version of it. Here are two options to be considered:

  • Distribute it in two directories - esm and es5 which respectively contain two different versions of our module’s JavaScript files:
    • esm - contains an ES5 version that uses ES2015 modules.
    • es5 - contains an ES5 version of the package which doesn’t use any ES2015 syntax (i.e. the modules are in commonjs, amd, system or UMD format).
  • Distribute the packages with esm and also provide ES5 UMD (Universal Module Definition) bundle.

The second approach has a few advantages:

  • We don’t bloat the package with additional files - we have only the esm version of our package and a single bundle which contains everything else.
  • When developers use our package in development with SystemJS their browser can load the entire library with only a single request to the UMD bundle. In contrast, if we distribute the package without bundling the module but providing it as individual files instead, SystemJS will send request for each file. Once your project grows this can become inconvenient by slowing down each page refresh dramatically.

It doesn’t matter much which tool we’d choose for producing the UMD ES5 bundle. Google, for instance, uses rollup for bundling Angular, which is also the case for ngresizable. It’s pretty neat that you can compile your module to ES5 and ES2015 modules. Later your bundler can translate the ES2015 module syntax to UMD so you don’t need any additional steps of transpilation.

In the end, since we don’t have to complicate the directory structure of the package additionally, we can simply output both, the esm version of our code and the UMD bundle, in the root of directory of the distribution.

Configuring the package

So, now we have two different versions of our code esm and ES5 UMD bundle. The question is what entry file in package.json should we provide? We want bundlers which understand esm to use the ES2015 modules, and bundles which don’t know how to use ES2015 modules to use the UMD bundle instead.

In order to do this we can:

  • Set the value of the main property of package.json to point to the ES5 UMD bundle.
  • Set the value of the module property to point to the entry file of the esm version of the library. module is a field in package.json where bundlers such as rollup and webpack 2 expect to find a reference to the ES2015 version of the code. Note that some older versions of the bundlers use jsnext:main instead of module so we need to set both properties.

Our final package.json should look something like:

{
  ...
  "main": "ngresizable.bundle.js",
  "module": "ngresizable.module.js",
  "jsnext:main": "ngresizable.module.js",
  ...
}

So far so good, but that’s not all!

Providing type definitions

Since most likely the users of the package will use TypeScript, we need to provide type definitions to them. To do this, we need to enable the declaration flag in tsconfig.json and set the types field of our package.json.

tsconfig.json should look like:

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "sourceMap": true,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "declaration": true,
    "outDir": "./dist",
    "lib": ["es2015", "dom"]
  },
  "files": [
    "./lib/ngresizable.module.ts"
  ]
}

…and respectively package.json:

{
  ...
  "main": "ngresizable.bundle.js",
  "module": "ngresizable.module.js",
  "jsnext:main": "ngresizable.module.js",
  "types": "ngresizable.module.d.ts",
  ...
}

Compatibility with Angular’s AoT Compiler

Ahead-of-Time compilation is a great feature and we need to develop & distribute our modules compatible with the compiler.

Compatible with compiler

If we distribute our module as JavaScript without any additional metadata, users who depend on them will not be able to compile their Angular application. But how can we provide metadata to ngc? Well, we can include the TypeScript version of our modules as well…but it’s not required.

Instead, we should precompile our module with ngc and enable the skipTemplateCodegen flag in tsconfig.json’s angularCompilerOptions. After the last update our tsconfig.json will look like:

{
  "compilerOptions": {
    "target": "es5",
    "module": "es2015",
    "sourceMap": true,
    "moduleResolution": "node",
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true,
    "declaration": true,
    "outDir": "./dist",
    "lib": ["es2015", "dom"]
  },
  "files": [
    "./lib/ngresizable.module.ts"
  ],
  "angularCompilerOptions": {
    "skipTemplateCodegen": true
  }
}

By default ngc generates ngfactories for the components and modules. By using skipTemplateCodegen flag we can skip this and only get *.metadata.json files.

Recap

After applying all these build steps, the final structure of the ngresizable module looks like this:

.
├── README.md
├── ngresizable.actions.d.ts
├── ngresizable.actions.js
├── ngresizable.actions.js.map
├── ngresizable.actions.metadata.json
├── ngresizable.bundle.js
├── ngresizable.component.d.ts
├── ngresizable.component.js
├── ngresizable.component.js.map
├── ngresizable.component.metadata.json
├── ngresizable.module.d.ts
├── ngresizable.module.js
├── ngresizable.module.js.map
├── ngresizable.module.metadata.json
├── ngresizable.reducer.d.ts
├── ngresizable.reducer.js
├── ngresizable.reducer.js.map
├── ngresizable.reducer.metadata.json
├── ngresizable.store.d.ts
├── ngresizable.store.js
├── ngresizable.store.js.map
├── ngresizable.store.metadata.json
├── ngresizable.utils.d.ts
├── ngresizable.utils.js
├── ngresizable.utils.js.map
├── ngresizable.utils.metadata.json
└── package.json

As recap, notice that in the final package we have:

  • ngresizable.bundle.js - an ES5 UMD bundle of the module.
  • esm ES5 version of our code - allows tree-shaking.
  • *.js.map - source map files for easier debugging.
  • *.metadata.json - metadata required by ngc to do its job.
  • *.d.ts - type definitions which allow TypeScript to perform compile-time type checking and allow intellisense for our library.

Other topics

Very important topic that needs to be considered is related to following the style guide. Your modules should follow best practices especially when with given practice you can impact a dependent project. For further information visit angular.io/styleguide.

For instance, the ngresizable component violates a practice from the styleguide:

  • It uses a component as an attribute. This is a practice which violation is acceptable in this specific case because of implementation details of the component.

Note that using ng as prefix of selector for your directives/components is not recommended because of possible collisions with components coming from Google.

Distributing an Angular Library - Checklist

In this section we’ll wrap things up, by briefly mentioning each point you need to consider:

  • Try not to directly access DOM APIs (i.e. follow the dependency inversion principle).
  • Provide esm version of your library in order to allow tree-shaking.
    • Reference the esm version under the module and jsnext:main properties in package.json.
  • Provide ES5 UMD bundle of your library.
    • Reference the bundle under the main property of your package.json.
  • Provide the type definitions of your library by generating them with tsc with the declaration flag set to true.
    • Reference the type definitions corresponding to the main module of your package in the types property in your package.json.
  • Compile your library with ngc and include the generated *.metadata.json files in your package. The tsconfig.json used by ngc should have skipTemplateCodegen set to true, under angularCompilerOptions.

Conclusion

In this blog post we briefly explained the most important things you need to consider when it comes to distributing your Angular library.

We explained how to keep the library decoupled from the underlying platform. After that, we went to distributing our code in a way that it’s tree-shakable and has minimum overhead over the user. As next section we described how to make the library friendly to the Angular’s Ahead-of-Time compiler.

Finally, we summarized all the practices in a short list which can serve you as a checklist.