M Logo
๐Ÿ“ TypeScript from Zero ยท Part 50 of 50

TypeScript: Publish a Typed npm Package (exports, types, files) + Series Recap

November 6, 2026

Welcome to episode 50 of TypeScript from Zero, a series for complete beginners (no JavaScript needed). Welcome back to TypeScript from zero! This is episode fifty, the last one. We have written a lot of code. Today we share some of it: we prepare a small library as a package that anybody can install, with types included. We do everything except the last step, which would put it on the real registry. A new file called index.ts in a folder called lib. Every code block below is the exact code and output from the video, ready to copy.

Setup

We start in the terminal and open the project in VS Code:

bash
cd lib
touch src/index.ts
code .

1. The code

The library has one function: slugify, which turns a title into a piece of a web address. It lowercases, trims, and replaces the spaces with dashes.

2. Build

The T S config file is prepared: source in src, output in dist, and declaration true. Declaration files are the key for a typed package. tsc writes a.d.ts file next to every.js file, with the types and without the code. This is the only thing that users of the library will need for types.

src/index.ts:

ts
export function slugify(text: string): string {
  return text.toLowerCase().trim().replace(/\s+/g, "-");
}
bash
$ npx tsc
text
$ ls dist
index.d.ts
index.js

3. npm pack

What would be published? npm pack with dry run lists the files that would go into the package. It is too much: our source, the T S config, everything in the folder. Users need only the finished files.

text
$ npm pack --dry-run
tiny-slug-1.0.0.tgz
npm notice
npm notice ๐Ÿ“ฆ  tiny-slug@1.0.0
npm notice Tarball Contents
npm notice 55B dist/index.d.ts
npm notice 93B dist/index.js
npm notice 192B package.json
npm notice 107B src/index.ts
npm notice 199B tsconfig.json
npm notice Tarball Details
npm notice name: tiny-slug
npm notice version: 1.0.0
npm notice filename: tiny-slug-1.0.0.tgz
npm notice package size: 509 B
npm notice unpacked size: 646 B
npm notice shasum: 55e125dc286a7feeec8c178be0b0fd7af466ef60
npm notice integrity: sha512-0HICHLiNoBW7S[...]CmLJs/fXW0h+w==
npm notice total files: 5
npm notice
text
$ npm pack --dry-run 2>&1 | head -40
npm notice
npm notice ๐Ÿ“ฆ  tiny-slug@1.0.0
npm notice Tarball Contents
npm notice 55B dist/index.d.ts
npm notice 93B dist/index.js
npm notice 192B package.json
npm notice 107B src/index.ts
npm notice 199B tsconfig.json
npm notice Tarball Details
npm notice name: tiny-slug
npm notice version: 1.0.0
npm notice filename: tiny-slug-1.0.0.tgz
npm notice package size: 509 B
npm notice unpacked size: 646 B
npm notice shasum: 55e125dc286a7feeec8c178be0b0fd7af466ef60
npm notice integrity: sha512-0HICHLiNoBW7S[...]CmLJs/fXW0h+w==
npm notice total files: 5
npm notice
tiny-slug-1.0.0.tgz

4. exports

Two fields in the package file fix it. Files lists what to include: only dist. And exports says how to import the package: the types come from the declaration file, and the code from the javascript file. The word types comes first, because the first match wins. Exports also hides everything else in the package.

package.json:

ts
{
  "name": "tiny-slug",
  "version": "1.0.0",
  "description": "Turn a title into a URL slug",
  "license": "MIT",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    }
  },
  "files": ["dist"],
  "devDependencies": {
    "typescript": "^7.0.2"
  }
}
text
$ npm pack --dry-run
tiny-slug-1.0.0.tgz
npm notice
npm notice ๐Ÿ“ฆ  tiny-slug@1.0.0
npm notice Tarball Contents
npm notice 55B dist/index.d.ts
npm notice 93B dist/index.js
npm notice 321B package.json
npm notice Tarball Details
npm notice name: tiny-slug
npm notice version: 1.0.0
npm notice filename: tiny-slug-1.0.0.tgz
npm notice package size: 418 B
npm notice unpacked size: 469 B
npm notice shasum: 5c88699ef13bffbd56d021d65a9a0f4204d438fc
npm notice integrity: sha512-9MnGLM8ckt9io[...]iI+2nr2pqEOgQ==
npm notice total files: 3
npm notice
text
$ npm pack --dry-run 2>&1 | head -40
npm notice
npm notice ๐Ÿ“ฆ  tiny-slug@1.0.0
npm notice Tarball Contents
npm notice 55B dist/index.d.ts
npm notice 93B dist/index.js
npm notice 321B package.json
npm notice Tarball Details
npm notice name: tiny-slug
npm notice version: 1.0.0
npm notice filename: tiny-slug-1.0.0.tgz
npm notice package size: 418 B
npm notice unpacked size: 469 B
npm notice shasum: 5c88699ef13bffbd56d021d65a9a0f4204d438fc
npm notice integrity: sha512-9MnGLM8ckt9io[...]iI+2nr2pqEOgQ==
npm notice total files: 3
npm notice
tiny-slug-1.0.0.tgz

