Right, let’s talk about getting your beautifully crafted TypeScript out of its cozy development directory and into a structure that something else, like Node.js or a browser, can actually run. This is where outDir and rootDir come in, and if you’re publishing a library, declarationDir joins the party. These settings feel simple until they aren’t, and then you’re staring at a cryptic error about being “not under ‘rootDir’” and questioning all your life choices. I’ve been there. Let’s fix that.

The Basic Relationship: rootDir and outDir

Think of rootDir as the “source truth” and outDir as the “output truth.” The TypeScript compiler is essentially going to take your entire source file structure under rootDir, compile it, and mirror that exact same structure starting from outDir.

By default, if you don’t set rootDir, TypeScript does something… interesting. It calculates the longest common path among all your input files. This is a clever way to avoid having to set the option, but it’s a recipe for inconsistency. If you add a new file outside that calculated path, your entire output structure can suddenly shift. It’s chaos. You are not a chaotic person. Be explicit.

Here’s the golden rule: rootDir should be the common ancestor directory of all your non-declaration source files. Let’s set up a sane project.

// tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "target": "ES2020",
    "module": "commonjs",
    // ... other options
  }
}

With this structure:

my-project/
├── src/
│   ├── utils/
│   │   └── math.ts
│   └── index.ts
├── dist/          // Generated by tsc
│   ├── utils/
│   │   └── math.js
│   └── index.js
└── tsconfig.json

When you run tsc, it takes everything in ./src, compiles it, and places the resulting .js files in ./dist, perfectly preserving the directory hierarchy. This is what you want 99% of the time.

The Infamous “not under ‘rootDir’” Error

You will meet this error. It’s a rite of passage. It happens when the compiler finds a .ts file that is not under the path you specified (or it calculated) for rootDir.

A classic culprit? Your test.ts files living in a /test directory at the same level as /src.

my-project/
├── src/
│   └── index.ts
├── test/          // <-- This is a problem!
│   └── index.test.ts
└── tsconfig.json

If your rootDir is ./src, the compiler sees ./test/index.test.ts and panics. “This file is outside the kingdom I was told to rule!” The solution is simple: either move your tests into src/ (a perfectly valid choice) or, more commonly, exclude them from compilation entirely using the "exclude" option in your tsconfig.json. The compiler doesn’t try to compile excluded files, so it doesn’t care that they’re outside rootDir.

// tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    // ...
  },
  "exclude": ["test", "**/*.test.ts", "**/*.mock.ts"] // Tell the compiler to ignore these
}

Generating Declaration Files (.d.ts)

If you’re writing a library, you need to ship declaration files so your users get type hints. You enable this with "declaration": true. But where do those .d.ts files go? By default, they’re emitted right next to their corresponding .js file in the outDir. This is often fine.

But sometimes you want to separate your emitted JavaScript from your emitted declarations. This is where declarationDir comes in. It lets you send all your .d.ts files to a single, separate directory. This can be useful for publishing strategies where you want a cleaner output or need to package types separately.

// tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src",
    "declaration": true,
    "declarationDir": "./types", // Send .d.ts files here
    // ...
  }
}

Now your output looks like this:

my-project/
├── dist/          // Just JavaScript
│   ├── utils/
│   │   └── math.js
│   └── index.js
├── types/         // Just Declaration files
│   ├── utils/
│   │   └── math.d.ts
│   └── index.d.ts
└── tsconfig.json

A word of caution: if you use declarationDir, your outDir and declarationDir are completely independent. The compiler won’t mirror your src structure between them; it mirrors the src structure within each of them. The key is that both output directories are built from the same source truth (rootDir).

The Composer’s Illusion

Here’s the subtle bit that everyone misses: rootDir only controls the structure of the output, not which files are included. File inclusion is handled by "include", "exclude", and "files". rootDir’s only job is to say, “Okay, for all the files you are going to compile, this is the directory I will use as the root when I mirror them into outDir.”

So, if you set "include": ["**/*"] and "rootDir": "./src", but you have a file at ./scripts/old-thing.ts, the compiler will still try to compile ./scripts/old-thing.ts and then freak out because it can’t place it under ./dist without breaking the mirroring rule. You didn’t break the mirroring rule; you broke the inclusion rule by letting a source file exist outside the structure you told the compiler to mirror. Always use "exclude" to be absolutely sure. Your future self will thank you for the cleanliness.