package.json exports + Types: Designing a Clean Public API

How to structure multi-entry TypeScript packages with package.json exports, prevent deep imports, and ship correct .d.ts for consumers.

F

Frontend Interview Team

March 01, 2026

2 min read
package.json exports + Types: Designing a Clean Public API

What you’ll learn

  • Why exports is the real public API (not your folder structure)
  • How to ship correct .d.ts for each entry point
  • How to prevent “deep import” dependency traps (pkg/dist/internal)
  • A production-ready package layout you can copy

30‑second interview answer

For modern Node/bundlers, package.json exports defines what paths consumers can import. If you don’t configure it, consumers can deep-import internal files, locking you into accidental APIs. A high-quality TS package ships matching JS + .d.ts for each export entry and keeps the surface small and stable.


Mental model: “Your folders are not your API”

Consumers don’t import “your codebase”; they import paths:

import { foo } from "@acme/utils";
import { slugify } from "@acme/utils/string";

If you don’t control those paths, users will do this:

// Don’t let this become possible:
import { secretInternalFn } from "@acme/utils/dist/internal/secret";

Then you can never change internals without breaking people.


package/
  src/
    index.ts
    string.ts
  dist/
    index.js
    index.d.ts
    string.js
    string.d.ts
  package.json
  tsconfig.build.json

The exports map (copy/paste starting point)

{
  "name": "@acme/utils",
  "version": "1.0.0",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "default": "./dist/index.js"
    },
    "./string": {
      "types": "./dist/string.d.ts",
      "default": "./dist/string.js"
    }
  }
}

Why both types and exports?

  • types at the top is a fallback for older tooling.
  • Per-entry exports["./x"].types ensures each subpath has correct typings.

Example code (what consumers should see)

// src/string.ts
export function slugify(input: string) {
  return input
    .trim()
    .toLowerCase()
    .replace(/\s+/g, "-")
    .replace(/[^a-z0-9-]/g, "");
}
// src/index.ts
export { slugify } from "./string";

Now consumers can do:

import { slugify } from "@acme/utils";
// or
import { slugify } from "@acme/utils/string";

The 3 common failure modes

1) JS exists but .d.ts doesn’t

Symptom: runtime works, TypeScript errors.

Fix: ensure build emits declarations:

  • declaration: true
  • and your build pipeline copies .d.ts to dist/

2) .d.ts exists but points to the wrong module kind

Symptom: types resolve but tooling complains about ESM/CJS mismatch.

Fix: align TS build module/moduleResolution with runtime target (NodeNext for ESM packages).

3) Deep imports leak your internals

Symptom: consumers import dist/* paths.

Fix: add an exports map and do not export internals.


Production rule of thumb

  • Every public import path should be explicitly listed under exports.
  • Ship only stable entry points.
  • If you wouldn’t document it, don’t export it.

Interview questions

  1. Q: What problem does exports solve?

    • A: It defines the allowed import paths and prevents accidental public APIs via deep imports.
  2. Q: How do you ship typings for multiple entry points?

    • A: Emit .d.ts per entry and map them in exports under types.
  3. Q: Why is this important for long-term maintenance?

    • A: It lets you refactor internals without breaking consumers.

Quick recap

  • exports is your real API surface.
  • Map JS + .d.ts for every entry.
  • Prevent deep imports to keep your package maintainable.