The JavaScript ecosystem is vibrant but fragmented. If you have worked with Node.js and TypeScript recently, you have likely encountered the friction between CommonJS (CJS) and ES Modules (ESM). It starts as a minor annoyance and often grows into a significant productivity sink.

The Complexity of Modules

Node.js supports both systems, but mixing them is fraught with peril. You might have seen the dreaded ERR_REQUIRE_ESM error when trying to import an ESM-only library into a CJS project. This happens because the two systems are fundamentally incompatible: ESM contexts lack the require method, and CommonJS contexts lack the import method (though dynamic import() is available).

Beyond this basic incompatibility, Node.js introduces its own set of rules that can be dizzying:

  • File Extensions: In Node.js ESM, relative imports must include file extensions.
  • Package Configuration: You have to manage the type field in package.json to tell Node.js how to treat .js files, or use .mjs/.cjs extensions explicitly.
  • Exports Map: The exports field in package.json adds another layer of visibility control and aliasing.

TypeScript adds yet another layer of complexity. It has its own module resolution strategies that don’t always align perfectly with Node.js. And the tsconfig.json settings can further complicate matters.

According to the TypeScript documentation, the only value for the module setting that fully supports ESM in Node.js is "nodenext". However, this setting introduces its own quirks. For example: you often have to write imports with .js extensions (e.g., import foo from './foo.js') even though the file on disk is .ts. It feels counterintuitive and adds mental overhead.

In our opinion, the current state of module interoperability in JavaScript ecosystem, Node.js runtime and TypeScript compiler is a mess. It wastes developer time and energy that could be better spent building features. But we still have to wait until the ecosystem matures and gets easier to use.

Module Interoperability Headache

Enter TSX

We decided to cut through this complexity by adopting TSX in our production environment. TSX is a runtime that allows you to execute TypeScript and JavaScript directly, handling the transpilation on the fly using esbuild.

While TSX is commonly used as a development tool (a modern replacement for ts-node), we found it robust enough for production. It abstracts away the module resolution mess, allowing CJS and ESM to coexist more peacefully.

Is it Production Ready?

The immediate question is usually about performance. “Isn’t running TypeScript directly slow?”

TSX uses esbuild under the hood, which is incredibly fast. The startup overhead is minimal, and once the code is running, it is just Node.js. The stability of TSX effectively mirrors the stability of Node.js and esbuild—both mature, battle-tested technologies.

For us, the trade-off was clear: the slight, almost negligible performance cost was worth the massive gain in developer experience and maintainability. We no longer spend hours debugging module resolution errors.

Note: We haven’t run formal benchmarks, but we haven’t observed any noticeable performance degradation in our workloads.

Why not just bundle?

A common alternative is to use a bundler like Vite or esbuild to compile everything into a single compatible file for production.

This is a valid strategy—bundlers are great at hiding these interoperability details. However, it introduces a build step and potential issues with relative paths for non-code assets. As you know, Node.js applications often rely on process working directories and relative paths for loading configuration files, templates, or other resources. Bundling the entire application can complicate this, we have to add extra logic to manage paths correctly.

As an example, Nuxt.js bundles server code, but in our experience, it breaks relative paths, so we have to patch the path resolution logic via an environment variable, like this:

const getAppRoot = () => {
    if (process.env.APP_ROOT) {
        return path.resolve(process.env.APP_ROOT);
    }
    const { __dirname } = getCurrentFilePath(); // custom function to get __dirname in ESM
    return path.resolve(__dirname, '..', '..', '..', '..');
};

Fortunately, tsx gives us the “bundler” benefits—handling the interop—without the explicit build artifact, and never breaking relative paths.

How to Migrate

The migration process was surprisingly straightforward.

  1. Move dependency: We moved tsx from devDependencies to dependencies in our package.json.

  2. Drop the build: We removed the build step that compiled TypeScript to JavaScript. No more dist or build folders to manage in Docker images.

  3. Update scripts: We changed our start script to run the entry point directly.

    "start": "tsx src/index.ts"
    

Bonus: Dynamic Capabilities

One unexpected benefit was the ability to use dynamic imports with TypeScript files natively. We can now import modules dynamically based on runtime conditions without needing a complex pre-compilation setup.

// dynamic-import.ts
const moduleName = './someModule.ts';
import(moduleName).then((module) => {
  module.doSomething();
});

Conclusion

Migrating to TSX has simplified our deployment pipeline and removed a whole category of bugs related to module interoperability. Instead of fighting configuration files, we are back to focusing on building features.