Building a Type-Safe npm Library From Scratch
AI generated
<T>
type
TypeScript · npm · Libraries · Tooling
Building a Type-Safe npm Library From Scratch
from project structure to a stable public API

Publishing a TypeScript library is not just writing code, it is signing a contract with projects you do not control. Project structure, tsconfig.json, a dual format build and the exports field decide whether a TypeScript library works smoothly in every consumer setup or generates daily support tickets.

18 min read tsconfig · tsup · exports field · Semantic Versioning TypeScript 5.x · Node.js 20+

1. Why a TypeScript library has different requirements than an app

An application is built and run by a single team in a known environment. A TypeScript library, on the other hand, ends up in dozens of foreign projects with different bundlers, different tsconfig settings and different Node versions. A bug in your own app is noticed immediately, a bug in a published TypeScript library only shows up weeks later as an issue in someone else's repository, often with contradictory error messages because the consumer's build is configured differently from your own.

That is why stricter rules apply to a TypeScript library than to internal code: the public API must be stable, generated type declarations must work equally well in strict and non-strict consumer projects, and the output format must be compatible with both ESM-only bundlers and classic CommonJS. Thinking through these requirements from the start saves you from later breaking changes that would otherwise break a working TypeScript library across dozens of downstream projects.

2. Project structure: separating src, dist and package.json from day one

The structure of a TypeScript library follows a simple principle: source code and build artifacts must never live in the same directory. The src/ folder contains exclusively hand written TypeScript code, the dist/ folder contains exclusively generated files and is never edited by hand or committed to source control. This separation prevents stale compiled files from accidentally landing in Git, and prevents a developer from hand editing a generated .d.ts file without realizing the next build will overwrite the change.

A second important building block is the files field in package.json, which defines exactly which directories actually end up in the tarball when running npm publish. Without this field, test files, configuration files or even the entire src/ folder can accidentally end up in the published package, unnecessarily inflating the installed size. A well structured TypeScript library publishes only dist/, the README and the license file.


{
  "name": "@mironsoft/query-builder",
  "version": "0.1.0",
  "description": "Type-safe query builder for relational databases",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": [
    "dist",
    "README.md",
    "LICENSE"
  ],
  "sideEffects": false,
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "build": "tsup src/index.ts --format esm,cjs --dts",
    "prepublishOnly": "npm run build"
  }
}

3. tsconfig.json for libraries: declaration, declarationMap, isolatedModules

The tsconfig.json of a TypeScript library differs from an application's in decisive ways. The option declaration: true is mandatory, because without it the compiler produces no .d.ts files and consumers lose all type information. declarationMap: true adds declaration maps that let IDEs jump straight from a generated type declaration back to the original source, which makes debugging your own library considerably easier. isolatedModules: true ensures every file can be transpiled independently, a prerequisite for fast build tools such as esbuild or SWC that process files in parallel without a full type check.

Equally important is skipLibCheck: true, because a TypeScript library should not re-check the type declarations of its own dependencies on every build, that only costs time and rarely surfaces bugs in your own code. declarationDir can separate generated type declarations from compiled JavaScript files when needed, which helps in more complex build pipelines with multiple output formats. Together these options form the foundation on which every further build decision for a TypeScript library rests.


{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "declaration": true,
    "declarationMap": true,
    "isolatedModules": true,
    "skipLibCheck": true,
    "strict": true,
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"],
  "exclude": ["dist", "**/*.test.ts", "examples"]
}

4. Dual format build: shipping ESM and CJS at once

A common pitfall with a TypeScript library is assuming every consumer has already migrated to ESM. In practice, plenty of CommonJS projects, older Jest configurations without ESM support and tools that call require() synchronously still exist. A TypeScript library that ships ESM only categorically excludes these consumers or forces them into awkward dynamic imports at places where synchronous code is expected.

A dual format build solves this by producing both an ESM file (index.js) and a CJS file (index.cjs) from the same source. Both variants must be content identical and export the same public API, otherwise subtle differences between ESM and CJS behavior appear that are hard to reproduce. For namespace imports like import * as lib from "...", the CJS variant additionally needs a correct module.exports interop, which modern build tools generate automatically.

5. Build tooling: tsup, tsc and Rollup compared

The native TypeScript compiler tsc can generate both JavaScript and declarations, but does not support a true dual format build in a single pass, instead requiring two separate tsconfig files and two invocations. That is acceptable for smaller libraries, but quickly becomes unwieldy for larger projects with multiple entry points. tsup, a wrapper around esbuild, produces ESM and CJS in one command, optionally bundles dependencies and generates correct .d.ts files via a background tsc call.

Rollup remains the right choice when a TypeScript library needs plugins for complex tree shaking, custom output formats or code splitting across multiple entry points that tsup does not cover. For most libraries without exotic requirements, however, tsup is the most pragmatic starting point: minimal configuration, fast build times thanks to esbuild, and automatic support for dual format output including type declarations.


// tsup.config.ts — dual format build with type declarations
import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  splitting: false,
  sourcemap: true,
  clean: true,
  minify: false,
  target: "es2020",
});

6. Making the exports field type safe

The exports field in package.json is the mechanism Node.js and modern bundlers use to decide which file to load for which import context. For a TypeScript library it is crucial that each conditional export block also contains the matching types entry, and that it comes first within that object. TypeScript reads the exports field top to bottom and takes the first matching entry, an incorrectly ordered object causes consumers to receive CJS types while importing ESM, or the other way around.

