DEV Community

Cover image for How to configure TSC + Webpack + ESM for NodeJS

How to configure TSC + Webpack + ESM for NodeJS

Just had a revelation in how different tools work together thanks to hours of trying to build my Nx monorepo app where I was using:

  • Bleeding-edge syntaxes in TS/JS like using and Symbol.dispose.
  • Typescript to be on the safe side of JavaScript.
  • And lastly ESM since I feel we're moving to that direction (at least tools like Vite are moving away from CommonJS (ref)).

Note

Here I use pnpm since I really do not like to be subjected to this fate again 😅.

Steps to configure it

  1. Let's create a directory for ourselves to do our bidding 😂: mkdir huawei && cd huawei.
  2. Create a package.json with pnpm init.
  3. Install these dev dependencies:

     pnpm add -D @types/node typescript webpack webpack-cli
    
  4. Now we need to add these scripts in our package.json:

     "build:tsc": "rm -rf lib-esm && tsc",
     "build": "webpack --mode=production --node-env=production"
    
  5. Do not forget to add "type": "module" to your package.json.

  6. Let's create our tsconfig.json:

    {
      "compilerOptions": {
        "allowSyntheticDefaultImports": true,
        "noImplicitAny": true,
        "module": "NodeNext",
        "target": "ES6",
        "experimentalDecorators": true,
        "sourceMap": true,
        "pretty": true,
        "outDir": "out-tsc",
        "esModuleInterop": true,
        "lib": ["esnext.disposable"],
        "skipLibCheck": true,
        "moduleResolution": "nodenext"
      }
    }
    

    Here it is important to note that:

    1. out-tsc: that's where tsc will store the compiled code. We did separate tsc's output dir from webpack to be able to differentiate between what tsc will generate and what webpack generates.

      Not to mention that we wanna remove the output directory before build in both webpack and tsc.

    2. Our target is ES6.

    3. Adding "lib": ["esnext.disposable"] to support disposable is crucial and we needed to add "esModuleInterop": true to prevent some other issues (read this for a comprehensive explanation.).

      For some reason in Codesandbox I had to add "DOM" to my lib too. But in local it is not necessary.

    4. "moduleResolution": "nodenext".

  7. Now its time to write our webpack.config.cjs:

    // @ts-check
    const { resolve } = require("path");
    
    const isProduction = process.env.NODE_ENV == "production";
    /**@type {import('webpack').Configuration} */
    const config = {
      target: "node",
      entry: "./out-tsc/index.js",
      output: {
        path: resolve(__dirname, "dist"),
        clean: true,
        filename: "index.[contenthash].js",
      },
      experiments: {
        outputModule: true,
      },
      resolve: {
        extensions: [".tsx", ".ts", ".jsx", ".js"],
        extensionAlias: {
          ".js": [".js", ".ts"],
          ".cjs": [".cjs", ".cts"],
          ".mjs": [".mjs", ".mts"],
        },
      },
    };
    
    module.exports = () => {
      if (isProduction) {
        config.mode = "production";
      } else {
        config.mode = "development";
      }
      return config;
    };
    

    So a couple of things we need to discuss and explain why they are there:

    1. I guess target: "node" is obvious but for the sake of completeness I wanna say that we need it otherwise webpack would complain whenever we import something from nodejs.
    2. experiments is what makes the difference. It is telling webpack to generate ESM modules rather than CommonJS.
    3. We do not need ts-loader, I tried to kinda use it but I was getting this error and had no idea why it was not first compiling it with tsc as I instructed it and then bundle it:

       ERROR in ./src/index.ts 4:6
       Module parse failed: Unexpected token (4:6)
       You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
       | import { random } from "./random.mjs";
       | 
       > using connect = new Connect()
       | 
       | console.log(random());
       webpack 5.96.1 compiled with 1 error in 173 ms
        ELIFECYCLE  Command failed with exit code 1.
      

      But if your know what I was doing wrong I appreciate it if you could fork my codesandbox, fix it, and share it here so we can look at what I was doing wrong.

    4. resolve is another important piece of this puzzle. We wanted to use .mts extension. But then you need to import them as .mjs. If you do not do this when tsc compiles your code, it will not add .mjs or .js extension to your imports. Thus when you bundle it with webpack and try to run it you will get a "module not found error" (read more about it here):

      ERROR in ./out-tsc/index.js 53:0-36
      Module not found: Error: Can't resolve './connect' in '/tmp/test/out-tsc'
      Did you mean 'connect.js'?
      BREAKING CHANGE: The request './connect' failed to resolve only because it was resolved as fully specified 
      (probably because the origin is strict EcmaScript Module, e. g. a module with javascript mimetype, a '*.mjs' file, or a '*.js' file where the package.json contains '"type": "module"').
      The extension in the request is mandatory for it to be fully specified.
      Add the extension to the request.
      resolve './connect' in '/tmp/test/out-tsc'
      using description file: /tmp/test/package.json (relative path: ./out-tsc)
          using description file: /tmp/test/package.json (relative path: ./out-tsc/connect)
            /tmp/test/out-tsc/connect doesn't exist
      
      webpack 5.96.1 compiled with 1 error in 333 ms
      ELIFECYCLE  Command failed with exit code 1.
      
    5. I choose CommonJS for the webpack config file since I had some trouble with it being TS or JS.

Codesandbox

https://codesandbox.io/p/sandbox/nodejs-webpack-esm-ts-4p4fpl


You can also find me on:

Top comments (0)