Search code examples
typescriptwebpackjasminets-loader

Importing ES modules for TypeScript, Webpack, and Jasmine


Background

I have a set of packages that I reuse throughout my personal projects using a similar structure.

For production purposes, they are written in TypeScript, and compiled into JavaScript via tsc before they are published to npm so I can install them in my other projects. I also run tests on them using Jasmine.

In development, I use Webpack with its ts-loader to compile the TypeScript in a way that lets my use it in example scripts running in a browser. I also typically use Webpack in my projects where I consume these packages, though as they were compiled before being published those projects will see them as JavaScript only and won't need to use ts-loader.

I use ES module syntax everywhere so I can comfortably use the same syntax in each context instead of worrying about switching between CommonJS for Node and ES module syntax for browsers. To allow this in Jasmine, in my jasmine.json configuration file I've set "jsLoader": "import".

I am having an issue around how to write my import statements. In particular, how to specify file extensions in a way that will work for each use case that comes up when I'm developing these packages.


No file extension

TypeScript's Node module resolution makes some useful assumptions around file extensions:

TypeScript will mimic the Node.js run-time resolution strategy in order to locate definition files for modules at compile-time. To accomplish this, TypeScript overlays the TypeScript source file extensions (.ts, .tsx, and .d.ts) over Node’s resolution logic.

This means if I use code such as import { foo } from './foo'; then TypeScript will understand to look for a file called foo.ts.

Similarly, Webpack's resolve configuration object allows a set of file extensions to be specified in the extensions property to tell Webpack how to try to resolve an import with no file extension. So with my configuration of extensions: ['.js', '.ts'] it will find the same foo.ts file as TypeScript.

However, when I attempt to run my tests in Jasmine using import without a file extension, it's unable to resolve.

C:\Users\mark.hanna\Documents\Code Playground\analyser>npm test

> [email protected] test C:\Users\mark.hanna\Documents\Code Playground\analyser
> tsc && jasmine

Error: While loading C:/Users/mark.hanna/Documents/Code Playground/analyser/spec/analyser.spec.js: NodeError: Cannot find module 'C:\Users\mark.hanna\Documents\Code Playground\analyser\dist\file-processing' imported from C:\Users\mark.hanna\Documents\Code Playground\analyser\dist\analyser.js
    at C:\Users\mark.hanna\Documents\Code Playground\analyser\node_modules\jasmine\lib\loader.js:22:30
    at async Jasmine._loadFiles (C:\Users\mark.hanna\Documents\Code Playground\analyser\node_modules\jasmine\lib\jasmine.js:182:5)
    at async Jasmine.loadSpecs (C:\Users\mark.hanna\Documents\Code Playground\analyser\node_modules\jasmine\lib\jasmine.js:173:3)
    at async Jasmine.execute (C:\Users\mark.hanna\Documents\Code Playground\analyser\node_modules\jasmine\lib\jasmine.js:479:3)
npm ERR! Test failed.  See above for more details.

.js extension

The other option I have is to specify the .js file extension in my imports, e.g. import { foo } from './foo.js.

Though I haven't found it clearly documented, TypeScript's module resolution also clearly understands that ./foo.js may actually mean ./foo.ts. I've found references to this having been supported since TypeScript 2

This means I can specify the .js file extension and still correctly compile my code using tsc. I'm also then able to correctly run my Jasmine tests, as it understands how to do module resolution when the file extension is included.

However, Webpack does not understand that ./foo.js may actually mean ./foo.ts. So with this approach I can't build my code within the package when importing it into scripts to show examples in the browser.

ERROR in ./src/file-processing.ts 2:0-49
Module not found: Error: Can't resolve './AnalyserRows.js' in 'C:\Users\mark.hanna\Documents\Code Playground\analyser\src'
resolve './AnalyserRows.js' in 'C:\Users\mark.hanna\Documents\Code Playground\analyser\src'
  using description file: C:\Users\mark.hanna\Documents\Code Playground\analyser\package.json (relative path: ./src)
    Field 'browser' doesn't contain a valid alias configuration
    using description file: C:\Users\mark.hanna\Documents\Code Playground\analyser\package.json (relative path: ./src/AnalyserRows.js)
      no extension
        Field 'browser' doesn't contain a valid alias configuration
        C:\Users\mark.hanna\Documents\Code Playground\analyser\src\AnalyserRows.js doesn't exist
      .js
        Field 'browser' doesn't contain a valid alias configuration
        C:\Users\mark.hanna\Documents\Code Playground\analyser\src\AnalyserRows.js.js doesn't exist
      .ts
        Field 'browser' doesn't contain a valid alias configuration
        C:\Users\mark.hanna\Documents\Code Playground\analyser\src\AnalyserRows.js.ts doesn't exist
      as directory
        C:\Users\mark.hanna\Documents\Code Playground\analyser\src\AnalyserRows.js doesn't exist
 @ ./src/analyser.ts 4:0-48 69:0-50
 @ ./docs/assets/js/src/docs-script.ts 2:0-53 7:14-36 34:14-36 43:14-36 48:51-68 49:28-45

.js extension with Webpack aliasess

The only solution I've found so far is one I really don't like, which is to use Webpack's resolve.alias configuration to explicitly tell it that when I say ./foo.js I mean ./foo.ts.

For example, I've currently used this approach in my @cipscis/csv project:

    resolve: {
        extensions: ['.js', '.ts'],
        alias: {
            '@cipscis/csv': `${srcPath}/csv.ts`,
            './stringify.js': './stringify.ts',
            './parse.js': './parse.ts',
        },
    },

webpack.config.js

This feels very clunky though, and I have to specify an alias like that for every single file I import so it doesn't feel very scalable at all. It feels like surely there must be a "right" way of doing this?


Solution

  • Answer as of 2022-10-20

    Webpack v5.74.0 was released in July 2022, and it includes a new resolve.extensionAlias option which allows this problem to be solved without requiring a plugin.

    For example:

    resolve: {
        extensionAlias: {
            '.js': ['.ts', '.js'],
        },
    }
    

    Answer as of 2021-09-07

    Thanks to amosq's reply to my answer to a related question, I now have an answer to this question.

    Jasmine was a bit of a red herring here, the real relevance there was only the ES Module resolution it was trying to do with my setup. The core of the issue was the Webpack's module resolution is unable to reproduce the functionality of TypeScript's module resolution rules, where it treats *.js paths as potentially pointing to *.ts files.

    Thanks to amosq's comment, I've been able to resolve this by using a Webpack resolver plugin: resolve-typescript-plugin

    Its instructions only show how to use it in a CommonJS syntax, though as I mentioned in my question I'm using ES Module syntax everywhere I can. To use it in my Webpack config I've done this:

    import resolveTypeScriptPluginModule from 'resolve-typescript-plugin';
    const ResolveTypeScriptPlugin = resolveTypeScriptPluginModule.default;
    
    const config = {
        // ...
        resolve: {
            fullySpecified: true,
            plugins: [new ResolveTypeScriptPlugin()],
            // ...
        },
        // ...
    };
    

    This setup has allowed me to use Webpack with ts-loader to compile TypeScript files that use *.js in their import statements, as part of the same setup where I'm also using tsc to compile those files. I no longer need to use the Webpack aliases I mentioned at the end of my question.