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.
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
> analyser@0.1.0 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.
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
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',
},
},
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?
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'],
},
}
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.