JavaScript, TypeScript, Module Resolution and Module Alias

Recently, I've encountered an interesting issue with a project using TypeScript. In this article, I will describe the problem, the investigation I did and how I locate the root cause, and suggest some solutions.

What happened

We have a project that's a "pure" TypeScript project -- the codebase contains no JavaScript file, except for some configuration. As part of the build process, TypeScript is transpiled into JavaScript with tsc. Within the container, the project call node and runs the transpiled index.js, something like node dist/src/index.js.

When a PR was merged and deployed into one of the test environments, the container would not start and went into a crash loop state. This obviously is not a good sign. The initial step was checking the logs emitted from within the container. This revealed the cause of the crash was an imported module (imported with non-relative path) can not be found in one of the JavaScript file.

Investigation & Reproduce

The obvious first step for investigation, was to run the project with branch that caused the issue. Not surprisingly, the project ran just fine when running locally, and it even passed all tests. This, to me indicated the local setup did not match what's in the container.

Next, I checked the Dockerfile for the project and there was indeed some discrepancy between how the project runs in container vs how it runs using NPM. Locally, the project runs with ts-node which did not contain the transpile process. The container, was running using the transpiled JavaScript.

So now, when I ran the tsc locally and then ran the transpiled index.js file that's generated, I was able to reproduce the issue locally. I can see the module in question was imported using non-relative import.

Once I was able to reproduce the issue, I had several questions in mind that need to be answered:

  1. Why was there a module not found error when it was running fine locally (both tests, and actual server runs fine)?
  2. Why wasn't TypeScript compiler reporting the error in Visual Studio Code?
  3. How can this sort of issue be prevented?

Root cause

I'm not a fan of TypeScript, and the following analysis contains my personal opinions.

My conclusion was the answer to all of questions boils down to how this project was setup, more specifically how it uses TypeScript and the tsconfig file.

Modern day JavaScript has the concept of modules. A module can be imported in two ways, relative and non-relative. A relative imports, involves finding the path using relative path, that is, when you see something like import helper from '../../helper' is a relative import. Whereas, import helper from 'tests/helpers' is an example of non-relative import. At runtime (or compile time for TypeScript), modules import one another using module loader ^1. The process of how modules decide where to find the module to import from is called module resolution.

In most circumstances, you would prefer relative imports, which works fine if you don't have deeply nested folder structures. Non-relative imports are mostly for importing modules from third party NPM packages. However, once the projects gets larger, you would see non-relative imports even for modules that are not from third party which makes importing easier to understand and follow.

TypeScript mimics NodeJS module resolution algorithm. It also employs some flags to INFORM the compiler of what's expected to happen to module resolution at runtime. This is because, a project may have a different structure at runtime vs at build time. As an example, most front-end projects will be bundled to multiple JavaScript files, this is not representative of the project structure before it's built. One important thing to note here is that TypeScript compiler itself WILL NOT do any transformation on its own.

In tsconfig, baseUrl and path configuration are examples of the aforementioned flags.

  • baseUrl: Setting baseUrl informs the compiler where to find modules. All module imports with non-relative names are assumed to be relative to the baseUrl. ^2
  • path: this is a mapping between file and module, and should be used when modules sits outside of the baseUrl. ^3

The tsconfig file for this project in question contains the following line baseUrl: ./src. This has several implications:

  • When writing code, someone could import other modules within src folder using non-relative names, this would NOT cause any issue.
  • At build time, TypeScript would emit JavaScript file with non-relative imports.
  • At runtime, depending on how the project is setup, there could be different consequences:
    • If the final runtime code was bundled together using a bundler, chances are the project would run fine.
    • If the final runtime code was not bundled together, and nothing was done at project level to support the non-relative import, the project would fail at runtime, and emit a module not found error.

For the project I mentioned, a NPM package called module-alias was used. However, not all aliases are registered properly. This was what led to the module not found error at runtime.

Solutions

I think the key is to have consistency between the project setup and TypeScript configuration (tsconfig), that is, if the project is not using a bundler, the baseUrl should be used together with paths mapping. And anytime a new non-relative import is added in code, one should also check whether the alias has been setup correctly.

There are many ways to setup alias for non-relative path import:

  • Using the module-alias NPM package. Note that this package is not a development dependency, you will have to include it in your actual dependency.
  • Using the subpath imports method provided by NodeJS (this is only applicable in NodeJS project) which setup alias in package.json

References

https://www.typescriptlang.org/docs/handbook/modules.html
https://www.typescriptlang.org/docs/handbook/module-resolution.html