The short answer
In package.json, give each entry in exports a types condition first, pointing at the .d.ts file, and include the types folder in files:
{
"name": "@acme/price-tools",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./types/index.d.ts",
"default": "./src/index.js"
}
},
"files": ["src", "types"]
} A consumer project then gets the declared types, checked here with a jsconfig.json that does not read the package’s JavaScript at all (maxNodeModuleJsDepth: 0), so the types can only come from the .d.ts:
import { formatPrice } from "@acme/price-tools";
console.log(formatPrice(1999).padStart(10));import { formatPrice } from "@acme/price-tools";
console.log(formatPrice(1999).padStart(10));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [],
"maxNodeModuleJsDepth": 0
},
"include": ["*.js"]
} Why it works
Under module: "nodenext" (and node16 or bundler), tsc resolves a package import the way Node.js does: through exports. For each entry it looks for a types condition before default, and uses that file for the types while the runtime uses the JavaScript. The condition must come first, because conditions are matched in order. Writing the .d.ts files is a separate step, covered in creating .d.ts files, and the library setup puts both together.
The same thing in TypeScript
The TypeScript equivalent tab is the consumer as a .ts file; it resolves the package the same way. A package written in TypeScript publishes the same exports map, with its compiled JavaScript as default.
What goes wrong
A top-level types field is not enough once the package has exports: tsc follows exports, finds only JavaScript, and reports TS7016, even mentioning where the types are:
import { formatPrice } from "@acme/price-tools";error TS7016: Could not find a declaration file for module '@acme/price-tools'. '/project/node_modules/@acme/price-tools/src/index.js' implicitly has an 'any' type.
There are types at '/project/node_modules/@acme/price-tools/types/index.d.ts', but this result could not be resolved when respecting package.json "exports". The '@acme/price-tools' library may need to update its package.json or typings.
console.log(formatPrice(1999));jsconfig.json for this example
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"strict": true,
"target": "es2022",
"module": "nodenext",
"types": [],
"maxNodeModuleJsDepth": 0
},
"include": ["*.js"]
} Gotchas
- Every subpath needs its own
types. An export such as"./format"needs its owntypescondition (or a.d.tsnext to its.jsfile). - A
.d.tsnext to the.jsfile also works. For"default": "./src/index.js", tsc also looks for./src/index.d.ts; a separatetypes/folder needs the explicit condition. - Ship the files. If
filesinpackage.jsonleaves outtypes/, the published package has atypescondition that points at nothing.
Questions
Is the top-level types field still needed?
Not for consumers that resolve with exports (node16, nodenext, bundler). It still helps tools and configs that ignore exports, such as moduleResolution: "node10", so packages often keep both, pointing at the same file.
How can I check my package before publishing?
Pack it with npm pack, install the tarball in a small consumer project with a jsconfig.json, and run tsc there. The "Are the types wrong?" tool from the TypeScript community checks the same thing for several resolution modes.
Sources
- Modules Reference (packages with exports) · TypeScript handbook
- Publishing (declaration files) · TypeScript handbook
- package.json: exports · Node.js documentation
Last reviewed 21 October 2026.