for internal TypeScript packages in a monorepo
An internal TypeScript package that gets imported inside the monorepo but suddenly throws type errors or loads the wrong runtime module when the application bundles almost always has the same root cause, a misconfigured exports field. Understanding the details of conditional exports and typesVersions avoids this class of bugs for good.
Table of Contents
- 1. Why the exports field is mandatory today
- 2. Basic structure: main, types and exports working together
- 3. Conditional exports: import, require and types
- 4. Condition order decides the outcome
- 5. Subpath exports for granular package structure
- 6. typesVersions for older TypeScript versions
- 7. Understanding the dual package hazard problem
- 8. Debugging: tracing module resolution
- 9. Export strategies compared
- 10. Summary
- 11. FAQ
1. Why the exports field is mandatory today
Before the introduction of the exports field, the main field in package.json alone determined which file gets loaded when a package is imported, while arbitrary internal file paths such as @myorg/utils/dist/internal/helper.js remained directly importable too. For a TypeScript monorepo with many internal packages, this meant implementation details accidentally became part of the public API, because nothing prevented anyone from importing deep into internal directories.
The exports field solves this problem by explicitly defining which entry points a package offers at all. Any import path not listed in the exports field throws an error on import, regardless of whether the file physically exists. For internal packages in a TypeScript monorepo, this is a blessing for maintainability, because refactoring inside a package becomes possible without other packages accidentally depending on internal implementation details.
2. Basic structure: main, types and exports working together
For a simple TypeScript package in the monorepo, a minimal configuration defining a single main entry point is enough to start. It matters that main and types remain as a fallback for older tools, while exports represents the modern, authoritative truth for Node.js and current bundlers. Tools that do not support exports, such as very old Node versions, automatically fall back to main.
A common mistake in a growing TypeScript monorepo is maintaining only exports and letting main/types go stale or be forgotten. Some editor integrations and older build tools still read main first before evaluating exports, which can cause inconsistent behavior between the development environment and the production build if the two fields diverge.
{
"name": "@myorg/shared-utils",
"version": "1.4.0",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"files": ["dist"]
}
3. Conditional exports: import, require and types
Conditional exports allow providing different files for the same entry point depending on the execution context. The import condition applies when a consumer uses import syntax, require applies to CommonJS calls with require(), and types gives the TypeScript compiler the matching declaration file path. This structure is the reason a single TypeScript package in a TypeScript monorepo can be used by ESM consumers and older CommonJS consumers at the same time, without having to maintain two separate packages.
Additionally, more specific conditions exist, such as node for Node.js specific code, browser for bundlers operating in a browser context, and development/production for different builds depending on environment. In an internal TypeScript monorepo, this fine grained differentiation usually only pays off once a package actually needs different behavior per environment, for example because a debug variant emits additional warnings meant to be stripped in production.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": {
"import": "./dist/node.js",
"require": "./dist/node.cjs"
},
"browser": "./dist/browser.js",
"development": "./dist/index.dev.js",
"default": "./dist/index.js"
}
}
}
4. Condition order decides the outcome
A subtle but common mistake in conditional exports is the wrong order of keys inside a condition object. Node.js evaluates conditions in exactly the order they are written in package.json and takes the first one that matches. If types is not first, a bundler can end up matching import or default before the TypeScript compiler finds the correct declaration file, resulting in any typed imports with no visible error message.
The reliable rule for a TypeScript monorepo is therefore: always place types as the first key in every condition object, followed by more specific runtime conditions such as node or browser, with default as the final, most general fallback. TypeScript itself does not automatically check this order, which is why an ESLint plugin such as eslint-plugin-package-json or a manual review step when adding new packages is worthwhile.
5. Subpath exports for granular package structure
Larger internal packages in a TypeScript monorepo often offer more than one sensible entry point, for example separate modules for validation, formatting and date calculation. Subpath exports allow exactly that: instead of bundling everything through the main entry point ., which can create unnecessarily large bundle sizes, the package defines additional paths such as ./validation or ./date that consumers can import selectively.
A wildcard pattern with ./* additionally allows exporting whole directories dynamically without listing every single file path manually. This is practical for packages with many small modules, but carries the risk of accidentally exposing internal implementation details that should not really be part of the public API. For a cleanly cut TypeScript monorepo package, an explicit list of subpath exports is usually the better choice over a blanket wildcard export.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./validation": {
"types": "./dist/validation/index.d.ts",
"import": "./dist/validation/index.js"
},
"./date": {
"types": "./dist/date/index.d.ts",
"import": "./dist/date/index.js"
},
"./package.json": "./package.json"
}
}
6. typesVersions for older TypeScript versions
The typesVersions field solves a specific problem: not every team in a large TypeScript monorepo necessarily uses the same TypeScript version, and some newer TypeScript features in declaration files are not understood by older compiler versions. With typesVersions, a package can provide alternative declaration files for certain TypeScript version ranges, for example a simplified variant for TypeScript before version 5.0.
In practice, typesVersions is needed less often than exports itself, because modern monorepos usually enforce a uniform TypeScript version across all packages, for example through a shared root package.json with a fixed dependency version. For package authors who publish their library publicly on npm and must support a wide range of TypeScript versions, typesVersions nonetheless remains an important tool to avoid compatibility problems.
7. Understanding the dual package hazard problem
A dual package hazard occurs when a package is loaded both via import and via require, resulting in two distinct module instances in memory instead of a single one. This is especially dangerous for packages that hold global state, for example a configuration singleton or an internal cache, because instance checks with instanceof suddenly fail even though the same type is apparently being used.
For an internal TypeScript monorepo, this risk is avoided most reliably by consistently using only one module system, usually ESM with "type": "module" in the root package.json. If a dual package with a CommonJS and an ESM build is nevertheless offered for compatibility reasons, stateful code that must stay consistent across both loading paths should be moved into a separate, singly loaded package.
8. Debugging: tracing module resolution
When an import fails despite an exports configuration that looks correct, the Node.js flag --experimental-loader combined with NODE_DEBUG=module provides detailed output about which condition was actually evaluated and which file ends up loaded. For bundlers like Vite or Webpack, their own debug modes exist that show which path the module resolution actually took inside the internal TypeScript monorepo package.
A quick manual check that already clarifies many cases: node -e "console.log(require.resolve('@myorg/shared-utils'))" for CommonJS contexts, or a small ESM test script with a dynamic import() for ESM contexts. If the resolved path points to an unexpected file, the bug is almost always in the order or the missing conditions inside the exports field, not in the actual build process.
# CommonJS: which path does Node actually resolve?
node -e "console.log(require.resolve('@myorg/shared-utils'))"
# ESM: dynamic import to debug resolution
node --input-type=module -e "
import('@myorg/shared-utils').then((m) => console.log(m))
"
# Verbose debug output of module resolution
NODE_DEBUG=module node ./scripts/check-import.mjs
9. Export strategies compared
Depending on the size and usage context of an internal package in the TypeScript monorepo, a different export strategy fits better. The following overview classifies the most common approaches.
| Strategy | When it makes sense | Risk | Recommendation |
|---|---|---|---|
| Single entry point | Small, focused packages | Larger bundle on partial import | Sufficient for most internal packages |
| Explicit subpath exports | Packages with several domain modules | More upkeep with new modules | Preferred over wildcards |
| Wildcard subpath (./*) | Many small, generated modules | Internal details easily become public | Only use with a clear naming convention |
| Dual package (ESM+CJS) | External npm publication required | Dual package hazard with state | Usually avoidable internally, use ESM only |
For purely internal packages in a TypeScript monorepo that are never consumed outside the own repository, the simplest viable solution is usually a single ESM entry point with explicit subpath exports for larger packages. The complexity of dual package setups with a CommonJS fallback almost only pays off for packages that are additionally published publicly on npm.
Mironsoft
TypeScript package structure, module resolution and monorepo tooling
Puzzling import errors in internal packages?
We review exports configurations in your TypeScript monorepo, fix ordering and dual package issues, and set up a clean, maintainable package structure with clear public interfaces.
Exports audit
Review existing package.json configurations for error sources
Package structure
Cut subpath exports cleanly instead of using wildcard exports
Module resolution debugging
Find root causes of wrong imports quickly and systematically
10. Summary
The exports field is no longer an optional detail in a modern TypeScript monorepo, it is the authoritative public interface of an internal package. It prevents other packages from accidentally depending on internal implementation details and, with conditional exports, makes a single codebase usable for both ESM and CommonJS consumers. The correct order of conditions, with types always first, is the most common stumbling block.
Subpath exports structure larger packages cleanly, but should be defined explicitly rather than through wildcards, to avoid accidentally exposing internal details. Dual package setups with CommonJS and ESM almost only pay off for libraries published publicly on npm, while purely internal packages in a TypeScript monorepo benefit from a single, consistently used module system.
package.json exports field — the key takeaways
Order matters
types always as the first key in every condition object, otherwise declaration files can be overlooked.
Keep subpath exports explicit
Define individual subpaths deliberately instead of wildcard patterns, to keep the public API clearly bounded.
Avoid dual package hazard
Load stateful code internally through only one module system to prevent duplicate instances.
Debugging tools
require.resolve, dynamic import() and NODE_DEBUG=module show the actual resolution.