How to create strongly-typed npm packages

Michel Weststrate
8 min readDec 1, 2015

Note, this blog was written for TypeScript 1 and is frankly quite outdated. TypeScript 2 has introduced some major improvements, like providing @types/package packages, that can be set as peer or dev dependencies, avoiding many of the problems described below. So the most important take away when using TypeScript 2: enable "declarations" in the compiler options and set the"typings" field in package.json

I've been using TypeScript for over more than a year now on a full-time basis. And whenever people asked what I think of TypeScript, my answer always was “TypeScript is awesome and a real life saver. But the module system sucks big time.” Using a an arbitrary npm package in a strongly-typed fashion is quite a hassle. So let alone publishing one. But with the introduction of TypeScript 1.6 this has finally changed. TypeScript now follows the npm rules for module resolution. Sadly, so far this goodness has barely been documented although there are a lot of questions around the subject. In this blog post I’ll try address most questions.

In this blog post I will introduce two imaginary packages, ‘petstore’ and ‘pets’. The pets package just exposes a ‘Cat’ class that can ‘meow()’. The fun thing with TypeScript 1.6 is that if you set up your package properly, you will be able to just..

npm install pets --save

..in the petstore package folder and then consume the package directly from its TypeScript sources:

A plain ES6 style import is enough for a strongly-typed Cat class:

import {Cat} from “pets”

No further triple slash references, .d.ts files or tsd installs are required. Sounds promising right? But first, let’ s dive into the horrible pre-1.6 situation to make this blog a real emotional roller-coaster.

TypeScript npm packages prior 1.6

Feel free to skip this section to jump to the solution. It’s like the old- and new testament; you just will get a deeper understanding of the Good News if you study the old covenant.

Publishing strongly-typed packages before 1.6 was cumbersome because you had to distribute the typings of your package separately from the actual, compiled, package. You might wonder, why don’t you just ship the TypeScript source files with the packages then? Well, that introduces quite some problems:

  1. You get ugly imports, like import {Cat} from “../node_modules/pets/src/cat.ts”.
  2. It bypasses the ‘main’ entry point of npm packages.
  3. It blows up most TypeScript tools as you will need refer to files outside the project sources.
  4. The package must be compatible with your compiler version.
  5. It is slow to compile additional packages as part of your build process.

In other words: don’t publish TypeScript source files as npm package. So what was the alternative? Answer: Ambient module declarations. Ambient module declarations allow you to ship the typings of a package separately from the package itself. The idea is pretty straightforward: An ambient module declaration specifies the type information of some global variable by name. An ambient module declaration looks like:

declare module "pets" {
export class Cat {
meow();
}
}

After that, you refer to this typings file from the consuming project using so called triple slash references:

/// <reference path="typings/pets/pets.d.ts" />
import {Cat} from "pets";

TypeScript can now determine the type of ‘Cat’ correctly because there is an import statement and an ambient module declaration which use exactly the same name; “pets” (including the double quotes!). That might seem quite OK at first sight, but those ambient declarations cause a lot of trouble:

  1. You will have to write and supply ambient module declarations manually! The compiler has no built-in option to emit those declarations. As you can imagine, in no-time these declarations will be out of sync with the real sources. There are some community packages that can generate those files though but even those get you only halfway usually.
  2. You have to ship ambient declarations separately from your package. If you ship them with your package and require them with a triple slash reference like path=”./node_modules/pets/index.d.ts” the build of your package consumers will break as soon as the pets module exists twice in your module tree.
  3. Ambient declarations and package versioning is a big issue; publicly available typings are often behind. And even if they aren’t, your local copies still might be behind. Or your downloaded typings do not (exactly) match the actual version of the package you are using, which leads to confusing bugs.
  4. A fourth problem is that your module might require a module with an ambient module declaration which is also required by another module. For example both the express and cats package might require (typings for) the lodash package. Because these declarations live in a global namespace and are matched by name, they collide in no-time with each other. You know that you did ran into this issue as soon as you encounter an error like this one (…and probably a couple of hundred like these at the same time):
lib.core.d.ts(83,5): error TS2300: Duplicate identifier 'configurable'

To work around these issues the community has established some conventions and some tools (especially tsd) to make stuff a more bearable. The definitely typed repository is nowadays the primary source for ambient module declarations. So just remember; ambient module declarations are the root of all evil.

Ambient module declarations are the root of all evil.

Creating strongly-typed packages

As said, TypeScript 1.6 changed things. From TypeScript 1.6 onward, you can specify a ‘typings’ field in your package.json file to signal the compiler where the typings of this package can be found. This offers some really cool advantages:

  1. Typings will now ship with your package and can be emitted and consumed directly by the compiler. So they are always in sync!
  2. Compiler emitted typings are not ambient so they won’t pollute the global namespace. You can safely use the same package multiple times in your dependency tree without risking conflicts. To setup our strongly-typed ‘cats’ package we just need the following configuration:

