The hidden power of package.json

Omri Bar-Zik
Outbrain Engineering
5 min readJun 17, 2023

Most of us interact with the package.json file only to add a new script, update dependencies or update the version of our packages, but the package.json has a lot more to provide.

package.json with export and import propeties

We will talk about how to improve compatibility with different import mechanisms, restrict access to specific files, and use Node’s new features to improve our codebase.

Entry Points

When we require a package, Node.js looks for a node_modules folder that contains our package. By default, Node will search and run the file index.js:

.
├── node_modules/
│ └── some-module/
│ └── index.js <- This will be loaded
|
└── source.js // require('some-module')

We can change that behavior by adding a property in our package.json called main. This property is the path to the JavaScript file we want Node.js to load instead of index.js:

// some-module's package.json
{
"main": "lib/index.js"
}
.
├── node_modules/
│ └── some-module/
│ ├── lib/
│ │ └── index.js <- This will be loaded
| |
│ ├── index.js <- This will be ignored
│ └── package.json
|
└── source.js // require('some-module')

We can also support ESM (import) by using the property module:

// some-module's package.json
{
"module": "lib/index.mjs"
}
.
├── node_modules/
│ └── some-module/
│ ├── lib/
│ │ └── index.mjs <- This will be loaded
| |
│ ├── index.js
│ └── package.json
|
└── source.mjs // import data from 'some-module'

The TypeScript compiler uses a similar algorithm to Node.js to look for locating package types. The main difference is that TC searches for files with the .d.ts extension. By default, TC will look index.d.ts is inside the module folder, but we can change this behavior by adding the property types:

// some-module's package.json
{
"types": "lib/index.d.ts"
}
.
├── node_modules/
│ └── some-module/
│ ├── lib/
│ │ └── index.d.ts <- TS will load this
| |
│ ├── index.d.ts <- By default, TS will load this
│ └── package.json
|
└── source.js // import type { someType } from 'some-module'

The nice thing is these properties are not exclusive, so we can combine them all to support both CommonJS/CJS (require), ESM (import), and TypeScript simultaneously!

// some-module's package.json
{
"main": "lib/index.cjs",
"module": "lib/index.mjs",
"types": "lib/index.d.ts"
}
.
├── node_modules/
│ └── some-module/
│ ├── lib/
│ │ ├── index.d.ts <- TS will load this
│ │ ├── index.mjs <- EMS will load this
│ │ └── index.cjs <- CJS will load this
| |
│ └── package.json
|
├── source.ts // import type data from 'some-module'
├── source.mjs // import data from 'some-module'
└── source.cjs // const data = require('some-module')

Exports & Imports

Node introduced the exports property to the package.json in version 12.7.0 and the imports property in version 14.6.0. These new properties enable us to control better how other developers import from our package and how we can require files and modules inside our project.

Imports

In addition, to set the main entry point, we can also control how to access the package subdirectories and even prevent loading specific files altogether. We can do that by setting the property exports:

// some-module's package.json
{
"exports": {
".": "./lib/index.js", // like using "main"
"./utils": "./lib/utils/index.js"
}
}
.
├── node_modules/
│ └── some-module/
│ ├── lib/
| | ├── hidden.js
│ │ ├── index.js // main path import
| | |
│ │ └── utils/
│ │ └── index.js // subpath import
| |
│ └── package.json
|
├── source.js // const data = require('some-module')
└── other.js // const utils = require('some-module/utils')

This way, we can mask the actual path of our files and export by using the path we set inside our package.json.

The bonus of this approach is that we can hide/prevent access to any files we don’t export, the file hidden.js is inaccessible, and if we try to require it, we will get this error:

// source.js
require('some-module/lib/hidden')
// ^
// Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './lib/hidden' is not defined by "exports"

We can change this behavior using a glob pattern to export all the files in a given directory:

// some-module's package.json
{
"exports": {
".": "./lib/index.js",
"./lib/*": "./lib/*", // export all files from lib
"./utils": "./lib/utils/index.js"
}
}

With this new export, we can now access hidden.js:

// source.js
require('some-module/lib/hidden.js')

Do note that we must specify the file extension to access the files with the glob pattern.

We can also get specific paths for specific environments (CJS/ESM) like so:

// some-module's package.json
"exports":{
".": { // require from root
"require": "./lib/index.cjs", // CJS
"import": "./lib/index.mjs", // ESM
"default": "./lib/index.js", // fallback
"types": "./lib/index.d.ts" // TS
}
}

Imports

In a complex project, we sometimes want to create scopes so we will not need to write an endless ../../../../../

Adding the property imports in the package.json allows us to set scopes to import globally from our node application:

// our project's package.json
{
"imports": {
"#utils/read": "./src/utils/read.js"
}
}
.
├── package.json
└── src/
├── dir/
│ └── dir/
│ └── dir/
│ └── some.js
└── utils/
└── read.js

To import read.js from some.js, all we need to do is this:

const read = require('#utils/read')

Note that we must use the # both in the import field and when we require the file.

And like in the exports property, we can set glob to import all the content of the directory like so:

// our project's package.json
{
"imports": {
"#utils/*": "./src/utils/*"
}
}
require('#utils/read.js')

Do note that we need to add the file extension to access files, like with the export property.

And like the export property, we can specify different imports for different environments (CJS/ESM), but unlike the export property, we can set external packages as the path:

// our project's package.json
{
"imports": {
"#lodash": {
"require": "lodash",
"import": "lodash-es"
}
},
"dependencies": {
"lodash-es": "^4.17.21",
"lodash": "^4.17.21"
}
}

With this setup, we can use lodash without changing our code:

// index.cjs
const { omit } = require("#lodash");
// index.mjs
import { omit } from "#lodash";

And now each environment will load its own lodash.

Word Of Warning

The main drawback of using imports and exports is that most IDEs don’t support these new fields, will not auto-complete you, and might throw you errors while developing.

Final Notes

The package.json file has more to offer than just managing scripts and dependencies. With various properties, we can Improve compatibility with different import mechanisms and allow us to control how our package is loaded in CommonJS and ESM. We can Restrict access to specific files and control how our package subdirectories are accessed. We can create scopes and import them globally from our Node application.

--

--