Subpath exports let you expose parts of a TypeScript library selectively, for example @mironsoft/query-builder/adapters/mysql, without bundling the entire adapter code into the main entry point. That reduces bundle size for consumers who only need a single adapter. It is important to repeat the same three part structure of types, import and require for every subpath, otherwise type resolution breaks exactly for that subpath.


{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./adapters/mysql": {
      "types": "./dist/adapters/mysql.d.ts",
      "import": "./dist/adapters/mysql.js",
      "require": "./dist/adapters/mysql.cjs"
    },
    "./package.json": "./package.json"
  }
}

// src/index.ts — deliberately curated public entry point
export { QueryBuilder } from "./query-builder";
export type { QueryBuilderOptions, Column, SortDirection } from "./types";

// Internal helpers stay in src/internal and are never re-exported here
// export * from "./internal/sql-escape"; — intentionally NOT exported

7. Deliberately shaping the public API

Every exported function, class or type of a TypeScript library is a contract that cannot be removed after the first release without a breaking change. That is why the public entry point src/index.ts should be set up as a deliberately curated barrel file that only exports what is genuinely meant for consumers. Internal helper functions, internal types and implementation details stay in separate files without being re-exported through the barrel file.

A proven pattern is a clear separation between public and internal modules through folder structure: src/internal/ for code that must never be reachable through the main entry point, src/public/ or plain src/ for everything that belongs to the API. This discipline prevents a TypeScript library from accidentally exposing implementation details that consumers then rely on, even though they are meant to change on the next refactor.

8. Versioning and Semantic Versioning for type changes

Semantic Versioning applies to a TypeScript library not only for runtime behavior, but equally for types. A type change that turns existing consumer code into a compile error is a major release, even if runtime behavior does not change at all. Adding an optional parameter or a new exported function is a minor release. Fixing a type that was previously too permissive can paradoxically also be a breaking change, because code that relied on the overly loose typing suddenly stops compiling.

For a TypeScript library it is therefore recommended to trial compile every change to public types against a small example project containing representative consumer usage patterns before releasing. Tools like Changesets automate this process by asking on every pull request whether a change is a patch, minor or major release, and deriving the changelog and the new version number from that automatically.

9. Build strategies compared

The choice of build tool and output format has a direct impact on how reliably a TypeScript library works in foreign projects. The overview below compares the most common approaches.

Approach Effort Dual format Recommendation
tsc only, ESM only Low No Suitable only for internal packages
tsc with two tsconfig files Medium Yes Sufficient for very small libraries
tsup Low Yes Pragmatic standard for most libraries
Rollup with plugins High Yes For complex tree shaking and code splitting

For the vast majority of TypeScript libraries, tsup strikes the right balance between configuration effort and outcome. Rollup only pays off once there are genuinely special requirements for bundle splitting or output formats beyond straightforward ESM and CJS bundling.

Mironsoft

TypeScript libraries, tooling and build pipelines

Need a TypeScript library built for you?

We design and build TypeScript libraries with a clean public API, dual format builds and stable versioning, so your team and external consumers can rely on every release.

Project setup

Setting up tsconfig, build tooling and the exports field cleanly from scratch

API design

Deliberately shaping the public interface and protecting it against breaking changes

Release pipeline

Setting up Semantic Versioning, Changesets and CI backed publishing

10. Summary

Building a type-safe TypeScript library from scratch means thinking in contracts from the very first line of code, instead of internal implementation details. A clear separation of src/ and dist/, a tsconfig.json with declaration, declarationMap and isolatedModules, a dual format build for ESM and CJS, and a correctly ordered exports field form the technical foundation. A deliberately curated public API and consistent Semantic Versioning ensure that consumers of the library can trust it.

Planning these building blocks from the start saves expensive migrations and breaking change communication later. Build tools like tsup take away a large part of the complexity without sacrificing control over the generated type declarations. The effort of setting up a TypeScript library correctly from the start is considerably lower than the effort of lifting an already widely used library onto a clean foundation afterward.

TypeScript library from scratch — the essentials at a glance

Project structure

Strictly separate src/ and dist/, use files in package.json to limit the published tarball to what is needed.

tsconfig.json

declaration, declarationMap and isolatedModules are mandatory for libraries, not optional.

Dual format build

Ship ESM and CJS in parallel, with tsup as the pragmatic default tool for most libraries.

API & versioning

Curate the public API deliberately, order the exports field correctly, apply Semantic Versioning to type changes too.

11. FAQ: TypeScript library from scratch

1Do I really need a dual format build?
Yes, once the library is public. CommonJS projects and older Jest setups still need CJS. tsup produces both formats with little extra effort.
2Difference between main, module, exports?
main is classic CJS, module is read by some bundlers for ESM, exports is the modern standard that takes precedence over both.
3Why separate src and dist?
So generated files never land in Git and nobody accidentally edits a compiled file instead of the source.
4Protecting internal functions from export?
Through a curated barrel file as the single entry point, internal code stays unreachable without being re-exported.
5Is tsc alone enough?
For very simple cases yes, but without a true dual format build in one pass. tsup automates that.
6Wrong order in the exports object?
TypeScript takes the first match. If types is not first, consumers get any types or compile errors.
7Does a type fix count as breaking?
Often yes. Consumer code that relied on the previously loose typing may no longer compile.
8How large should the first release be?
As small as possible. A lean core can be extended risk free through minor releases.
9Should sideEffects: false always be set?
Only if the library is genuinely free of import side effects. Then it enables effective tree shaking.
10How to test real consumer compatibility?
With npm pack and a local install in a test project covering ESM, CJS, strict and non-strict tsconfig.