DEV Community

Nick Matantsev
Nick Matantsev

Posted on • Edited on • Originally published at unframework.com

TypeScript front-end library compilation for publishing on NPM

I have recently been going through the process of packaging and publishing a React UI widget on NPM (React CSV Importer) and wanted to document some of the technical nuances encountered along the way.

Please note that there are more comprehensive publishing guides out there, such as this one; here I want to focus on my experience with a specific aspect of the process: the compilation pipeline for the library.

Overview

I am a big fan of TypeScript and use it almost exclusively when writing React front-end apps. There are plenty of tools and patterns that help compile and bundle TypeScript for final deployment to the browser. However, when publishing a library, the build/packaging pipeline has key differences in requirements.

A published front-end library should provide the following:

  • JavaScript code included by apps
  • TypeScript typings (.d.ts file for TS apps)

This will be installed and referenced by the applications that are consuming our library (referred to as "consuming app" further on). Because those consuming apps have their own build pipelines and language expectations, we have to keep the above output compliant with those expectations. Let's go over them in some detail.

Generating JavaScript Code

In the most minimal case, one could simply package up and publish the original TypeScript source code; of course, that excludes a large chunk of consuming apps that cannot use TypeScript for various reasons. This is why we need to compile to JavaScript output before publishing.

Unlike a regular app, our library's JavaScript code does not have to be bundled and minified into a single file. We can assume that whichever app consumes our library will have its own Webpack/Rollup/etc setup, so we do not need to perform any of that ourselves.

The simplest build pipeline then is to just run tsc:

# output goes into dist folder (cleaned first using rimraf)
rimraf dist && tsc --outDir dist
Enter fullscreen mode Exit fullscreen mode

To produce the correct "flavour" of JavaScript output, the tsconfig.json file should include the following in addition to your other settings:

{
  "compilerOptions": {
    "target": "ES6", // change to ES5 for much older browsers
    "module": "CommonJS", // change to ES2015 or ESNext for ES module syntax output
    "isolatedModules": true, // may help catch isolation issues
    ... other options ...
  },
  "include": ["src"] // change as needed
}
Enter fullscreen mode Exit fullscreen mode

The generated JavaScript files will get bundled by the consuming app, but they will most likely not get transpiled for legacy browser compatibility. In other words, what you produce is what will run in the browser or server-side Node process directly (as happens while unit testing or pre-rendering page contents). This is why TypeScript target should be fairly conservative: e.g. ES6 is probably good enough for most browsers/environments that will run your code at the moment.

Your TypeScript source files reference each other and third-party module dependencies via import statements. The module setting controls what happens to that import syntax in the resulting JS output. This matters because this will be parsed by the consuming app's Webpack/Rollup/etc bundler, and older versions of bundlers may not recognize the import keyword. Also, if your code runs in a server-side Node process, the runtime might not support it either. Setting module to CommonJS will result in imports being output as require() calls, which is supported most broadly at the moment.

Once you produce your output (in the dist folder in the above example), you might want to refer to the main entry point of your library by adding this to your published package.json:

{
  ...
  "main": "dist/index.js" // change to your entry .js output
  ...
}
Enter fullscreen mode Exit fullscreen mode

This way when the consuming app imports your library it will be loading the correct file under dist.

There might be more complex situations where simple tsc is not enough to build your library. You may want to set up Babel to perform the transpilation for TypeScript alongside other source formats, e.g. PostCSS for stylesheet theming. Or, you may want to rely on Webpack or Rollup to do the same plus also bundle the files together (which is especially useful for libraries that allow a "raw" option - inclusion via script tags). This post cannot document all these possible advanced use cases, of course, but hopefully this provides a starting point for further research.

Generating Typings

When your tsc produces JavaScript output, all the type information (interface declarations, function args and return types) is lost. Hence we want to gather up the typings that were lost and expose them to the consuming app - that is usually referred to as the .d.ts or "DTS" file.

The TypeScript compiler has an option to produce typings for every file that it processes, but this is not very useful for us! A lot of internal types should never be exposed to the consuming app, but tsc has no awareness of what is "internal" versus "external" to the library - so its output will be way too big and include all the unnecessary internal type information.

For small libraries, the simplest thing to do is to "cheat" a bit. Move externally-visible type declarations in your source code to a central file named something like exports.ts and import it in your other source files as usual. Then, before publishing, do the following:

cp src/exports.ts dist/index.d.ts
Enter fullscreen mode Exit fullscreen mode

That's it. All you need to do then is add this to your package.json:

{
  ...
  "types": "dist/index.d.ts"
  ...
}
Enter fullscreen mode Exit fullscreen mode

The consuming app's TypeScript compiler will now consult your typings file and will be able to perform necessary type safety checks downstream.

For more advanced use cases, there are helpers such as dts-bundle-generator. This type of tool will "intelligently" read through your source code, starting with your library entry-point, and collect exposed type information while discarding anything that is purely internal to the library. There are plenty of new caveats that come with this approach, so that deserves a separate write-up.

Review

This post has described a very basic starter build pipeline for a TypeScript-based front-end NPM module. You will need to figure out a few other things before running "npm publish", but hopefully this provides a reference point for further work. Happy publishing!

Top comments (0)