Skip to content

TypeScript Projects and Library Boundaries ​

A TypeScript project succeeds when three models agree: the imports written in source, the files understood by the checker, and the files loaded by the runtime. Many frustrating TypeScript problems come from configuring only one of those layers.

Choose settings from the execution environment ​

For a modern Node ESM application, a starting configuration might be:

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"]
}

NodeNext models Node's ESM and CommonJS behavior, including package metadata and file extensions. A bundler-based browser application commonly uses module: "ESNext", moduleResolution: "Bundler", and noEmit: true, because the bundler resolves and transforms source.

Do not copy a tsconfig solely because it is labeled “strict.” The correct module and library settings depend on deployment. The compiler option reference and framework template are better starting points than a maximal anonymous gist.

With TypeScript emitting ESM for Node under NodeNext-style resolution, source commonly imports the eventual runtime filename:

ts
import { buildReport } from "./build-report.js";

TypeScript resolves that specifier to build-report.ts during checking and preserves .js for Node. Writing .ts can produce imports that emitted JavaScript cannot load unless a specialized runtime rewrites or supports them. Bundlers and runtimes that execute TypeScript directly may use different extension rules, so the advice is not universal.

Compile and check are separable jobs ​

tsc can both check and emit JavaScript. A project may instead use a fast transformer such as Vite, esbuild, SWC, Bun, or Node's type stripping. Most such transformations erase types without performing full type checking.

Make both responsibilities explicit:

json
{
  "scripts": {
    "check": "tsc --noEmit",
    "build": "vite build"
  }
}

An editor diagnostic is excellent feedback but not a reproducible build gate. A clean check in automation ensures the whole configured program is checked with committed settings.

Source maps connect runtime stack traces back to TypeScript source. Enable them for emitted projects and verify that the deployment retains or uploads them appropriately without unintentionally exposing source.

Declaration files describe JavaScript surfaces ​

Type declarations (.d.ts) contain types without implementations. Packages often publish them alongside JavaScript. The types field or exports map tells tools where the public declarations live.

A small library source might export:

ts
export type Report = {
  count: number;
  total: number;
};

export function buildReport(values: readonly number[]): Report {
  return {
    count: values.length,
    total: values.reduce((sum, value) => sum + value, 0),
  };
}

With declaration: true, TypeScript can emit a corresponding .d.ts. Consumers compile against that public contract, so exported inferred types should be stable and intelligible. Do not expose accidental internal types merely because inference can name them.

Package boundaries also need runtime design: which subpaths are public, whether the library provides ESM, CommonJS, or both, and which JavaScript versions it targets. Publishing dual module formats is possible but adds resolution and testing complexity. Support only formats your consumers need.

The exports map defines the public package ​

Files present in an archive are not automatically supported entry points. An exports map makes the public API explicit and prevents accidental deep imports:

json
{
  "name": "@example/reporting",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./format": {
      "types": "./dist/format.d.ts",
      "import": "./dist/format.js"
    }
  },
  "files": ["dist"]
}

Consumers may import @example/reporting or @example/reporting/format, but not arbitrary internal files. Every public subpath is a compatibility commitment.

Conditions are selected by the consumer's loader. A package that truly supports ESM and CommonJS can provide import and require targets, but dual publishing creates two build graphs and can create two instances of module state. Do not advertise a condition without testing its output. The related imports field defines private aliases, whose keys begin with #, for use inside the package.

Dependency kinds communicate ownership ​

Runtime requirements belong in dependencies; build and test tools belong in devDependencies. A peerDependency says the consuming project must supply a compatible shared dependency:

json
{
  "peerDependencies": {
    "react": "^19.0.0"
  },
  "devDependencies": {
    "react": "^19.0.0",
    "typescript": "^5.9.0"
  }
}

A component library commonly peers on React so the application owns the React instance. It still needs React as a development dependency for its own build and tests. Use peers when host and plugin must agree on a shared contract or instance, not merely to reduce installation size.

Semantic-version ranges are promises as well as resolver syntax. ^5.9.0 normally permits compatible minor and patch versions below 6.0.0; prereleases and zero-major versions need extra care. A lockfile records one resolved graph for repeatable development, while a library's declared ranges describe what consumers may resolve.

