Hello! Welcome to the final module of our course. We've built a robust, well-tested reactive library. Now, it's time to prepare it for the world.
In our previous lessons, we built a comprehensive testing suite, culminating in an automated test to detect memory leaks. With the quality of our library assured, we can now shift our focus to packaging and distribution.
This lesson tackles a crucial aspect of publishing a modern TypeScript library: configuring package.json. We'll move beyond the traditional main and module fields and dive into the powerful exports and typesVersions fields. This will ensure that consumers of our library—whether they're using ES Modules (ESM) or CommonJS (CJS)—get the right code and, most importantly, the right type definitions for a seamless development experience.
1. The Modern Approach to Defining Package Entry Points
For years, library authors have juggled multiple fields in package.json to support different JavaScript environments:
"main": The entry point for CommonJS environments (e.g., older Node.js versions), typically pointing to a.jsfile."module": The entry point for ESM-aware tools (e.g., bundlers like Vite, Webpack), pointing to an.mjsor.jsfile with ES module syntax."types": The entry point for TypeScript, pointing to the main declaration file (e.g.,index.d.ts).
This approach has several drawbacks:
- No Encapsulation: Consumers can import any internal file from your package (e.g.,
my-rx/dist/internal/helpers), creating a fragile dependency on your project's structure. - Complex Tooling: Supporting different formats and entry points often requires complex build configurations.
- Ambiguity: It's not always clear which file a tool should pick, leading to resolution issues.
The exports field was introduced in Node.js v12.7.0 to solve these problems by providing a single, authoritative way to define your package's public API.
To get a comprehensive overview of what the exports field does and the problems it solves, please read the introductory sections of the following guide.
Guide to the package.json `exports` field
The article 'Guide to the package.json exports field' on hirok.io provides an excellent breakdown of this feature. It clearly explains the benefits and core concepts.
Read the sections 'What’s the exports field?' and 'Benefits of exports'. Pay close attention to the concepts of 'Protecting internal files' and 'Multi-format packages'.
2. Deep Dive into the exports Field
The exports field is incredibly powerful. It allows you to define not just the main entry point, but also specific subpaths, and to provide different files based on certain conditions.
Subpath Exports: Defining Your Public API
With exports, you control exactly what modules a consumer can import. Anything not listed is private.
For example, our library has a main entry point, but we might also want to allow users to import operators or creation functions directly. We can define this with subpath exports:
{
"exports": {
".": "./dist/index.mjs",
"./operators": "./dist/operators.mjs",
"./creators": "./dist/creators.mjs"
}
}
This configuration allows:
import { Observable } from 'my-rx';(resolves to./dist/index.mjs)import { map } from 'my-rx/operators';(resolves to./dist/operators.mjs)- But it blocks
import { someInternalHelper } from 'my-rx/dist/internal/utils.mjs';
Conditional Exports: Supporting ESM and CJS
This is the most critical feature of exports for a modern library. You can specify different files for consumers using import (ESM) versus require (CJS). This is essential because in Module 1, we configured Vite to output both formats.
To see a detailed walkthrough of how TypeScript's module resolution uses these conditions, please watch the following video. It's a deep dive, but given your background, you'll find the level of detail very insightful.
A horrifically deep dive into TypeScript module resolution
The video 'A horrifically deep dive into TypeScript module resolution' provides a fantastic, in-depth explanation. We'll focus on the part that demonstrates exports in action.
First, watch 'Node 16 Module Resolution and exports in package.json' (11:36 - 13:29) for a conceptual introduction. Then, jump to the 'Practical Example: exports in package.json with Node 16' (45:35 - 53:08). This long segment shows exactly how conditional exports for import and require work and how TypeScript resolves them.
Tying it Together: Conditional Exports with TypeScript
The final piece of the puzzle is telling TypeScript which declaration files to use for each condition. If you provide an ESM file, you must provide ESM-compatible types. If you provide a CJS file, you need CJS-compatible types.
This is done by adding a types condition inside the import and require conditions.
The following resource provides the clearest possible example of this structure.
Guide to the package.json `exports` field
Let's return to the hirok.io guide. This section shows the exact package.json structure needed to support ESM, CJS, and their corresponding TypeScript definitions.
Read the section 'Targeting Node.js ESM, CJS, & TypeScript'. Study the package.json example carefully. Also, read the FAQ below it, which answers critical questions about why separate type files are needed and what tsconfig.json settings consumers require (moduleResolution: "NodeNext" or "Bundler").
3. Applying exports to Our Project
Let's synthesize this information into a configuration for our library. Based on our Vite setup from Module 1 which produces dist/index.mjs (ESM) and dist/index.cjs (CJS), and assuming our tsconfig.json is set to generate corresponding declaration files (.d.mts and .d.cts), here is what our exports field should look like:
// package.json
{
"name": "my-rx",
"version": "1.0.0",
"type": "module",
"files": [
"dist"
],
// Fallbacks for older tools
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
// The modern, authoritative entry points
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
}
}
A few key points about this configuration:
"type": "module": This tells Node.js and other tools to treat.jsfiles in our package as ES modules by default. It's a modern best practice."files": ["dist"]: This ensures that only thedistdirectory is included when we publish to npm, keeping our package lean.- Fallbacks: We still include
main,module, andtypesto provide the best possible compatibility with older build tools or TypeScript configurations that don't understand theexportsfield. exports: This is the source of truth for modern tools.- The
.defines the main entry point (import ... from 'my-rx'). - The
importcondition points to our ESM build and its corresponding.d.mtstypes. - The
requirecondition points to our CJS build and its.d.ctstypes. "types"must come before"default"within each condition for TypeScript to resolve it correctly.- We explicitly export
package.jsonas it's a common pattern for tools to read it. By default,exportshides it.
- The
Test your understanding!
A consumer of your library has the following tsconfig.json:
{
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node" // Note: This is the legacy mode
}
}
And writes the following code:const myRx = require('my-rx');
Which file will TypeScript resolve for the types, and which file will Node.js resolve at runtime?
Show answer
Because the consumer is using moduleResolution: "Node", their TypeScript compiler does not understand the exports field.
- For types, TypeScript will fall back to the top-level
"types"field and resolve./dist/index.d.ts. - At runtime, Node.js will fall back to the top-level
"main"field and resolve./dist/index.cjs.
This is why providing those fallbacks is still important for maximum compatibility. A consumer using a modern setup (moduleResolution: "NodeNext" or "Bundler") would correctly use the exports map.
4. Supporting Different TypeScript Versions with typesVersions
While exports handles different module formats, the typesVersions field handles providing different type definitions for different versions of the TypeScript compiler.
This is a more specialized feature. You would use it if, for example, your library's types use a feature introduced in TypeScript 4.5 (like a specific utility type), but you want to provide simpler, backward-compatible types for users still on TypeScript 4.4 or older.
The official TypeScript documentation provides the most direct explanation.
The TypeScript documentation explains the typesVersions field and its syntax.
Read the section 'Version selection with typesVersions'. Note the syntax, which uses semver ranges (e.g., '>=3.1') to map import paths to version-specific directories or files.
Here's a simple example of what it might look like:
{
"name": "my-rx",
"version": "1.0.0",
"types": "./dist/index.d.ts",
"typesVersions": {
"<4.5": {
// For TS versions older than 4.5, map 'dist/index.d.ts' to a fallback file
"dist/index.d.ts": ["dist/legacy-types/index.d.ts"]
}
}
}
In modern development, it's often simpler to establish a minimum supported TypeScript version for your library. However, typesVersions remains a powerful tool if you need to support a wide range of legacy projects. For our project, we won't need it, but it's crucial to know it exists and what problem it solves.
Conclusion
In this lesson, you've mastered the modern foundation for publishing a TypeScript library. You now understand how to create a robust, well-defined public API that works seamlessly across different module systems and tooling environments.
Key Takeaways:
- The
exportsfield is the modern, authoritative way to define your package's entry points, replacing the ambiguity ofmain,module, andbrowser. - Conditional Exports are the key to supporting both ESM (
import) and CJS (require) consumers from a single package. - To provide proper TypeScript support with
exports, you must use a nestedtypescondition for bothimportandrequire, pointing to format-specific declaration files (.d.mtsand.d.cts). - The
typesVersionsfield is a separate tool for providing different type definitions based on the consumer's TypeScript compiler version.
With package.json correctly configured, your library is now well-prepared for distribution.
Next Up: We'll leverage the TSDoc comments we've been writing throughout the course to automatically generate a professional API documentation website using TypeDoc. This will make your library easy for other developers to discover and use.
Can't find a good explanation? Sign up and we'll make it for you
Sign up