Skip to content

Uncovered cases explored from a project migration聽#123

Closed
3 of 3 issues completed
Closed

Description

@timofei-iatsenko

Hey there! First off, thanks for this awesome tool鈥攊t's been a huge time-saver! 馃檶

I recently migrated a TypeScript project to ESM and used ts2esm to correctly add .js extensions in imports. While it worked well overall, I ran into a few challenges that I think could improve the tool even further.

1锔忊儯 Partial Support for tsconfig Paths

I saw that tsconfig path support works to some extent, but I had issues in an Nx monorepo with /apps/* and /libs/*. These are linked using tsconfig paths, and ts2esm only resolved the imports correctly after I moved libs inside an apps folder. My workaround looked like this:

  1. Move libs/* into apps/myapp/libs/*
  2. Update tsconfig.json paths accordingly
  3. Run ts2esm
  4. Move libs back to their original location
  5. Repeat for each app

If ts2esm could resolve paths of aliases outside the current project dir, that would be amazing!

2锔忊儯 Handling Package Exports and Subpath Imports

My project had a mix of modern packages (with package.json exports) and older ones using subpath imports. It would be great if ts2esm could leverage TypeScript's built-in resolver to automatically handle these cases.

Example 1: Legacy Package (lodash)

lodash doesn't have an exports field, so it requires .js in the import path:

// Before (CommonJS)
import omit from 'lodash/omit';

// After (ESM)
import omit from 'lodash/omit.js';

Currently, ts2esm doesn鈥檛 handle this, so I had to fix it manually.

Example 2: Modern Package (firebase-functions)

firebase-functions has an exports field, so no changes are needed:

// This works in both CJS and ESM
import { HttpsError } from 'firebase-functions/v1/https';

However, in cases where an import incorrectly targets a private subpath, it breaks in ESM:

// This works in CJS but fails in ESM
import type { ObjectMetadata } from 'firebase-functions/lib/v1/providers/storage';

Since TypeScript correctly flags these issues, maybe ts2esm could warn or ignore them?

3锔忊儯 Dynamic Imports Are Not Converted

I also noticed that dynamic imports aren鈥檛 updated with .js extensions:

const module = await import('./my-function.function'); 
// Expected:
const module = await import('./my-function.function.js'); 

Handling this would make ts2esm even more powerful!

Would love to hear your thoughts! Let me know if I can help test anything. 馃槉

Activity

  1. bennycode commented on Feb 3, 2025

    @bennycode
    Owner

    Hey @timofei-iatsenko, thanks a lot for putting in the effort to write such a detailed report! 馃檹 Seeing others engage so deeply with projects I maintain gives me a real boost of motivation to keep working on them. 馃挭

    Let's go through the topics...

    1锔忊儯 Partial Support for tsconfig Paths

    Is this the structure of your Nx Monorepo?

    • apps/myapp/tsconfig.json
    • apps/myapp/apps/*
    • apps/myapp/libs/*

    Do you have a single main tsconfig.json, or are there multiple? I ask because I use sourceFile.getProject() from ts-morph](https://github.com/dsherret/ts-morph) to locate TS configs. @fgarcia mentioned that sourceFile should be a .d.ts file to properly find references from co-located packages: dsherret/ts-morph#1242 (comment)

    2锔忊儯 Handling Package Exports and Subpath Imports

    Right now, ts2esm simply checks if a JS file is inside the node_modules directory of the referenced package. It doesn鈥檛 parse the package.json file of installed modules at all. It sounds like supporting this would require reading the exports field from package.json. Might take some effort to implement.

    Relevant docs: https://devblogs.microsoft.com/typescript/announcing-typescript-4-5-beta/#package.json-exports-imports-and-self-referencing

    Currently, my isNodeModuleRoot can already locate the relevant package.json file. That would be a good entrypoint to build the parsing functionality. 馃挕

    3锔忊儯 Dynamic Imports Are Not Converted

    Great catch! I tested the following code in the TypeScript AST Viewer:

    const module = await import('./my-function.function'); 

    It shows that this call expression is of type SyntaxKind.ImportKeyword, which isn't currently handled in convertFile.

    This snippet could help detect it:

    sourceFile.getVariableStatements().forEach(statement => {
      const declaration = statement.getDeclarations()[0];
      const initializer = declaration?.getInitializerIfKind(SyntaxKind.CallExpression);
      const kind = initializer?.getExpression().getKind();
      if (kind === SyntaxKind.ImportKeyword) {
        // ...
      }
    });

    It should be relatively easy to implement. I'll see when I can get to it. 馃

    Thanks again for your report!

    Best from Berlin,
    Benny

  2. timofei-iatsenko commented on Feb 3, 2025

    @timofei-iatsenko
    Author

    Is this the structure of your Nx Monorepo?

    • apps/myapp/tsconfig.json
    • apps/myapp/apps/*
    • apps/myapp/libs/*
    • /tsconfig.json - root tsconfig, has path mappings such as @mylib - > /libs/mylib/src/index.ts
    • /libs/*
    • /libs/mylib/tsconfig.json lib tsconfig extends root one and has an include field for src/*
    • /libs/mylib/src/*
    • /apps/myapp/tsconfig.json app tsconfig extends root one and has an include field for src/*
    • /apps/myapp/src/*

    I started the script in each project in monorepo one by one eq: cd ./apps/myapp && npx ts2esm

    Right now, ts2esm simply checks if a JS file is inside the node_modules directory of the referenced package. It doesn鈥檛 parse the package.json file of installed modules at all. It sounds like supporting this would require reading the exports field from package.json. Might take some effort to implement.

    I'm thinking might be utilizing real typescript resolver would help here?

    So the logic might be like that:

    • take a current bare id, say firebase-functions/v1/https, try to resolve it with typescript's ts.resolveModuleName (see example here https://github.com/nrwl/nx/blob/master/packages/jest/plugins/resolver.ts#L69-L74)
    • if it resolved with moduleResolution: NodeNext + type: module, means this external import is ok. left it as is
    • If it's not resolved, add .js and try again. If finally you get result, replace import in source file.
  3. bennycode commented on Feb 24, 2025

    @bennycode
    Owner

    Thanks for your insights @timofei-iatsenko!

    I implemented support for dynamic imports (#130) and I created myself a test case for "2锔忊儯 Handling Package Exports and Subpath Imports":

    import omit from 'lodash/omit';
    import {HttpsError} from 'firebase-functions/v1/https';
    
    const object = {a: 1, b: '2', c: 3};
    omit(object, ['a', 'c']);
    
    export function logError() {
      console.log(HttpsError);
    }

    Using the current version of ts2esm it gets converted to this:

    import omit from 'lodash/omit.js';
    import {HttpsError} from 'firebase-functions/v1/https';
    
    const object = {a: 1, b: '2', c: 3};
    omit(object, ['a', 'c']);
    
    export function logError() {
      console.log(HttpsError);
    }
    • A .js extension is assigned to a "Legacy Package"
    • Imports of a "Modern Package" are untouched

    Isn't that exactly what you wanted?

    I also tried to use the built-in TypeScript resolver using this utility method:

    import { SourceFile, ts } from 'ts-morph';
    
    export const ProjectUtil = {
      getModule: (moduleName: string, sourceFile: SourceFile) => {
        const compilerOptions = sourceFile.getProject().getCompilerOptions();
        const host = ts.createCompilerHost(compilerOptions, true);
        return ts.resolveModuleName(
          moduleName,
          sourceFile.getFilePath(),
          sourceFile.getProject().getCompilerOptions(),
          host
        );
      },
    };

    However, it returns a resolution to the .d.ts file when asking it for 'lodash/omit':

    {
      resolvedModule: {
        resolvedFileName: '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/@types/lodash/omit.d.ts',
        originalPath: undefined,
        extension: '.d.ts',
        isExternalLibraryImport: true,
        packageId: {
          name: '@types/lodash',
          subModuleName: 'omit.d.ts',
          version: '4.17.15',
          peerDependencies: undefined
        },
        resolvedUsingTsExtension: false
      },
      failedLookupLocations: [
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit/index.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit/index.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/lodash/omit/index.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/@types/lodash/omit/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/@types/lodash/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/@types/lodash/omit.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/src/node_modules/@types/lodash/omit/index.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.js.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.js.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.js.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.js/index.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.js/index.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/lodash.js/index.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/index.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/index.tsx',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/omit/index.d.ts',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/@types/lodash/omit/package.json'
      ],
      affectingLocations: [
        '/Users/bennycode/dev/bennycode/ts-node-starter/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/lodash/package.json',
        '/Users/bennycode/dev/bennycode/ts-node-starter/node_modules/@types/lodash/package.json'
      ],
      resolutionDiagnostics: undefined,
      alternateResult: undefined
    }
    

    I am not yet sure how I can use this information to decide if I should add a .js extension to 'lodash/omit' or not. 馃

  4. timofei-iatsenko commented on Feb 24, 2025

    @timofei-iatsenko
    Author

    Isn't that exactly what you wanted?

    Looks awesome!

    I also tried to use the built-in TypeScript resolver using this utility method:

    That's a bummer, hoped it will free you from implementing this logic yourself.

  5. albjeremias commented on May 7, 2025

    @albjeremias

    after I ran ts2esm on my project i got this error:

    Error: Cannot find package '@src/app' imported from api/dist/main.js
        at Object.getPackageJSONURL (node:internal/modules/package_json_reader:267:9)
        at packageResolve (node:internal/modules/esm/resolve:768:81)
        at moduleResolve (node:internal/modules/esm/resolve:854:18)
        at defaultResolve (node:internal/modules/esm/resolve:984:11)
        at ModuleLoader.defaultResolve (node:internal/modules/esm/loader:736:12)
        at ModuleLoader.#cachedDefaultResolve (node:internal/modules/esm/loader:660:25)
        at ModuleLoader.resolve (node:internal/modules/esm/loader:643:38)
        at ModuleLoader.getModuleJobForImport (node:internal/modules/esm/loader:279:38)
        at ModuleJob._link (node:internal/modules/esm/module_job:137:49)
    

    I have no idea what is wrong cause im not an esm expert.

    here is the branch i pushed the ts2esm changed code

    any support is very welcome

  6. bennycode commented on Oct 1, 2026

    @bennycode
    Owner

    All three points from this report are fixed now:

    1. tsconfig paths in monorepos: Support for tsconfig paths in monorepos聽#127, fixed in fix: Resolve tsconfig path aliases like TypeScript does in monorepos聽#213. Paths declared in a root tsconfig that an app's tsconfig extends are now resolved from the right directory.
    2. Package exports and subpath imports: Handling exports in package.json from 3rd party packages聽#128, fixed in fix: Respect package.json exports when adding extensions to package imports聽#212. Package imports are now checked with TypeScript's resolver in ESM mode, so packages with an exports field stay untouched and legacy packages like lodash/omit get .js.
    3. Dynamic imports: fixed in feat: Convert dynamic imports聽#130.

    @albjeremias the Cannot find package '@src/app' error happens because tsc never rewrites path aliases in the emitted JavaScript. paths in tsconfig only tells the type checker where to look, so Node.js sees @src/app at runtime and treats it as a package name. ts2esm can't change that. Two common ways to fix it:

    • Run tsc-alias after tsc to rewrite the aliases into relative paths in your build output.
    • Replace the alias with subpath imports in your package.json, e.g. "imports": { "#src/*": "./dist/*" }, and import from #src/app.js. Node.js and TypeScript both understand these natively.

    Closing this, since the individual issues are resolved. Feel free to open a new issue if something else comes up during a migration.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions