# Build and publish a TypeScript package to npm

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-26 | Topics: [Node.js](https://flaviocopes.com/tags/node/) | Canonical: https://flaviocopes.com/publish-typescript-package-npm/

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

Then details matter:

- does it work with ESM?
- does it work with CommonJS?
- can TypeScript find the types?
- are internal source files accidentally public?
- does the npm tarball contain what we expect?

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

It will convert milliseconds into a readable duration:

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

## Create the package

```bash
mkdir tiny-duration
cd tiny-duration
npm init -y
npm install --save-dev typescript tsup vitest
```

Create `src/index.ts`:

```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`:

```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 `tsup.config.ts`:

```ts
import { defineConfig } from 'tsup'

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

Add scripts to `package.json`:

```json
{
  "scripts": {
    "build": "tsup",
    "test": "vitest run",
    "prepack": "npm run test && npm run build"
  }
}
```

Run:

```bash
npm run build
```

The `dist` directory contains:

```text
dist/
  index.cjs
  index.d.cts
  index.d.ts
  index.js
  index.js.map
```

The exact declaration filenames can vary slightly with the build tool version.

## Define package exports

Edit `package.json`:

```json
{
  "name": "tiny-duration",
  "version": "1.0.0",
  "description": "Format a duration in milliseconds",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./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.js`. CommonJS consumers receive `index.cjs`. TypeScript receives the declarations.

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`:

```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:

```bash
npm test
```

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

## Inspect the npm tarball

Run:

```bash
npm pack --dry-run
```

npm prints every file that would be published.

Look for:

- `dist` files
- `package.json`
- `README.md`
- `LICENSE`

Look for things that should not be there:

- `.env`
- test fixtures
- private keys
- editor files
- large source maps you did not intend to ship

Then create the actual tarball:

```bash
npm pack
```

Install that tarball in a temporary consumer project:

```bash
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:

```js
import { formatDuration } from 'tiny-duration'

console.log(formatDuration(90500))
```

Test CommonJS:

```js
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`:

```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:

- patch: bug fix that preserves the public API
- minor: backward-compatible feature
- major: breaking change

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

```bash
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:

```bash
npm login
```

For an unscoped public package:

```bash
npm publish
```

For a new scoped public package:

```bash
npm publish --access public
```

The `prepack` script 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.

Create `.github/workflows/publish.yml`:

```yaml
name: publish

on:
  release:
    types: [published]

permissions:
  contents: read
  id-token: write

jobs:
  publish:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22.14.0
          registry-url: https://registry.npmjs.org

      - run: npm ci
      - run: npm test
      - run: npm run build
      - run: npm publish
```

Trusted publishing requires a recent npm CLI and supported Node.js version. Use npm 11.5.1 or newer and Node 22.14 or newer unless the npm documentation lists newer minimums when you set it up.

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:

- publish only from a protected environment
- require review for releases
- keep the workflow filename stable
- ensure `repository.url` exactly identifies the source repository
- build from the release commit, not an arbitrary branch

## 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

The package is not `src/index.ts`. The package is the tarball, its metadata, its type declarations, and the compatibility promise attached to its version.
