Migrate expressions, workflow-parser, and languageservice packages to use proper ESM imports with .js extensions that work with node16 moduleResolution. Changes: - Update tsconfig.build.json in each package to use module: node16 and moduleResolution: node16 - Add .js extensions to all relative import paths (Option B approach) - Fix yaml internal type imports in workflow-parser by defining local types - Add skipLibCheck to handle @types/node compatibility issues - Add TypeScript 5.8.3 override in root package.json - Add ESM migration plan documentation The languageserver package is deferred due to test hang issues that need further investigation. Related #154 - Upgrade moduleResolution from node to node16 or nodenext Related #110 - Published ESM code has imports without file extensions Related #64 - expressions: ERR_MODULE_NOT_FOUND attempting to run example Related #146 - Can not import @actions/workflow-parser Test results: - expressions: 1068 tests passed - workflow-parser: 292 tests passed - languageservice: 452 tests passed * docs: update ESM migration plan with findings - Update languageserver blocker: vscode-languageserver v8.0.2 lacks ESM exports (not a test hang issue) - Document that Option B (manual .js extensions) was chosen over Option A due to ts-jest compatibility issues - Add workaround for yaml package internal types (LinePos, NodeBase) - Update migration status table with accurate reason for deferral - Add skipLibCheck note for @types/node compatibility
9.3 KiB
ESM Migration Plan: Add File Extensions to Imports
Overview
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.
Issues Fixed
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
Problem Statement
Current State
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!
Why This Fails
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'
Migration Strategy
Option A: TypeScript 5.7+ with rewriteRelativeImportExtensions (Recommended)
TypeScript 5.7 introduced a new compiler option that automatically rewrites .ts extensions to .js in output:
{
"compilerOptions": {
"moduleResolution": "node16", // or "nodenext"
"rewriteRelativeImportExtensions": true
}
}
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
Option B: Manual .js Extensions
Use .js extensions in source TypeScript files:
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
Pros:
- Works with TypeScript 4.7+ (with node16 moduleResolution)
- Well-established pattern
- No post-processing needed
- Works with ts-jest without extra configuration
Cons:
- Confusing -
.jsfiles don't exist at write time - Doesn't work with Deno out of the box
Recommendation
Use Option B (manual .js extensions). Option A with rewriteRelativeImportExtensions has compatibility issues with ts-jest and requires additional workarounds.
Known Issues and Workarounds (December 2025)
1. TypeScript Version Conflicts in Monorepo
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"
}
}
2. ts-jest Compatibility with TypeScript 5.9+
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+
3. TypeScript rewriteRelativeImportExtensions Bug with .d.ts Files
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: https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
Workaround: Post-process .d.ts files with a script. See script/fix-dts-extensions.cjs.
Note: Since we use Option B (manual .js extensions), this bug does not affect our migration.
4. yaml Package Internal Types Not Exported
Problem: The yaml package does not export internal types like LinePos and NodeBase that are used in workflow-parser/src/workflows/yaml-object-reader.ts.
Error:
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
Solution: Define local type aliases in the file that uses them:
// Local type definitions to replace yaml internal imports
type LinePos = { line: number; col: number };
type NodeBase = { range?: [number, number, number] };
5. languageserver Blocked by vscode-languageserver Dependency
Problem: The vscode-languageserver package (v8.0.2) does not have proper ESM exports. When using moduleResolution: "node16", TypeScript requires packages to have an exports field in package.json for subpath imports to work.
Error:
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
Root Cause: The vscode-languageserver package.json only has main and browser fields, but no exports field:
{
"main": "./lib/node/main.js",
"browser": {
"./lib/node/main.js": "./lib/browser/main.js"
}
// No "exports" field!
}
With moduleResolution: "node16", TypeScript follows Node.js ESM resolution rules which require explicit exports for subpath imports like vscode-languageserver/browser and vscode-languageserver/node.
Status: Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
Current Decision: The languageserver package is deferred from this migration until the upstream vscode-languageserver package adds proper ESM exports. It will continue using the old moduleResolution: "node" configuration.
Options to resolve:
- Wait for vscode-languageserver to add ESM exports
- Try upgrading to vscode-languageserver v9.x to see if exports were added
- Use a bundler to work around the module resolution
- Fork or patch the dependency
Migration Status
| Package | Tests | ESM Status |
|---|---|---|
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
Required Configuration Changes
tsconfig.build.json (each migrated package)
Note: We use Option B (manual .js extensions in source files) rather than rewriteRelativeImportExtensions because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"skipLibCheck": true,
"lib": ["ES2022"],
"target": "ES2022"
}
}
The skipLibCheck: true is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
### jest.config.js (each migrated package)
```javascript
/** @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"],
};
Root package.json
{
"overrides": {
"typescript": "5.8.3"
}
}
Each workspace package.json
{
"devDependencies": {
"typescript": "^5.8.3",
"ts-jest": "^29.0.3"
}
}