Migrating from Babel to TSC in a Node.js TypeScript Project


Recently, I migrated a Node.js project from Babel to the native TypeScript compiler (tsc
). While Babel is incredibly flexible and fast, switching to tsc
helped reduce toolchain complexity and align more closely with TypeScript’s intended usage. However, this migration came with several unexpected challenges and learnings. Here’s a write-up of the issues I faced and how I resolved them.
🧩 Problem 1: Unresolved Path Aliases at Runtime
Symptoms:
Error: Cannot find module '~src/plugins/IndexabilityClientPlugin'
This happened even though my tsconfig.json
was configured with:
"baseUrl": "src",
"paths": {
"~src/*": ["./*"]
}
Why it happens: While tsc
understands the alias during compile time, Node.js doesn’t natively resolve them at runtime.
✅ Solution
Initially, I tried using module-alias, which worked but required extra steps:
Defining all aliases again in
package.json
under_moduleAliases
Adding the module-alias flag to the Dockerfiles, as it rewrites imports with a path alias during runtime.
Next, I discovered tsconfig-paths, which reads the path aliases directly from tsconfig.json
and works well with ts-node
or runtime environments.
However, the best fit for my project was ts-alias. It requires minimal configuration and integrates seamlessly with the tsc
build process by rewriting alias paths during compilation. This means no extra runtime aliasing or additional config is needed.
After installing ts-alias
, I updated my build command to:
"build": "tsc && tsc-alias"
For the development environment, I used tsconfig-paths/register
to ensure path aliases resolve correctly when running TypeScript files directly without compiling them first. For example, in the task:clean-cache-entries:dev
script, I have:
"task:clean-cache-entries:dev": "ts-node -r tsconfig-paths/register -r dotenv/config src/tasks/clean-cache-entries/index.ts"
This setup lets me use the aliases from tsconfig.json
during development, keeping the workflow fully TypeScript-native without the need to compile to JavaScript first.
For production, since ts-alias
rewrites the alias paths at build time, the equivalent script looks like this (note that we do not need any additional flags during runtime as it modfies the compiled js directly):
"task:clean-cache-entries": "node -r dotenv/config lib/tasks/clean-cache-entries/index.js"
🤝 Why ts-alias
over module-alias
No need to duplicate aliases in
package.json
Works directly with
tsc
No need to add a special
register
import in your runtimeSimpler and less error-prone
Especially suitable for TypeScript-first projects
Requires much less configuration than
module-alias
, making it feel more native in a TypeScript+Node.js setup
For a clean TypeScript setup with tsc
, ts-alias
was the most seamless choice.
tsc && tsc-alias🤝 Why ts-alias
over module-alias
No need to duplicate aliases in
package.json
Works directly with
tsc
No need to add a special
register
import in your runtimeEspecially suitable for TypeScript-first projects
For a clean TypeScript setup with tsc
, ts-alias
was the most seamless choice.
⚠️ Note:
You can usetsconfig-paths
alone in production to resolve aliases at runtime, but this is less efficient. It adds overhead by resolving paths every time a module loads. Using compile-time alias rewriting withts-alias
is better for performance since paths are resolved once during the build, resulting in faster runtime execution.
🛑 Problem 2: tsc
Fails Due to Missing express
Types in Dependencies
Symptoms:
error TS2307: Cannot find module 'express' or its corresponding type declarations.
This came from a transitive dependency from an internal library, which uses express
types but doesn’t list them as a peerDependency
or dependency
.
✅ Solution
Instead of adding express
and @types/express
as direct dependencies to our project, just to satisfy tsc
, I opted to set this in tsconfig.json
:
"skipLibCheck": true
This skips type-checking for declaration files (*.d.ts
), which is generally safe when third-party types are stable(I guess). I decided to keep this config temporarily for now because we have plans to remove the express dependency from the internal library that our project uses eventually.
🤔 Why Didn’t I Need This With Babel?
Babel was handling module resolution using plugins like module-resolver
, within .babelrc
. And those aliases were applied during transformation.
plugins: [
["module-resolver", {
"root": ["./src"],
"alias": { "~src": "./src" }
}]
]
I also created separate configuration files:
tsconfig.test.json
for testing setuptsconfig.eslint.json
for ESLint compatibility
These extra files helped keep the TypeScript and ESLint integration smooth.
But TypeScript by itself doesn’t rewrite paths unless you use tools like ts-alias
, which simulates what Babel was doing.
🔚 Conclusion
Migrating from Babel to tsc
simplified our stack, but required some extra care around module aliasing and dependency types. If you're working with a TypeScript Node.js project and want minimal setup, I strongly recommend using ts-alias
over module-alias
for its cleaner integration and simpler configuration.
Let me know if you’ve faced similar issues or found other tools that helped your migration smoother!
Subscribe to my newsletter
Read articles from Flora Dandelion directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Flora Dandelion
Flora Dandelion
I’m a developer who codes for a living and reflects in my spare time, trying to make sense of the journey. I share small wins and lessons in case they help someone else find their way too.