This document outlines the plan to migrate from TypeScript's deprecated "moduleResolution": "node" (node10) to "moduleResolution": "node16" or "nodenext". This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
This migration will resolve the following issues:
- #154 - Upgrade
moduleResolutionfromnodetonode16ornodenextin tsconfig - #110 - Published ESM code has imports without file extensions
- #64 - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
- #146 - Can not import
@actions/workflow-parser
All packages use "moduleResolution": "node":
| Package | moduleResolution | TypeScript |
|---|---|---|
| expressions | "node" |
^4.7.4 |
| workflow-parser | "node" |
^4.8.4 |
| languageservice | "node" |
^4.8.4 |
| languageserver | "node" |
^4.8.4 |
| browser-playground | "Node16" ✅ |
^4.9.4 |
This causes TypeScript to emit code like:
// Published to npm - INVALID ESM
export { Expr } from "./ast"; // Missing .js extension!ESM in Node.js 12+ requires explicit file extensions. When users try to import these packages:
// User's code
import { Expr } from "@actions/expressions";Node.js fails with:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
TypeScript 5.7 introduced a new compiler option that automatically rewrites .ts extensions to .js in output:
Source code:
import { Expr } from "./ast.ts";Compiled output:
export { Expr } from "./ast.js";Pros:
- Source uses
.tsextensions (matches actual files) - Works with Deno (which requires
.tsextensions) - TypeScript automatically transforms to
.js - Modern, forward-looking approach
Cons:
- Requires TypeScript 5.7+
- Relatively new feature
- BUG: See "Known Issues" section below
Use .js extensions in source TypeScript files:
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extensionPros:
- Works with TypeScript 4.7+ (with node16 moduleResolution)
- Well-established pattern
- No post-processing needed
Cons:
- Confusing -
.jsfiles don't exist at write time - Doesn't work with Deno out of the box
Use Option A with workarounds for known issues (see below).
Problem: The root node_modules/typescript was version 4.9.5 (pulled in by ts-node and tsutils dependencies), while workspace packages specified ^5.8.3.
Symptoms:
npx tsc --versionshowed 4.9.5require('typescript').versionin ts-jest showed 5.8.3- Confusing build failures
Solution: Add npm overrides in root package.json:
{
"overrides": {
"typescript": "5.8.3"
}
}Problem: ts-jest 29.4.6 uses typescript.JSDocParsingMode.ParseAll which doesn't exist in TypeScript's ES module exports.
Error:
TypeError: Cannot read properties of undefined (reading 'ParseAll')
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
Root Cause: ts-jest accesses typescript_1.default.JSDocParsingMode.ParseAll but TypeScript has no default export in ESM.
Solution:
- Use ts-jest 29.0.3 (older version that doesn't use this API)
- OR wait for ts-jest fix
- Stay on TypeScript 5.8.3, not 5.9+
Problem: TypeScript's rewriteRelativeImportExtensions: true correctly rewrites .ts → .js in .js output files, but incorrectly keeps .ts extensions in .d.ts declaration files.
Example:
- Source:
export { Expr } from "./ast.ts"; - Output
index.js:export { Expr } from "./ast.js";✅ Correct - Output
index.d.ts:export { Expr } from "./ast.ts";❌ Wrong (should be.js)
Upstream Issue: microsoft/TypeScript#61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
Workaround: Post-process .d.ts files with a script. Create script/fix-dts-extensions.cjs:
#!/usr/bin/env node
/**
* Post-build script to fix TypeScript's rewriteRelativeImportExtensions bug
* where .d.ts files get .ts extensions instead of .js extensions.
* See: https://github.com/microsoft/TypeScript/issues/61037
*/
const fs = require('fs');
const path = require('path');
function fixDtsFile(filePath) {
let content = fs.readFileSync(filePath, 'utf8');
const original = content;
// Replace .ts extensions in import/export statements with .js
content = content.replace(/(from\s+["'])([^"']+)\.ts(["'])/g, '$1$2.js$3');
content = content.replace(/(import\s*\(\s*["'])([^"']+)\.ts(["']\s*\))/g, '$1$2.js$3');
content = content.replace(/(export\s+\*\s+from\s+["'])([^"']+)\.ts(["'])/g, '$1$2.js$3');
if (content !== original) {
fs.writeFileSync(filePath, content, 'utf8');
console.log(`Fixed: ${filePath}`);
return true;
}
return false;
}
function walkDir(dir, callback) {
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
walkDir(filePath, callback);
} else if (file.endsWith('.d.ts')) {
callback(filePath);
}
}
}
function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: fix-dts-extensions.cjs <dist-dir> [<dist-dir2> ...]');
process.exit(1);
}
let fixedCount = 0;
for (const dir of args) {
if (!fs.existsSync(dir)) {
console.warn(`Directory not found: ${dir}`);
continue;
}
walkDir(dir, (filePath) => {
if (fixDtsFile(filePath)) {
fixedCount++;
}
});
}
console.log(`Fixed ${fixedCount} .d.ts file(s)`);
}
main();Note: Use .cjs extension since root package.json has "type": "module".
Usage in package.json build scripts:
{
"scripts": {
"build": "tsc --build tsconfig.build.json && node ../script/fix-dts-extensions.cjs dist"
}
}Problem: The languageserver tests hang indefinitely when running with the ESM configuration.
Status: Not fully diagnosed. Tests pass on main branch but hang on ESM branch.
Possible causes:
- Jest ESM module resolution issues
- Cross-package source mappings in jest.config.js
- vscode-languageserver ESM compatibility issues
- Specific test file causing hang (needs isolation testing)
Investigation needed:
- Run individual test files to isolate the hanging test
- Check if vscode-languageserver has ESM compatibility issues
- Review jest configuration for problematic mappings
- Try running with
--detectOpenHandlesflag
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"rewriteRelativeImportExtensions": true,
"lib": ["ES2022"],
"target": "ES2022"
}
}/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^(\\.{1,2}/.*)\\.ts$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
isolatedModules: true,
},
],
},
moduleFileExtensions: ["ts", "js"],
};{
"overrides": {
"typescript": "5.8.3"
}
}{
"devDependencies": {
"typescript": "^5.8.3",
"ts-jest": "^29.0.3"
}
}| Package | Tests | Status |
|---|---|---|
| expressions | 1068 | ✅ Pass |
| workflow-parser | 292 | ✅ Pass |
| languageservice | 452 | ✅ Pass |
| languageserver | 6 files | ❌ Hangs (needs investigation) |
- Merge all pending PRs to avoid conflicts
- Ensure clean main branch state
- Add
"overrides": { "typescript": "5.8.3" }to root package.json - Update all workspace packages to TypeScript ^5.8.3
- Downgrade ts-jest to ^29.0.3 in all packages
- Run
npm installto apply changes
- Update tsconfig.json in each package with node16 settings
- Add
rewriteRelativeImportExtensions: true
- Update all relative imports in source files to use
.tsextensions - This was already done in PR #243
- Add
script/fix-dts-extensions.cjsto repository - Update build scripts to run fix after tsc
- Investigate why tests hang
- Isolate problematic test file
- Apply fix or workaround
- Run
npm run buildin all packages - Run
npm testin all packages - Test importing published packages in Node.js ESM mode
- Verify browser-playground still works
- Update GitHub Actions workflows if needed
- Ensure fix-dts-extensions.cjs runs in CI
- Root package.json: Add TypeScript override
- All packages: TypeScript 5.8.3, ts-jest 29.0.3
- All tsconfigs: node16 moduleResolution, rewriteRelativeImportExtensions
- All imports: Add .ts extensions (already done)
- Build scripts: Add post-build .d.ts fix
- languageserver: Debug test hang issue
{ "compilerOptions": { "moduleResolution": "node16", // or "nodenext" "rewriteRelativeImportExtensions": true } }