5. Try it

A package can import itself by its name, so we can test it right here. Importing slugify from tiny slug works. But reaching into the dist folder is not allowed any more: the error code says: package path is not exported. That is the point of exports: the public face is only what we choose.

text
$ node --input-type=module -e 'import { slugify } from "tiny-slug"; console.log(slugify(" Hello Type Script "))'
hello-type-script
text
$ node --input-type=module -e 'import "tiny-slug/dist/index.js"'
node:internal/modules/esm/resolve:282
  return new ERR_PACKAGE_PATH_NOT_EXPORTED(
         ^

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/index.js' is not defined by "exports" in /home/mah/lib/package.json imported from /home/mah/lib/[eval1]
    at exportsNotFound (node:internal/modules/esm/resolve:282:10)
    at packageExportsResolve (node:internal/modules/esm/resolve:630:9)
    at packageResolve (node:internal/modules/esm/resolve:759:12)
    at moduleResolve (node:internal/modules/esm/resolve:840:18)
    at defaultResolve (node:internal/modules/esm/resolve:973:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
    at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)
    at ModuleLoader.resolveSync (node:internal/modules/esm/loader:766:56)
    at #resolve (node:internal/modules/esm/loader:690:17)
    at ModuleLoader.getOrCreateModuleJob (node:internal/modules/esm/loader:610:35) {
  code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
}

Node.js v26.13.0
text
$ node --input-type=module -e 'import { slugify } from "tiny-slug"; console.log(slugify(" Hello Type Script "))'; node --input-type=module -e 'import "tiny-slug/dist/index.js"' 2>&1 | head -12
hello-type-script
node:internal/modules/esm/resolve:282
  return new ERR_PACKAGE_PATH_NOT_EXPORTED(
         ^

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/index.js' is not defined by "exports" in /home/mah/lib/package.json imported from /home/mah/lib/[eval1]
    at exportsNotFound (node:internal/modules/esm/resolve:282:10)
    at packageExportsResolve (node:internal/modules/esm/resolve:630:9)
    at packageResolve (node:internal/modules/esm/resolve:759:12)
    at moduleResolve (node:internal/modules/esm/resolve:840:18)
    at defaultResolve (node:internal/modules/esm/resolve:973:11)
    at #cachedDefaultResolve (node:internal/modules/esm/loader:708:20)
    at #resolveAndMaybeBlockOnLoaderThread (node:internal/modules/esm/loader:728:38)

6. Dry run

The last step is npm publish. With -- dry run, npm does everything except the upload, and shows what would happen: the name, the version, and the files. When we really want to publish, we log in with npm login, and run the same command without the flag. Good practice: raise the version number every time.

text
$ npm publish --dry-run
+ tiny-slug@1.0.0
npm notice
npm notice ๐Ÿ“ฆ  tiny-slug@1.0.0
npm notice Tarball Contents
npm notice 55B dist/index.d.ts
npm notice 93B dist/index.js
npm notice 321B package.json
npm notice Tarball Details
npm notice name: tiny-slug
npm notice version: 1.0.0
npm notice filename: tiny-slug-1.0.0.tgz
npm notice package size: 418 B
npm notice unpacked size: 469 B
npm notice shasum: 5c88699ef13bffbd56d021d65a9a0f4204d438fc
npm notice integrity: sha512-9MnGLM8ckt9io[...]iI+2nr2pqEOgQ==
npm notice total files: 3
npm notice
npm warn publish This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access (dry-run)
text
$ npm publish --dry-run 2>&1 | head -30
npm notice
npm notice ๐Ÿ“ฆ  tiny-slug@1.0.0
npm notice Tarball Contents
npm notice 55B dist/index.d.ts
npm notice 93B dist/index.js
npm notice 321B package.json
npm notice Tarball Details
npm notice name: tiny-slug
npm notice version: 1.0.0
npm notice filename: tiny-slug-1.0.0.tgz
npm notice package size: 418 B
npm notice unpacked size: 469 B
npm notice shasum: 5c88699ef13bffbd56d021d65a9a0f4204d438fc
npm notice integrity: sha512-9MnGLM8ckt9io[...]iI+2nr2pqEOgQ==
npm notice total files: 3
npm notice
npm warn publish This command requires you to be logged in to https://registry.npmjs.org/ (dry-run)
npm notice Publishing to https://registry.npmjs.org/ with tag latest and default access (dry-run)
+ tiny-slug@1.0.0

7. Look back

And that is the end of the series. We started with a single line, console dot log, and a typo. Then variables, numbers, strings, decisions, loops, arrays, objects and functions. We built a real project, and met the type system: unions, narrowing, generics, classes, modules and async code. We wrote a task tracker, with a model, a command line, files, errors, and a build. We checked data with Zod, built an HTTP server and a client, and typed events. We went deep into advanced types. And we finished with tests, linting, project references, and publishing.

8. Next

Keep going: build something of your own, and let tsc help you. Thank you for learning with me, for fifty episodes. If this series helped you, subscribe, share it with a friend, and see you in the next project.

ยฉ 2026 All Rights Reserved By Mahmudul Alam