Skip to Content
Course content

168: The tsconfig.json Reference In Depth

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've seen this happen a dozen times with developers moving from older Node projects to modern ESM (ES Modules). You've got a project that looks perfect in VS Code—no red squiggles anywhere—but the second you run tsc in your terminal, it explodes with a wall of "Cannot find module" errors.

// src/index.ts
import { logger } from './utils/logger.js'; 

console.log(logger.log('System started'));

The error looks something like this: Cannot find module './utils/logger.js'. Did you mean './utils/logger'?

Wait, why is TypeScript complaining about the .js extension? You're writing TypeScript! You've probably tried removing the extension, but then Node.js crashes at runtime because ESM requires explicit extensions. You're stuck in a loop where the IDE is happy, the compiler is angry, and the runtime is confused.

The mismatch between Module and ModuleResolution

The culprit here is almost always a misalignment in your tsconfig.json. In older TypeScript projects, we used "module": "commonjs" and "moduleResolution": "node". The node resolution strategy (now often called node10) is "lazy"—it looks for .ts, then .tsx, then /index.ts, and it doesn't care if you omit the extension.

But modern Node.js uses a much stricter resolution algorithm. If you're using "type": "module" in your package.json, your tsconfig.json needs to reflect that. If your config looks like this:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node" 
  }
}


...you're telling TypeScript to output ESM code, but to look up files using the old CommonJS rules. That's why tsc hates your .js extensions.

Aligning the compiler with the runtime

To fix this, you need to move to NodeNext (or Node16). This is a "combo" setting that forces TypeScript to follow the actual rules Node.js uses for ESM.

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}


Once you make this change, TypeScript finally understands why you're importing a .js file while writing a .ts file. It realizes that in the final emitted code, that extension must exist for Node to find the file. The errors vanish, and your build actually works.

Taming the Strictness Bundle

You've likely already seen the "strict": true flag. I usually tell people to just leave it on, but as you get deeper into the tsconfig reference, you should know that strict is actually a "meta-flag." It's a shortcut for a whole group of settings.

If you find strict: true is too aggressive for a legacy codebase you're migrating, you can turn it off and selectively enable the ones that actually matter. The most critical ones are:

  • strictNullChecks: This is the one that prevents 90% of "cannot read property of undefined" crashes. Keep this on at all costs.
  • noImplicitAny: Stops you from accidentally opting out of type checking by forgetting a type annotation.
  • strictFunctionTypes: Ensures your function arguments are checked more rigorously.

I've seen engineers disable strict entirely just to get a project to compile, but that's like taking the brakes off your car because you're tired of stopping at red lights. It's faster for a minute, but the crash is inevitable.

Solving the Import Spaghetti with Paths

Eventually, your imports start looking like this: import { User } from '../../../core/models/user'. It's ugly and fragile. You can fix this using baseUrl and paths, but there's a catch that trips everyone up.

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@core/*": ["src/core/*"]
    }
  }
}


Here is the "gotcha": paths is a compiler-only feature. TypeScript will resolve these aliases during type checking, but it will not rewrite them to relative paths when it emits the JavaScript. If you run the resulting JS in Node, it will throw a "Module not found" error because Node has no idea what @core/ means.

If you use aliases, you'll need a runtime helper like tsconfig-paths or use the native imports field in package.json. Don't assume that because the tsconfig is happy, the code will actually run.

The "Quiet" Flags that Save Your Build

When you start importing large third-party libraries, you might see TypeScript complaining about types inside those libraries—files you didn't even write. It's incredibly frustrating to have a build fail because a random npm package has a type mismatch in its own internal files.

This is where "skipLibCheck": true becomes your best friend. It tells TypeScript: "Don't type-check the .d.ts files in node_modules. I trust the library authors (mostly), and I just want to check my own code." In any professional project, this is almost always set to true to keep build times down and sanity levels up.




📋 Practical Task

Exercise: Repairing the "Broken Alias" Configuration

You have been handed a project with a broken tsconfig.json. The developer tried to implement path aliases and modern ESM, but the configuration is conflicting, causing the compiler to fail and the imports to be unresolved.

The Scenario:

  • The project uses "type": "module" in package.json.
  • The developer is using import { api } from '@services/api.js'.
  • The current tsconfig.json is using "moduleResolution": "node" and has a misplaced baseUrl.

Your Task: Edit the following tsconfig.json to:

  1. Update the module and moduleResolution to support modern Node.js ESM.
  2. Fix the paths configuration so that @services/* correctly maps to the src/services/ directory.
  3. Ensure that third-party type errors in node_modules are ignored to speed up compilation.
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "node",
    "baseUrl": "src", 
    "paths": {
      "@services/*": ["services/*"]
    },
    "strict": true,
    "skipLibCheck": false
  }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.