Build and publish a TypeScript package to npm

By

Build a TypeScript 7 library for npm with tsdown: ESM and CommonJS outputs, type declarations, exports, tests, semantic versions, and trusted publishing.

~~~

Publishing a TypeScript package is easy until the package is imported by another project.

Then details matter:

In this tutorial we’ll build and publish a small package called tiny-duration.

It will convert milliseconds into a readable duration:

formatDuration(90500) // "1m 30s"

Create the package

This tutorial assumes you know TypeScript’s type syntax. If you need to start there, take my free TypeScript course, which is also available as a PDF or EPUB.

mkdir tiny-duration
cd tiny-duration
npm init -y
npm pkg set type=module
npm install --save-dev typescript@7 tsdown vitest

tsdown builds the package, and it uses TypeScript 7 to generate the type declarations. Older versions of this tutorial used tsup, which is not actively maintained anymore. Its README now points to tsdown, and tsdown keeps a very similar config.

tsdown needs a recent Node.js, like a current Node.js 24 release. On an older Node.js 22 release, npm quietly installs an older tsdown that refuses TypeScript 7 with an ERESOLVE error.

We also need the npm pkg set type=module line. A fresh npm init -y writes "type": "commonjs", and with that tsdown fails to load its TypeScript config file.

Create src/index.ts:

export type FormatDurationOptions = {
  compact?: boolean
}

export function formatDuration(
  milliseconds: number,
  options: FormatDurationOptions = {}
): string {
  if (!Number.isFinite(milliseconds) || milliseconds < 0) {
    throw new TypeError('milliseconds must be a non-negative number')
  }

  const totalSeconds = Math.floor(milliseconds / 1000)
  const minutes = Math.floor(totalSeconds / 60)
  const seconds = totalSeconds % 60

  if (options.compact) {
    return `${minutes}:${String(seconds).padStart(2, '0')}`
  }

  const parts = []

  if (minutes > 0) {
    parts.push(`${minutes}m`)
  }

  if (seconds > 0 || parts.length === 0) {
    parts.push(`${seconds}s`)
  }

  return parts.join(' ')
}

The public API is everything exported from src/index.ts. Keep that surface small and intentional.

Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "declaration": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}

strict is especially important for a library. A weak type hidden inside the package becomes a weak type in every project that installs it.

Build ESM, CommonJS, and declarations

Create tsdown.config.ts:

import { defineConfig } from 'tsdown'

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  sourcemap: true,
})

Add scripts to package.json:

{
  "scripts": {
    "build": "tsdown",
    "typecheck": "tsc --noEmit",
    "test": "vitest run",
    "prepack": "npm run typecheck && npm run test && npm run build"
  }
}

tsdown doesn’t check your types. It builds the package even if the code has a type error. The typecheck script runs the TypeScript compiler as a checker only (--noEmit means it writes no files), so a type error stops prepack before anything gets packed.

Run:

npm run build

The dist directory contains:

dist/
  index.cjs
  index.cjs.map
  index.d.cts
  index.d.mts
  index.mjs
  index.mjs.map

tsdown uses the .mjs and .cjs extensions for Node.js libraries. Each file says which module system it uses, whatever the type field says. The declarations follow the same rule, with .d.mts for ESM and .d.cts for CommonJS.

You may also see a warning that TypeScript 7 doesn’t have a stable API yet. The build still produces the declarations.

Define package exports

Edit package.json:

{
  "name": "tiny-duration",
  "version": "1.0.0",
  "description": "Format a duration in milliseconds",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.cts",
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.mts",
        "default": "./dist/index.mjs"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  },
  "files": [
    "dist"
  ],
  "sideEffects": false
}

Choose a real package name that is available on npm. Scoped packages such as @yourname/tiny-duration avoid many naming collisions.

The exports map is the public entry point.

ESM consumers receive index.mjs and index.d.mts. CommonJS consumers receive index.cjs and index.d.cts. The top-level main, module and types fields are fallbacks for older tools that don’t read exports.

The files array is an allowlist for the published tarball. It prevents tests, source configuration, and local notes from being published accidentally.

