✨ Introduction

When working on a Node.js project with TypeScript, you've probably found yourself writing import statements like this:

import { something } from "../../../utils/helpers";

As your project grows, you end up with deeper folder structures — and soon you're drowning in ../../../../ imports. These long relative paths are hard to read, easy to break when changing files, and frankly, not fun to work with.

That's where TypeScript path aliases come to the rescue.

Path aliases let you define clean import paths (like @/utils) that map to specific folders or files in your project. Instead of those messy relative paths, you can write:

import { something } from "@/utils/helpers";

This transformation brings immediate benefits:

  • 📖 Readability: Your imports become self-documenting and easier to scan
  • 🔧 Maintainability: Restructuring folders won't cascade into import hell
  • Developer Experience: IDEs can better track dependencies and auto-update imports

In this guide, I'll walk you through setting up path aliases in a Node.js + TypeScript project and make sure everything works fine including:

  • 🐢 Production builds with native node
  • 🧑‍💻 Local development with ts-node-dev or nodemon
  • 🧪 Testing with jest

You can follow along with the complete example code on GitHub.

Ready to clean up those imports? Let's dive in! 🚀

📖 Our Approach: Single Source of Truth

There are many ways to set up TypeScript path aliases in Node.js, but some options are more practical than others.

Many tutorials will have you duplicating the same path mappings across multiple config files:

  • Once in tsconfig.json for TypeScript compilation
  • Again in jest.config.js for testing
  • Yet again in your bundler config (Webpack, Rollup, etc.)
  • And sometimes even in development tools like ts-node-dev or nodemon

Having the same configuration in many places makes everything harder to maintain. Change a path? Now you need to remember to update it in 3-4 different places. Miss one? Your build breaks in mysterious ways.

We're taking a better approach.. In this guide, we'll configure tsconfig.json as our single source of truth and make every other tool read from it. This means:

  • One place to define paths - All aliases live in tsconfig.json
  • Automatic synchronization - Tools pull config from the same source
  • Zero duplication - Change once, works everywhere
  • Fewer bugs - No more "it works in dev but breaks in tests"

💡 Philosophy: Good tooling should work together, not against each other.

🛠️ Define Path Aliases

All path alias magic starts in your tsconfig.json file — this is where TypeScript learns how to resolve your custom import paths during compilation.

We need to configure two essential properties:

  • baseUrl: Sets the root directory for module resolution. We need it to establish the starting point for all path mappings.
  • paths: Maps alias patterns to actual folder locations. This is the core of our alias system.
TypeScripttsconfig.json
{ "compilerOptions": { "resolveJsonModule": true, // Required so we can import tsconfig.json in the next steps "baseUrl": ".", "paths": { "@/src/*": ["./src/*"], "@/tests/*": ["./tests/*"] } } }

In the paths configuration, you can add as many aliases as you need, and they can point to any folder structure in your project.

With this configuration, you can transform messy imports:

// ❌ Before: Relative path hell import { Server } from "../../../src/server"; import { validateUser } from "../../../../src/utils/validation"; // ✅ After: Clean, readable aliases import { Server } from "@/src/server"; import { validateUser } from "@/src/utils/validation";

💡 Tip: Although most examples use aliases that point to folders (like "@/src/*"), you can also alias individual files. This is especially useful when working with internal packages where you only want to expose a single entry point, like an index.ts file.

For example:

"paths": { "@internal/utils": ["./src/internal/utils/index.ts"] }

With this setup:

  • ✅ Consumers can import cleanly: import { something } from "@internal/utils";
  • 🚫 But they can’t import from deeper internals like: import { hiddenThing } from "@internal/utils/private/stuff"; (unless you explicitly create a new alias for it)

This helps enforce boundaries and keep your internal APIs clean.

Node.js Making Aliases Work with node

Here's where things get tricky. TypeScript knows about our aliases, but Node.js doesn't.

When you compile your TypeScript code, the aliases remain in the JavaScript output:

npm run build && node dist/main.js

Result: 💥 Runtime error

> [email protected] build > rm -rf dist && tsc -p tsconfig.prod.json node:internal/modules/cjs/loader:1252 throw err; ^ Error: Cannot find module '@/src/server'

This happens because Node has no knowledge of the TypeScript paths configuration. Once the code is compiled, it still contains import paths like @/src/server, which Node cannot resolve.

To fix this, we need a tool that rewrites the import paths in the emitted .js files after compilation. In our case, we are going to use tsc-alias.

Install it as a dev dependency

npm install --save-dev tsc-alias

Then, update your build script to run tsc-alias right after compiling with tsc:

Npmpackage.json
{ "scripts": { "build": "tsc --project tsconfig.json && tsc-alias -p tsconfig.json", } }

This way, tsc-alias will post-process the compiled files inside dist/, replacing any alias-based imports with the corresponding relative paths that Node can understand.

Now, if you run the build command again and start the server, it should work without any issues.

👨‍💻 Path Aliases for Local Development

We've solved the production build puzzle, but development has its own challenge. Development tools like ts-node-dev and nodemon run TypeScript files directly without compilation — and they don't understand our aliases either.

If we try running the development server now, we get an error:

> [email protected] dev > ts-node-dev --quiet src/main.ts Error: Cannot find module '@/src/server'

To fix this, we need something that can resolve at runtime the path aliases we have defined. In our case, we are going to use a library called tsconfig-paths which reads the tsconfig.json file and sets up the path aliases automatically when running TypeScript files.

Let's install it as a dev dependency:

npm install --save-dev tsconfig-paths

Then, update your package.json scripts to use ts-node-dev or nodemon with tsconfig-paths:

Npmpackage.json
{ "scripts": { "dev:ts-node-dev": "ts-node-dev -r tsconfig-paths/register src/main.ts" "dev:nodemon": "nodemon --exec ts-node -r tsconfig-paths/register src/main.ts" } }

Jest Making Path Aliases Work with jest

By default, Jest does not read the paths from tsconfig.json, but if we're using ts-jest, we can bridge this gap using the tools it provides.

There are two key options we need to set:

  • modulePaths: This option tells Jest where to start resolving non-relative imports
  • moduleNameMapper: This maps alias patterns (like @/src/*) to real paths on disk.

To generate the moduleNameMapper from your tsconfig.json, we can use the helper function pathsToModuleNameMapper provided by ts-jest. It converts the paths config into something Jest can understand.

Here’s how your Jest config should look after setting it up properly:

Jestjest.config.js
const { compilerOptions } = require('./tsconfig.json'); const { pathsToModuleNameMapper } = require('ts-jest'); module.exports = { preset: 'ts-jest', testEnvironment: 'node', modulePaths: [compilerOptions.baseUrl], moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/', }), };

🎯 Conclusion

In this post, we’ve explored what TypeScript path aliases are and how to configure them properly in a Node.js environment — centralizing everything in a single place (tsconfig.json) and making sure that tools like node, ts-node-dev, nodemon, and jest can all understand them.

I hope you found this post helpful and that it saves you time when structuring your own projects.

Thanks for reading! 🙌