CoffeeScript in an ESM Node Project: Check the Compiler Output First
Mixing CoffeeScript with Node ES modules fails on output format, not syntax. Compile one file, inspect it, then choose pre-compilation or the register hook.
21 Jun 2026, 12:50 UTC

The failure is a module-format mismatch, not a CoffeeScript bug
You have a Node project whose entry points are ES modules — .mjs files, or a package.json containing "type": "module". You want to keep a few utilities in CoffeeScript. You compile them, import them, and Node throws a resolution or syntax error that talks about module format rather than about your code.
The cause is usually that the compiled .js is CommonJS (using require and module.exports) while the importing file is an ES module (using import and export). CoffeeScript's syntax is not the problem; the emitted format is.
The useful takeaway: before designing a build pipeline, compile one file and read what the compiler actually produced. The output format is a property of your compiler version and configuration, not something to assume.
Step one: compile a single file and inspect the output
Run these from your project root. You need CoffeeScript installed locally (npm install --save-dev coffeescript) and a Node version with native ES module support (12.17+ or 14+; earlier versions required a flag).
coffee --versionCreate src/math.coffee:
square = (x) -> x * x
module.exports = { square: square }Compile it into a separate output directory so generated files never mix with sources:
coffee -c -o lib src/math.coffeeThen read the generated file instead of guessing:
grep -nE 'module\.exports|^export |require\(' lib/math.jsWhat matches tells you which strategy below applies. A module.exports line means CommonJS output. Lines starting with export or import mean ES module output. Do not skip this check: the emitted format depends on the CoffeeScript version and on whether your source uses export syntax.
Strategy A: pre-compile, then import the CommonJS output
Node's ES module loader can import CommonJS files. The reliable form is a default import:
// app.mjs
import math from './lib/math.js';
console.log(math.square(5));Named imports such as import { square } from './lib/math.js' only work when Node's static analyzer recognizes the export pattern in the generated file. CoffeeScript's output shape is not something you control directly, so test the named form once; if it fails, use the default import and destructure from it.
For development, the watch flag removes the manual compile step:
coffee -w -c -o lib srcAdd -m to emit source maps so stack traces point back at .coffee lines:
coffee -c -m -o lib srcStrategy B: compile at runtime with the register hook
CoffeeScript ships a require hook. In a CommonJS project you can preload it instead of compiling ahead of time:
node -r coffeescript/register app.jsWatch the package name: CoffeeScript 2.x publishes coffeescript, while 1.x published coffee-script. Having both in one dependency tree is a common source of confusing failures.
The hook patches CommonJS require. It does not make import './math.coffee' work inside an ES module, because ESM resolution does not go through that hook. If your entry point is ESM, treat runtime registration as unavailable unless you add a dedicated loader, and verify that loader against your Node version before committing to it.
Trade-offs
| Aspect | Pre-compile | Runtime register hook |
|---|---|---|
| Build step | Required (coffee -c, or -w in development) | None |
| Startup cost | None; output is already JavaScript | Compilation on first load of each file |
| Debugging | Needs -m for source maps | Source maps generated during compilation |
| CI and deployment | Compiled output can be cached and shipped | CoffeeScript must be installed in the runtime environment |
| Version stability | Pin the compiler; output is frozen until you rebuild | Uses whatever version is installed at runtime |
| ESM entry points | Works, via Node's CommonJS interop | Does not apply to ESM resolution |
Limitations worth knowing before you commit
- CoffeeScript 2.x emits modern JavaScript. If you target an older runtime, the
--transpileflag routes output through Babel and adds a dependency. - There are no interfaces or enums. If your team relies on those, CoffeeScript is the wrong layer for shared type contracts.
- Debugging depends on source maps. Without
-m, stack traces reference generated lines. - Pin the compiler version in
package.json. Runtime registration uses whatever is installed, so a dependency bump can change generated code without any source change.
What to do next
- Run
coffee --versionand record the result. - Compile one representative file and grep the output for the module format.
- If it is CommonJS and your app is ESM, import it with a default import and confirm the app starts.
- If you need ES module output, confirm your compiler version supports it and re-run the same grep before changing the pipeline.
Verification is one command: start the app, confirm it loads without a module-resolution error, then call one exported function and check the value it returns.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.