Right, let’s talk about one of the first things that will make you want to throw your laptop out a window when you move from writing simple scripts to a real TypeScript application: the dreaded ../../../ import. You know the one. It’s like a little map of your file structure slowly driving you insane. We’re going to fix that with path aliases, and in the process, we’ll demystify how Node.js even finds your code. This isn’t just about convenience; it’s about sanity.

The core thing you need to understand is that TypeScript and Node.js are two separate entities with a sometimes-rocky relationship. TypeScript is your brilliant, pedantic friend who plans everything in advance. Node.js is the pragmatic, slightly older engine that actually runs the show. Your job is to get them to agree on where things are.

The baseUrl and paths Combo

This is where the magic starts, in your tsconfig.json. The baseUrl tells the TypeScript compiler (tsc) to resolve non-absolute module names starting from a specific directory, not the current file. This alone kills the ./ and ../ for your src or lib folder.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./src", // Now you can import from "utils/math" instead of "../utils/math"
    "paths": {
      "@/*": ["*"], // A common pattern: map `@/utils/math` to `src/utils/math`
      "@utils/*": ["utils/*"] // Or be more specific
    }
  }
}

Why is this brilliant? Because your imports become clean, stable, and refactor-proof.

// From this brain-melting mess:
import { calculateThing } from '../../../../utils/helpers';

// To this glorious clarity:
import { calculateThing } from '@/utils/helpers';
// or
import { calculateThing } from '@utils/helpers';

TypeScript now understands this perfectly. Your editor’s “Go to Definition” will work. The compiler is happy. You are happy. But then you run node dist/index.js and it all explodes. Why? Because we’ve only convinced the planner (TypeScript), not the executioner (Node.js). Node.js has no idea what @/utils/helpers means. It’s staring at you like a confused puppy.

The Module Resolution Mismatch

Here’s the crux of the issue: tsc does not rewrite your import paths. When it compiles your src/utils/helpers.ts to dist/utils/helpers.js, it leaves the statement import { calculateThing } from '@/utils/helpers'; exactly as is. Node.js sees that, doesn’t have a clue about our tsconfig.json rules, and throws a MODULE_NOT_FOUND error. It’s the most common “it works in VS Code but not when I run it” problem with path aliases.

So we have two solutions: tell Node.js how to resolve these paths at runtime, or have our bundler/compiler rewrite them to relative paths ahead of time.

Solution 1: The tsconfig-paths Runtime Band-Aid

The quickest way to unblock yourself is using the tsconfig-paths package. It’s a runtime module that reads your tsconfig.json and teaches Node.js how to resolve your paths.

  1. Install it: npm install --save-dev tsconfig-paths
  2. Register it. The most robust way is to use node -r (the --require flag):
    node -r tsconfig-paths/register dist/index.js
    

You can also require it programmatically at the very top of your entry file, but I hate that because it feels like a hack that pollutes my application code.

This works, and it’s fine for development. But it’s a runtime solution, which feels a bit… sloppy. It adds overhead to your application startup. For production, you probably want something more solid.

Solution 2: The Correct Way: Using a Bundler

This is the “grown-up” solution. Use a tool that actually transforms the import statements during build time. My tool of choice for this is ttypescript (a drop-in replacement for tsc) with the tsc-alias plugin.

  1. Install the necessary packages:
    npm install --save-dev ttypescript tsc-alias
    
  2. Change your tsconfig.json to use ttypescript and the plugin:
    // tsconfig.json
    {
      "compilerOptions": {
        "baseUrl": "./src",
        "paths": {
          "@/*": ["*"]
        },
        // ... other options
      },
      "plugins": [
        { "transform": "tsc-alias" } // Add this plugin
      ]
    }
    
  3. Update your build script in package.json:
    {
      "scripts": {
        "build": "ttsc && tsc-alias"
      }
    }
    

Now, when you run npm run build, ttypescript compiles your code, and then tsc-alias goes through the generated .js and .d.ts files and rewrites all the alias paths (@/utils/helpers) to the correct relative paths (./utils/helpers). The output is a dist folder that Node.js can run natively, with no runtime hacks required. This is clean, fast, and production-ready.

The exports Field in package.json

While we’re talking about modern module resolution, you should know about Node’s exports field. This is a fantastic feature that lets you define your public API and… you guessed it, create aliases within your own package.

// In your package.json
{
  "name": "my-cool-app",
  "exports": {
    "./utils": "./dist/utils/index.js",
    "./models": "./dist/models/index.js"
  }
}

Now, even from outside your package, someone could do import { something } from 'my-cool-app/utils'; and it would Just Work™. It’s incredibly powerful for controlling your public interface. For internal aliases, it’s a bit more heavy-handed than the methods above, but it’s a tool worth knowing exists, especially if you’re building libraries.