Import modules like a pro using enhanced resolve
December 7, 2021

A common folder structure we often see in frontend projects is a common or shared folder at the root of the project. This folder usually houses the code shared across the project, like, UI components, utility functions, abstractions, error handlers and so on.

├── app
   ├── favicon.ico
   ├── assets
   ├── routes
      ├── home
      ├── blog
         ├── blog-header
         ├── blog-body
            ├── BlogBody.tsx
            └── BlogBodyStyles.scss
         └── blog-footer
      └── projects
   └── partials/template
├── shared
   ├── components
   └── utils
├── dist
├── node_modules
├── test
├── .gitignore
├── package.json
├── webpack.config.js
└── README.md

This pattern works great for discovery, avoiding duplication and life is great... until you have to import these shared files somewhere else, deep in the nested folder hierarchy of app code.

You'll inadvertently end up with a long relative import path.

BlogBody.tsx
import { Button } from "../../../shared/components/Button";

Though having a flat folder structure would reduce the level of nesting, it would still cause problems while trying to refactor the code or move files around.

It'd be a lot more cleaner if we could simple do

import { Button } from "@shared/components/Button";

Although @shared scope is optional, it helps differentiate app code from from node_modules.

Lucky for us, webpack's enhanced-resolve lets us do just that.

Webpack resolve

Webpack provides a resolve option in the config that lets us override the module resolution and tell webpack where to look for while importing a module.

webpack.config.js
module.exports = {
    //...
    resolve: {
        // configuration options
    },
};

There are several ways to achieve what we intend to and the webpack documentation page lists all of them, but we'll specifically look at resolve plugin based approach.

To begin with, we tell webpack what directories should be searched when resolving modules.

We need to add our shared folder path to the resolve.modules list.

webpack.config.js
const path = require("path");
 
module.exports = {
    // ...
    resolve: {
        modules: [path.resolve(__dirname, "shared"), "node_modules"],
    },
};

Next, we need to write a resolver plugin to help webpack find the module code that needs to be included when we write import ... from '@shared/...'

Our custom PackagePluginResolver extends webpack's default enhanced-resolve to support the @shared/... path.

PackagePluginResolver.js
const path = require("path");
 
class PackagePluginResolver {
    constructor(source, target) {
        this.source = source;
        this.target = target;
    }
 
    apply(resolver) {
        const target = resolver.ensureHook(this.target);
        resolver
            .getHook(this.source)
            .tapAsync(
                "PackagePluginResolver",
                (request, resolveContext, callback) => {
                    const innerRequest = request.request || request.path;
                    if (!innerRequest) {
                        return callback();
                    }
 
                    // only apply custom resolution logic for paths that begin with @shared
                    if (innerRequest.startsWith("@shared")) {
                        const [scope, packageName, ...rest] =
                            innerRequest.split("/");
                        // run any scope check or packageName checks as needed
 
                        const newRequestPath = [
                            "shared",
                            packageName,
                            ...rest,
                        ].join("/");
 
                        // new path that points to the shared folder
                        const newPath = path.resolve(__dirname, newRequestPath);
 
                        const requestObject = {
                            ...request,
                            request: newPath,
                            fullySpecified: false,
                        };
 
                        return resolver.doResolve(
                            target,
                            requestObject,
                            `aliased ${innerRequest} to ${newRequestPath}`,
                            resolveContext,
                            (err, result) => {
                                if (err) {
                                    return callback(err);
                                }
                                if (result) {
                                    return callback(null, result);
                                }
 
                                return callback(null, null);
                            }
                        );
                    } else {
                        return callback();
                    }
                }
            );
    }
}
 
module.exports = { PackagePluginResolver };

As a final step, add this plugin to webpack's resolver plugins list.

webpack.config.js
const path = require("path");
const PackagePluginResolver = require("...");
 
module.exports = {
    // ...
    resolve: {
        // ...
        modules: [path.resolve(__dirname, "shared"), "node_modules"],
        plugins: [new PackagePluginResolver("module", "resolve")],
    },
};

And with that, you should be able to import modules like a pro!