Create an NPM Package in TypeScript from the Ground Up

You can do a lot in Node.js and TypeScript without ever building an NPM package. But what if you find yourself wanting to pull code out to share it amongst several Node.js projects? You may find that putting it into an NPM package is the way to go.

Build an NPM Package in TypeScript from the Ground Up
You could, of course, grab a starter, like this one or that one or maybe this other one. They all do useful things, and I recommend studying them when you have some grounding.

But there is so much going on that it’s really difficult to divine what the important bits are. The best way to really understand something in software is to build it up from the ground up.

A Library and a Program

Let’s start with the absolute minimum: a program, and a library it depends on.

We’ll be using CommonJS instead of the newer kinds of modules for this example. It works with Node.js out-of-the-box and is generally the easiest to work with.

hello/index.js

module.exports = {
  hello: (name) => "Hello, " + name + "!"
};

This is a pretty basic CommonJS module, exporting a single function.

hello/package.json

{
  "name": "@mattieb/hello"
}

A few interesting things are going on here if you’ve looked at a package.json file before:

  1. You don’t actually need to specify a version if you’re not publishing this package to a registry. We’re going to be installing this package by path later.
  2. You do need a name, though. The name is used to identify the package inside node_modules and give us something we can import.
  3. I’ve prefixed this name with @mattieb, my NPM scope. I may eventually publish this to the public NPM registry, but in the meantime, this prevents collisions with any other NPM packages also in the public registry.

hello-cli/index.js

const { hello } = require('@mattieb/hello');

console.log(hello('world'));

This is fairly self-explanatory. We’re pulling in our module, getting the function, and calling it.

You can see the code for what we’ve done thus far at the minimum tag on GitHub.

As you look through this, apart from a few other development niceties, you may notice one other thing — hello-cli/package.json. I didn’t create this file by hand.

You don’t need to specifically create package.json at all, actually. It’s sufficient to go into a directory and start installing dependencies. In my case, I typed:

npm install --save ../hello

NPM handled the rest.

If you’re checking this repository out, you just need to go into hello-cli and type:

npm install

and NPM will set up node_modules.

Now you can run node index.js, and you’ll see that familiar Hello, world!.

Mixing in a Little Typing

This is great if you only want to work in vanilla JavaScript. I’ve worked in vanilla JavaScript. I don’t want to work in vanilla JavaScript.

Let’s start by TypeScriptifying hello-cli.

The very first thing we need to do is install TypeScript. TypeScript is a development dependency (i.e., it doesn’t ship with production code).

We add it with npm install --save-dev typescript. (Don’t be tempted to rely on npm install -g here. Be explicit about your project’s dependencies.)

The TypeScript compiler needs a tsconfig.json. I’m working with Node 18, so I’ll start with this Node 18 base configuration from the bases project.

The TypeScript compiler outputs executable JavaScript. Following convention, I want to keep TypeScript source in src, and have the compiler put JavaScript into lib. To do this, we:

  1. Set the include option to src/**/* to tell the TypeScript compiler to look for TypeScript code there; and
  2. Set outDir under compilerOptions to lib to tell the TypeScript compiler to output JavaScript there.

While we’re in here, we are using console, so we’ll need one additional change, adding DOM to the lib list in compilerOptions.

The result:

hello-cli/tsconfig.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Node 18",

  "compilerOptions": {
    "lib": ["es2022"],
    "module": "commonjs",
    "target": "es2022",

    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    "outDir": "lib"
  },

  "include": ["src/**/*"]
}

hello-cli/package.json

{
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "@mattieb/hello": "file:../hello"
  },
  "devDependencies": {
    "typescript": "^4.7.3"
  }
}

Finally, to make it convenient to run the TypeScript compiler, we add an NPM script to run tsc.

hello-cli/src/index.ts

Now that this is all set, we can move index.js to src, rename it index.ts, and port it to TypeScript.

This is really just changing require to import:

import { hello } from '@mattieb/hello';

console.log(hello('world'));

We’re now at the upgrade-cli tag on GitHub. npm run build can now be used to try to build hello.

Except… now strict TypeScript isn’t happy.

src/index.ts:1:23 - error TS7016:
  Could not find a declaration file for module '@mattieb/hello'.
  '…/hello/hello/index.js' implicitly has an 'any' type.

This error means that our library doesn’t present any type information.

We could, of course, work around this issue with TypeScript configuration. But we own the library and want all TypeScript all the time, so let’s upgrade the library too.

Upgrading the Library

Let’s first talk a little bit about how an NPM module written in TypeScript works.

An NPM module must supply JavaScript (not TypeScript) code. It’s important to provide JavaScript so the modules work with JavaScript projects.

A module specifies an entry point with the main property in package.json. When you import a module, the exports from this entry point is what gets brought into your code. (By default, this is index.js, which is why our minimal JavaScript worked without specifying a specific entry point up till now.)

An NPM module can additionally supply TypeScript type information via a declaration file — additional TypeScript code that isn’t executable, but simply describes the types to apply to the JavaScript code.

A module specifies a declaration file with the types property in package.json. If you’ve ever installed a @types package, these are just declaration files.

To provide type information, our library first needs type information.

So, first things first: we need to install TypeScript there too, with NPM.

We’ll start with a copy of tsconfig.json from the other project. We don’t need the DOM entry in lib, but what we do need is a new compiler option, declaration, to tell the compiler to generate a declaration file for us:

hello/tsconfig.json

{
  "$schema": "https://json.schemastore.org/tsconfig",
  "display": "Node 18",

  "compilerOptions": {
    "lib": ["es2022"],
    "module": "commonjs",
    "target": "es2022",

    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,

    "outDir": "lib",
    "declaration": true
  },

  "include": ["src/**/*"]
}

hello/src/index.ts

Upgrading index.js to src/index.ts looks like this:

export const hello = (name: string): string => "Hello, " + name + "!";

hello/package.json

We’ll add the a script to run the TypeScript compiler to package.json as well:

{
  "name": "@mattieb/hello",
  "scripts": {
    "build": "tsc"
  },
  "devDependencies": {
    "typescript": "^4.7.3"
  }
}

We’re not done, but stop here a moment and build.

npm run build

If you look in lib now, you’ll see not just index.js (which is the exported module entry point) but also index.d.ts. This is our declaration file.

Let’s set these up in package.json:

{
  "name": "@mattieb/hello",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "scripts": {
    "build": "tsc"
  },
  "devDependencies": {
    "typescript": "^4.7.3"
  }
}

And that’s it!

Finally, all the builds will work, all the IntelliSense will be happy, and you can run node lib/index.js in hello-cli to see the whole thing functioning.

The finished code is at the upgrade-lib tag on GitHub.

A Little More

We’ve reached the end of how to make a minimal TypeScript NPM module, but we’ve barely scratched the surface here. There is so much more you can do with TypeScript and NPM.

We haven’t touched on versioning and publishing, or the considerations of what you should do for compatibility with other Node runtimes. We definitely haven’t talked about how to deal with module systems newer than CommonJS.

The great news is that with so much of the code that powers the JavaScript ecosystem up on places like GitHub, you can see how all of it is put together. With a little bit of a framework, you can start exploring more.

And don’t forget the manuals. Seeing what others have done can inspire, but if you really want to understand what it does, there’s no substitute for good documentation.

A few pages in particular are relevant for this topic:

I hope this post has helped demystify something you rely on every day and empowered you with more tools to make your Node.js and TypeScript projects better. Keep learning!

Conversation
  • Hingle McCringleberry says:

    This is a great guide!

  • Join the conversation

    Your email address will not be published. Required fields are marked *