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

Flora DandelionFlora Dandelion
4 min read

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 runtime

  • Simpler 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 runtime

  • Especially suitable for TypeScript-first projects

For a clean TypeScript setup with tsc, ts-alias was the most seamless choice.

⚠️ Note:
You can use tsconfig-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 with ts-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 setup

  • tsconfig.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!


0
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.