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.
Frontend Interview Team
March 01, 2026
What you’ll learn
- Why
exportsis the real public API (not your folder structure) - How to ship correct
.d.tsfor 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.
Recommended layout (library)
package/
src/
index.ts
string.ts
dist/
index.js
index.d.ts
string.js
string.d.ts
package.json
tsconfig.build.jsonThe 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?
typesat the top is a fallback for older tooling.- Per-entry
exports["./x"].typesensures 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.tstodist/
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
-
Q: What problem does
exportssolve?- A: It defines the allowed import paths and prevents accidental public APIs via deep imports.
-
Q: How do you ship typings for multiple entry points?
- A: Emit
.d.tsper entry and map them inexportsundertypes.
- A: Emit
-
Q: Why is this important for long-term maintenance?
- A: It lets you refactor internals without breaking consumers.
Quick recap
exportsis your real API surface.- Map JS +
.d.tsfor every entry. - Prevent deep imports to keep your package maintainable.