Skip to content

Make Typescript projects compatible with esm/mjs requirements

License

Notifications You must be signed in to change notification settings

antongolub/tsc-esm-fix

Repository files navigation

tsc-esm-fix

Make TS projects compatible with esm/mjs requirements

CI Maintainability Test Coverage npm (tag)

Problem

This workaround is aimed to bypass a bunch of tsc, ts-jest and esbuild issues right here and right now.

moduleResolution: nodenext

Nightly build TypeScript 4.7 provides experimental esm support. But it still forces to add extensions by hand (tested on 4.7.0-dev.20220408).

src/main/ts/q/u/x/index.ts:1:21 - error TS2835: Relative import paths need explicit file extensions in EcmaScript imports when '--moduleResolution' is 'node12' or 'nodenext'. Did you mean '../../../foo.js'?

1 import { foo } from '../../../foo'

Moreover, if understand TS/49271 correctly, nodenext + pkg.json type: module requires .js extension to be added to all .d.ts files of external ESM packages too. Well, good luck with that.

Solutions

  1. Post-process tsc-compiled outputs each time after build.
  2. Patch project sources once as Sindre recommends in the ESM migration guide
  3. Use ttypescript with transformer-append-js-ext plugin

This lib covers options 1 and 2.

Features

  • Injects extensions to imports/re-exports statements.
    • import {foo} from './foo' → import {foo} from './foo.js'
    • import {baz} from 'external/baz' → import {baz} from 'external/baz.js'
    • Note, including the file extension is only necessary for packages without an "exports" field. So in this case all the external refs remain as are.
    • Pays attention to index files: import {bar} from './bar' → import {bar} from './bar/index.js'
    • Handles . and .. shortcuts
      • export * from '.' → export * from './index.js'
      • export * from '..' → export * from '../index.js'
    • Injects .js extensions into .d.ts libdef files
    • Does not affect string literals and comments: depseek
  • Handles conditional exports (https://nodejs.org/api/packages.html#conditional-exports)
  • Follows outDir found in tsconfig.json.
  • Searches and replaces __dirname and __filename refs with import.meta.
  • Fills blank files with export {} (esbuild issue 1043)
  • Patches source map files to point to the updated files.
  • Patches require statements with new file refs if ext changes (hybrid/dual pkg)
  • Changes file extensions (applied to local deps only).
  • Supports Windows-based runtimes.

Getting started

Requirements

Node.js >=16.0.0

Install

npm i -dev tsc-esm-fix
yarn add -D tsc-esm-fix

# or w/o saving to package.json
npx tsc-esm-fix [options]

Usage examples

tsc-esm-fix [options]

# to post-process outputs each time
tsc-esm-fix --target='target/es6'

# to patch ts sources once
tsc-esm-fix --src='src/main/ts' --ext='.js'
import { fix } from 'tsc-esm-fix'
await fix({
  dirnameVar: true,
  filenameVar: true,
  ext: true
})

Input
code ref

import { foo } from './foo';
import './bar';

// external cjs module
import * as e1def from 'e1/a/b/c';
import * as e1root from 'e1';
const { e1 } = e1def;
const { e1: e1x } = e1root;
export { e1, e1x };

// external esm module with `main` in pkg.json
export { m1 } from 'm1';
export { m1 as m1x } from 'm1/index';

// external esm module with `exports` in pkg.json
export { e2 } from 'e2';
export { e2 as es3 } from 'e2/index';
export { e2 as es4 } from 'e2/alias';
export { e2foo } from 'e2/foo';
export { e2bar } from 'e2/bar-bundle';

export * from './foo';
export * from './baz';
export * from './q/u/x';
export const foobaz = foo + 'baz';
export { foo as foo1 } from './foo.js';

// Dir with index.js file inside: ./qux.js/index.js
export { qux } from './qux.js';

export const dirname = __dirname;
export const filename = __filename;

console.log(foobaz);

Output

import { foo } from './foo.js';
import './bar.js';

import * as e1def from 'e1/a/b/c/index.js';
import * as e1root from 'e1';
const { e1 } = e1def;
const { e1: e1x } = e1root;
export { e1, e1x };

export { m1 } from 'm1';
export { m1 as m1x } from 'm1/index.js';

export { e2 } from 'e2';
export { e2 as es3 } from 'e2/index';
export { e2 as es4 } from 'e2/alias';
export { e2foo } from 'e2/foo';
export { e2bar } from 'e2/bar-bundle';

export * from './foo.js';
export * from './baz/index.js';
export * from './q/u/x/index.js';
export const foobaz = foo + 'baz';
export { foo as foo1 } from './foo.js';

export { qux } from './qux.js/index.js';

export const dirname = /file:\\\\/\\\\/(.+)\\\\/[^/]/.exec(import.meta.url)[1];
export const filename = /file:\\\\/\\\\/(.+)/.exec(import.meta.url)[1];

CLI

tsc-esm-fix [opts]
Option Description Default
--tsconfig Path to project's ts-config(s) tsconfig.json
--src Entry points where the ts-source files are placed. If defined src option suppresses target
--target tsc-compiled output directory If not specified inherited from tsconfig.json compilerOptions.outDir
--dirnameVar Replace __dirname usages with import.meta true
--filenameVar Replace __filename var references with import.meta statements true
--ext Append extension to relative imports/re-exports .js
--ts-ext Known TS extensions .ts,.tsx,.mts,.mtsx,.cts,.ctsx
--js-ext Known JS extensions .js,.jsx,.mjs,.mjsx,.cjs,.cjsx
--unlink Remove original files if ext changes true
--fillBlank Fill blank files with export {} false
--forceDefaultExport Injects export default undefined if not present false
--sourceMap Patch source map files to point to the updated files. false
--cwd cwd process.cwd()
--out Output dir. Defaults to cwd, so files would be overwritten process.cwd()
--debug Prints debug notes

--target vs --src

When --src option is used, the util just modifies file contents in place. --target also renames files to change their extension. You may prevent deletion original of files by using --no-unlink.

glob patterns

By default, the util looks for ts/tsx files in src directory and js/d.ts files in target. But you can specify custom patterns via corresponding options. For example: --src='src/main/ts/**/*.ts'.

const patterns =
  sources.length > 0
    ? sources.map((src) => src.includes('*') ? src : `${src}/**/*.{ts,tsx}`)
    : targets.map((target) => target.includes('*') ? target : `${target}/**/*.{js,d.ts}`)

JS/TS API

import { fix, IFixOptions } from 'tsc-esm-fix'

const fixOptions: IFixOptions = {
  tsconfig: 'tsconfig.build.json',
  dirnameVar: true,
  filenameVar: true,
  ext: true
}

await fix(fixOptions)
export interface IFixOptions {
  cwd: string
  src?: string | string[]
  target?: string | string[]
  out?: string
  tsconfig?: string | string[]
  dirnameVar: boolean
  filenameVar: boolean
  fillBlank?: boolean
  forceDefaultExport?: boolean
  sourceMap?: boolean
  ext: boolean | string
  tsExt: string | string[]
  jsExt: string | string[]
  unlink?: boolean,
  debug?: boolean | IFunction
}

Alternatives

Contributing

Feel free to open any issues: bug reports, feature requests or questions. You're always welcome to suggest a PR. Just fork this repo, write some code, add some tests and push your changes. Any feedback is appreciated.

References

License

MIT