Workspaces do not replace package boundaries ​

A repository can connect applications and libraries as workspaces:

json
{
  "private": true,
  "workspaces": ["packages/*", "apps/*"]
}

Workspace tooling links local packages and coordinates installation. Each package still needs a valid manifest and intentional dependency edges. Hoisting can hide undeclared dependencies, while source aliases can bypass the exports map, so a workspace test alone does not prove the published artifact works.

Test the artifact a consumer installs ​

Create and inspect the registry archive before publishing:

sh
npm pack --dry-run
npm pack

Install the archive into a temporary consumer and import only through the package name. Verify that Node loads each supported runtime entry, TypeScript resolves its declarations, public subpaths work, private deep imports fail, and no dependency was available only because of workspace hoisting. Dual ESM/CommonJS packages need separate consumer tests; browser packages should also be built through a supported consumer bundler.

JavaScript libraries can be adopted gradually ​

TypeScript understands declaration packages, often installed as @types/name, when a JavaScript package does not ship its own types. A declaration can be wrong or version-mismatched; runtime documentation remains authoritative.

For local JavaScript migration, allowJs includes .js files and checkJs checks them. JSDoc provides types without renaming the file:

js
/**
 * @param {number[]} values
 * @returns {number}
 */
export function total(values) {
  return values.reduce((sum, value) => sum + value, 0);
}

This can be a durable choice for JavaScript libraries as well as a migration step. TypeScript is a checker, not a requirement that all source use .ts.

Keep unsafe edges narrow ​

Third-party SDKs, environment variables, JSON, and dynamic plugins are boundary data. Convert them into trusted application types at one location:

ts
type Environment = {
  apiBaseUrl: URL;
  retryCount: number;
};

function readEnvironment(source: NodeJS.ProcessEnv): Environment {
  const rawUrl = source.API_BASE_URL;
  if (!rawUrl) throw new Error("API_BASE_URL is required");

  const retryCount = Number(source.RETRY_COUNT ?? "3");
  if (!Number.isInteger(retryCount) || retryCount < 0) {
    throw new Error("RETRY_COUNT must be a non-negative integer");
  }

  return { apiBaseUrl: new URL(rawUrl), retryCount };
}

Inner code now receives a URL and number rather than repeatedly interpreting strings.

Avoid broad ambient declarations that claim an untyped module is safe:

ts
declare module "mystery-package";

That effectively makes its exports any. A narrow adapter with a small local declaration or runtime validation contains uncertainty more honestly.

Organize types with the code they describe ​

A global types.ts tends to become an unrelated warehouse. Place Transaction, its parser, and its domain operations near one another; export the public types callers actually need. Type-only dependency cycles are less dangerous at runtime, but they can still reveal confused ownership.

Use project references and multiple tsconfig files only when independently built packages or materially different environments justify them. A browser client and Node server need different global libraries; a small application often does not need a monorepo architecture.

Useful project scripts remain few and explicit:

json
{
  "scripts": {
    "check": "tsc --noEmit",
    "test": "vitest run",
    "lint": "eslint .",
    "build": "vite build"
  }
}

The important boundary is not TypeScript versus JavaScript. It is trusted internal assumptions versus values that can violate them, and source-level expectations versus what the selected runtime will actually execute.

Optional prompts ​

Predict: A file exists at dist/internal.js in a package whose exports map exposes only . and ./format. Should package/internal.js work?

Answer: No. Once exports is present, unlisted package subpaths are encapsulated even if the file exists in the archive. Consumers should use only declared entry points; adding one later is an intentional public-API change.

Explain: Why can a React component library list React in both peerDependencies and devDependencies without contradiction?

Answer: The peer declaration says the consuming application owns and supplies the compatible runtime instance. The development declaration lets the library build and test itself. Bundling a private React copy could create duplicate runtime state and violate the framework's single-instance expectations.

Debug: Source tests pass inside a workspace, but installing the packed archive fails to resolve tiny-invariant. What likely happened, and what test catches it?

Answer: Workspace hoisting made an undeclared dependency reachable from source. Declare the runtime dependency in the package that imports it, then install the packed archive in a clean temporary consumer. That consumer test observes the artifact and dependency metadata users actually receive.

Further reference ​