To setup our strongly-typed package we just need the following configuration:

=== package.json ===
{
"name": "pets",
"main": "lib/index.js",
"typings": "lib/index",
"files": [
"lib/"
]
// etc
}
=== tsconfig.json ===
{
"version": "1.6.2",
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"outDir": "lib/"
},
"files": [
"./src/index.ts"
]
}

Some explanation on this setup:

  1. Similar to ‘main’, package.json now also specifies an entry point for the ‘typings’. It maps .js files to corresponding .d.ts files with the same name. It's just the entry point, you can still import other files directly, like for example import {Dog} from "pets/lib/dogs". TypeScript will resolve these paths and typings in a similar fashion as npm does.
  2. It is recommended to specify ‘files’ in your package.json (or use a .npmignore file) to keep your TypeScript sources outside the actual package that will be submitted to npm. As rule of thumb: *.ts files should be in version control but not in the package, *.d.ts and *.js files should be in the package but not in version control. To make this easier I recommend to specify the ‘outDir’ compiler setting in tsconfig.json.
  3. For those unfamiliar with tsconfig.json files; these define your compiler options in a convenient way. You should really use them as it allows your build tools (like tsc, ts-loader, grunt-ts or tsify) and your IDE to all use the same compiler configuration.
  4. The compiler option ‘declaration’ must be enabled to signal TypeScript that it should emit typing files in the output directory. Furthermore a module system needs to be specified to make your module consumable from Nodejs. ‘commonjs’ is the recommended setting.

That's all! You can now npm publish your module and it can be used directly by any 1.6+ TypeScript project without the need to import additional typings. Awesome, right?

Ambient module declarations (again)

Sadly, this is not the entire story. This all works like a charm as long as you only use strongly-typed packages that leverage this system as well. But a lot of npm packages are not written in TypeScript and do not provide their own typings. If you want to use those packages in a strongly typed way, in either your implementation or in your public API, you still need those pesky ambient typings. (There is a new initiative, the typings project, that tries to provide non-ambient typings for external packages).

Can we combine ambient module declarations with this new system? It depends. If you use the ambient module declarations only in the implementation and not in the api of your package; just add the declaration files to your compiler sources in tsconfig.json (to avoid triple slash references). So, here is my advice on using external ambient module declarations in combination with the new system:

  1. You can safely use ambient typings in your sources as long as the public interface of your module doesn’t depend anywhere on it. Since the generated *.d.ts files for your package do not contain implementation details, package consumers don’t need the definitions of types that were only used in your implementation.
  2. If you really need some external typings in your api (for example when exposing promises), consider write non-ambient typings for that package and submit a pull request at the typings project.
  3. The last resort is to make it the responsibility of the consumers of your package to install the required typings, for example by running tsd install <something you require> — save in the root of their project. Make sure you document this very well, as it will be confusing for a lot of your users. This is basically the same as the pre TypeScript-1.6 setup, except that at least the typings of your module itself don’t have to be published and are always in sync.

Ts-npm-lint

As mentioned, I’ve published a small tool that will analyze your package.json, tsconfig.json and tsd.json files and the dependencies of your package to give you hints about the current setup of your package. It will analyze whether you leak triple slash references, report which modules might need to be installed by consumers of your package etc. Just run ‘ts-npm-lint’ in the root of your project, read the hints, adapt your setup and repeat until you are satisfied. ‘ts-npm-lint’ can be installed through npm:

npm install -g ts-npm-lint

Update 7 Oct 2016: the ` — noResolve` TypeScript compiler option achieve the same.

Namespaces

Experienced TypeScript users might have noticed that I didn’t talk about TypeScript ‘namespaces’ so far (also called ‘internal modules’). The reason for this is simple; don’t use them. Or at least do not create multi-file modules. It doesn’t fit into the commonjs system and requires you to manually write typings. I’ve already made the mistake a few times to quickly set up a new project by using namespaces and in the end I always had to refactor the code base to get rid of them.

Conclusion

So, TL;DR:

The TypeScript support for typings in npm modules is a very powerful feature that so far didn’t get the attention it deserves.

  1. The TypeScript support for ‘typings’ in npm modules is a very powerful feature that so far didn’t get the attention it deserves: It makes your npm package immediately consumable by any other TypeScript project in a strongly-typed manner. Without the need to download further tools and typings. And without the danger of introducing type collisions.
  2. Use tsd to manage ambient type declarations of packages you use in your implementation.
  3. Try to avoid exposing types from dependencies in your package that are typed using ambient module declarations.
  4. Keep a close eye on the typings project; it might turn out to be the future source of typings for packages that don’t ship with their own typings.
  5. Use ts-npm-lint to guide you through the quirks of setting up a strongly-typed npm package.

--

--

Michel Weststrate

Lead Developer @Mendix, JS, TS & React fanatic, dad (twice), creator of MobX, MX RestServices, nscript, mxgit. Created.