Only set sideEffects: false when importing the package does not perform global work. Bundlers use this hint for tree shaking.

Add tests

Create src/index.test.ts:

import { describe, expect, test } from 'vitest'
import { formatDuration } from './index'

describe('formatDuration', () => {
  test('formats minutes and seconds', () => {
    expect(formatDuration(90_500)).toBe('1m 30s')
  })

  test('supports compact output', () => {
    expect(formatDuration(90_500, { compact: true })).toBe('1:30')
  })

  test('rejects negative values', () => {
    expect(() => formatDuration(-1)).toThrow(TypeError)
  })
})

Run:

npm test

These tests exercise source code. We must also test the package people will install.

Inspect the npm tarball

Create README.md and LICENSE before packing. The README requirements are covered below. Choose a license you understand and put its complete text in LICENSE.

Run:

npm pack --dry-run

npm prints every file that would be published.

Look for:

Look for things that should not be there:

Then create the actual tarball:

npm pack

Install that tarball in a temporary consumer project:

mkdir /tmp/tiny-duration-consumer
cd /tmp/tiny-duration-consumer
npm init -y
npm install /path/to/tiny-duration/tiny-duration-1.0.0.tgz

Test ESM in a test.mjs file:

import { formatDuration } from 'tiny-duration'

console.log(formatDuration(90500))

Test CommonJS in a test.cjs file:

const { formatDuration } = require('tiny-duration')

console.log(formatDuration(90500))

The tarball test catches bad export paths that source tests cannot see.

Write the README before publishing

A useful package README should answer:

  1. what does this package do?
  2. how do I install it?
  3. what is the smallest working example?
  4. what inputs and outputs does the API use?
  5. which runtimes are supported?

Keep examples executable. The first code block should not depend on information explained three sections later.

Add repository and license metadata to package.json:

{
  "repository": {
    "type": "git",
    "url": "git+https://github.com/yourname/tiny-duration.git"
  },
  "license": "MIT",
  "engines": {
    "node": ">=18"
  }
}

Do not claim runtime support you have not tested.

Choose the next version

npm package versions normally follow semantic versioning:

Commands such as this update the version and create a Git tag:

npm version patch

Do not reuse a published version. The npm registry does not let you overwrite package contents at the same name and version.

Publish manually once

Create an npm account, enable two-factor authentication, and log in:

npm login

For an unscoped public package:

npm publish

For a new scoped public package:

npm publish --access public

The prepack script checks types, runs tests and builds before the tarball is created.

After publishing, install the registry version in the temporary consumer project. Do not stop at seeing the package page.

Publish from GitHub Actions without a token

npm trusted publishing uses OpenID Connect. GitHub Actions receives short-lived permission to publish a specific package, so you do not store a long-lived npm token.

In the npm package settings, add a trusted publisher for the repository and workflow filename. Enable the npm publish action.

Set its GitHub Actions environment to release. We will use that same name in the workflow.

Create .github/workflows/publish.yml:

name: publish

on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest
    environment: release

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          registry-url: https://registry.npmjs.org
          package-manager-cache: false

      - run: npm install --global npm@^11.5.1
      - run: npm ci
      - run: npm run typecheck
      - run: npm test
      - run: npm run build
      - run: npm publish

Trusted publishing requires npm 11.5.1 or newer and a supported Node.js version. The workflow installs a compatible npm release explicitly. package-manager-cache: false turns off setup-node’s automatic dependency cache, which the setup-node docs recommend in publishing workflows, because a poisoned cache could expose the short-lived publish token.

For public packages published through a trusted publisher from a public repository, npm can automatically include provenance that links the package to its source and workflow.

Protect the release process:

Keep the package maintainable

Every export becomes something users may depend on.

Before adding one, ask whether it needs to be public.

For every release:

  1. update and test the public behavior
  2. choose the semantic version
  3. inspect npm pack --dry-run
  4. test the packed artifact
  5. publish through a protected workflow
  6. install the registry result

People install the tarball, along with its metadata and type declarations. Its version also carries a compatibility promise.

Tagged: Node.js · All topics

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about node: