📖 The Story: The Defaults Trap

A startup launched their TypeScript project with the default tsconfig.json generated by tsc --init. The comments showed dozens of options, but none were enabled. The team assumed "TypeScript is keeping us safe."

Six months later, a production crash occurred. The root cause? A function received null for a parameter that the developer assumed was always a string. The code was TypeScript—but strictNullChecks was off, so TypeScript happily allowed null anywhere.

The CTO discovered that without strict: true, TypeScript was running in "legacy mode"—barely safer than plain JavaScript. They flipped one boolean in tsconfig.json, and the compiler immediately surfaced 847 errors across the codebase. Each one was a potential crash waiting to happen.

It took two weeks to fix those 847 errors. The team hasn't had a null-related production incident since.

TypeScript without strict mode is a seatbelt you never buckle. It looks safe but protects nothing.

🎯 Why tsconfig Matters

🛡️

Safety Level

strict: true transforms TS from "JavaScript with autocomplete" to a genuine bug-catcher.

Build Output

Controls what JavaScript version is generated and how modules are bundled.

🗺️

Developer Experience

Path aliases eliminate ../../../ import chains forever.

🧒 Explain Like I'm 10

Imagine a super-smart robot (the TypeScript compiler) that checks your homework. The tsconfig.json file is the rules sheet you give the robot.

If the rules sheet says "just glance at it" (no strict), the robot only catches obvious mistakes. If the rules sheet says "check EVERYTHING, letter by letter" (strict mode), the robot catches every tiny error.

Most people leave the rules sheet nearly blank and wonder why the robot misses things. Fill it out properly, and the robot becomes unstoppable.

🎓 Professional Explanation

tsconfig.json is the configuration file for the TypeScript compiler (tsc). It's divided into two main sections: compilerOptions (how the compiler behaves) and top-level fields like include/exclude (which files to compile). The strict flag is an umbrella that enables eight individual strictness flags, each catching a different category of bug.

⚙️ Anatomy of tsconfig

json · tsconfig.json
{
  "compilerOptions": {
    /* Language & Environment */
    "target": "ES2022",
    "module": "ESNext",
    "lib": ["ES2022", "DOM"],

    /* Strictness */
    "strict": true,

    /* Module Resolution */
    "moduleResolution": "bundler",
    "baseUrl": "./",
    "paths": { "@/*": ["./src/*"] },

    /* Output */
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

🛡️ Strict Mode Family

Setting "strict": true enables all eight flags below. Let's see what each one protects you from:

1. strictNullChecks — The Most Important Flag

typescript · Without vs With
// WITHOUT strictNullChecks:
function greet(name: string) {
  return "Hello " + name.toUpperCase();
}
greet(null); // No error! Crashes at runtime: Cannot read .toUpperCase of null

// WITH strictNullChecks:
function greet(name: string) {
  return "Hello " + name.toUpperCase();
}
greet(null); // ❌ Error: Argument of type 'null' is not assignable to 'string'
greet(undefined); // ❌ Error too!

This single flag eliminates the #1 crash in JavaScript applications: null and undefined access errors.

2. noImplicitAny — No Silent any

typescript
// WITHOUT noImplicitAny:
function process(data) { // data is silently 'any'
  return data.foo.bar.baz; // No warning! Crashes if data is null
}

// WITH noImplicitAny:
function process(data) { // ❌ Error: Parameter 'data' implicitly has 'any' type
  return data.foo.bar.baz;
}

3. The Other Six Flags

FlagWhat It Catches
strictFunctionTypesContravariant function parameter checking (sound function types)
strictBindCallApplyCorrect types for bind(), call(), apply()
strictPropertyInitializationClass properties must be initialized in constructor
noImplicitThisthis in functions must have a known type
alwaysStrictAdds "use strict" to output
useUnknownInCatchVariablescatch (e) gives unknown, not any

💎 Hidden Gems Beyond Strict

These flags aren't in strict but should be enabled in every serious project:

noUncheckedIndexedAccess

typescript
// Without this flag:
const arr = [1, 2, 3];
const item = arr[10]; // Type: number (but it's actually undefined!)

// With noUncheckedIndexedAccess:
const item = arr[10]; // Type: number | undefined ✅
item.toFixed(2); // ❌ Error! Might be undefined
if (item !== undefined) {
  item.toFixed(2); // ✅ Safe!
}

This flag also applies to object index access with Record<string, T>, making myRecord[key] return T | undefined.

Other Essential Non-Strict Flags

json
{
  "noUncheckedIndexedAccess": true,  // Array/object access includes undefined
  "noUnusedLocals": true,         // Error on unused local variables
  "noUnusedParameters": true,     // Error on unused function parameters
  "noFallthroughCasesInSwitch": true, // Error on switch fallthrough
  "noImplicitOverride": true,      // Require 'override' keyword in subclasses
  "forceConsistentCasingInFileNames": true // Fix Mac/Windows case issues
}

📦 Module Resolution

StrategyUse When
"bundler"Vite, webpack, esbuild (modern default)
"NodeNext"Node.js with ESM or CommonJS
"node"Legacy Node.js CommonJS (deprecated)

🗺️ Path Aliases

The developer experience upgrade that eliminates import spaghetti:

typescript
// Before: Import spaghetti
import { Button } from '../../../../components/Button';

// After: With path aliases in tsconfig.json
import { Button } from '@/components/Button';

/* tsconfig.json:
{
  "compilerOptions": {
    "baseUrl": "./",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
*/
⚠️ Don't Forget the Bundler!

TypeScript knows the alias, but your bundler (Vite/webpack) doesn't. You must also configure the alias in vite.config.ts or webpack.config.js, otherwise the build will fail.

🏆 The Production-Ready Config

Here's a battle-tested tsconfig.json for a modern frontend project:

json · Production tsconfig
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],

    /* Maximum Safety */
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true,

    /* Module System */
    "esModuleInterop": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "skipLibCheck": true,

    /* Path Aliases */
    "baseUrl": "./",
    "paths": {
      "@/*": ["./src/*"]
    },

    /* Output */
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}

noEmit: true: Modern bundlers (Vite, Next.js) handle the transpilation. TypeScript just checks types. skipLibCheck: true: Skips checking .d.ts files in node_modules for massive speed improvements.

⚠️ Common Mistakes

Mistake 1: Leaving strict Off "Temporarily"

Every project that starts loose stays loose. The longer you wait, the more errors accumulate, making the migration harder. Enable strict: true from day one.

Mistake 2: Using @ts-ignore Instead of Fixing Types

typescript · Bad
// ❌ Bad: Silences the error AND hides future bugs on that line
// @ts-ignore
config.setup(maybeNullValue);

// ✅ Better: Scoped, documented suppression
// @ts-expect-error: Third-party lib hasn't updated types yet (Issue #123)
config.setup(maybeNullValue);

@ts-expect-error is better than @ts-ignore: It errors if the underlying issue is fixed (reminding you to remove the comment). @ts-ignore silently suppresses forever.

Mistake 3: Forgetting extends for Monorepos

json · Monorepo Pattern
// packages/shared/tsconfig.json (base config)
{
  "compilerOptions": { "strict": true, "target": "ES2022" }
}

// apps/frontend/tsconfig.json (extends base)
{
  "extends": "../../packages/shared/tsconfig.json",
  "compilerOptions": {
    "jsx": "react-jsx"  // App-specific override
  }
}

✅ Best Practices

  1. Always use strict: true — non-negotiable for new projects.
  2. Add noUncheckedIndexedAccess: true for maximum safety on array/object access.
  3. Use skipLibCheck: true for faster compilation on large projects.
  4. Use path aliases (@/*) to keep imports clean and refactoring easy.
  5. Extend a base config in monorepos to keep all packages consistent.
  6. Prefer @ts-expect-error with a reason comment over @ts-ignore.

🏗️ Senior Deep Dive

The extends Chain

TypeScript supports cascading extends up to 5 levels deep. Frameworks like Next.js and Vite provide their own base configs (next-env.d.ts or vite/client types). Understanding how these merge is critical for debugging why a type isn't resolving.

tsconfig vs Project References

For massive codebases, Project References ("references": [{"path": "./packages/core"}]) allow incremental compilation—only rebuilding changed packages. This turns a 60-second compile into a 5-second one.

💼 Interview Questions

Beginner
What does strict: true enable?
Show Answer
It enables 8 flags simultaneously: strictNullChecks, noImplicitAny, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables. Together, they provide maximum compile-time safety.
Intermediate
What is the difference between @ts-ignore and @ts-expect-error?
Show Answer
@ts-ignore suppresses any error on the next line forever. @ts-expect-error suppresses the error but will itself error if no error exists—reminding you to remove the comment when the underlying issue is fixed.
Advanced
Why would you use noEmit: true?
Show Answer
When a bundler (Vite, webpack, esbuild) handles transpilation, TypeScript only needs to do type checking. Setting noEmit: true prevents the compiler from generating .js files, speeding up the check and avoiding conflicts with the bundler's output.
Scenario
Your team says enabling strict mode produces 800 errors. They want to skip it. What do you do?
Show Answer
I recommend a gradual migration: enable individual flags incrementally, starting with noImplicitAny (usually catches the most errors but easiest to fix with types), then strictNullChecks (catches the most dangerous bugs). Tools like ts-strictify can count remaining errors per flag. The alternative—shipping without strict—costs more in production incidents than the migration effort.

🏋️ Practical Exercise

🎯 Hands-On Practice Medium

Audit Your tsconfig

  1. Open any TypeScript project's tsconfig.json.
  2. Check: Is strict: true set? If not, you've found a vulnerability.
  3. Check: Is noUncheckedIndexedAccess enabled?
  4. Count how many @ts-ignore comments exist: grep -r "@ts-ignore" src/ | wc -l
  5. Enable strict mode and see how many errors surface. Each one is a prevented bug.

📝 Quiz

Test your understanding. Click an option to check your answer.

1. Which flag makes null and undefined separate types from other values?
A) noImplicitAny
B) strictNullChecks
C) alwaysStrict
Answer: B — strictNullChecks is the single most important safety flag, preventing null/undefined crashes.
2. What does noUncheckedIndexedAccess do?
A) Prevents accessing arrays by index
B) Makes arr[i] return T | undefined instead of T
C) Checks for array bounds at runtime
Answer: B — It acknowledges that index access can return undefined (out of bounds), forcing you to handle it.
3. Why is @ts-expect-error preferred over @ts-ignore?
A) It's shorter to type
B) It errors when the suppression is no longer needed
C) It works in more file types
Answer: B — Once the underlying error is fixed, @ts-expect-error itself becomes an error, reminding you to clean up.
4. What does "extends" do in tsconfig.json?
A) Extends the compilation timeout
B) Inherits compiler options from another tsconfig file
C) Adds more file types
Answer: B — It lets you share a base configuration across multiple packages in a monorepo.
5. Why set noEmit: true in a Vite project?
A) Vite handles the transpilation, so TS only needs to type-check
B) It makes TypeScript run faster at runtime
C) It's required by all frameworks
Answer: A — The bundler does the actual code generation; TypeScript's job is purely type checking.
6. What does skipLibCheck: true do?
A) Skips checking your source code
B) Skips type-checking .d.ts files in node_modules, speeding up compilation
C) Disables TypeScript entirely
Answer: B — It avoids checking third-party type declarations, which are often inconsistent. Massive speed boost.

📦 Summary

🎯 Key Takeaways

  • strict: true is non-negotiable — it enables 8 safety flags including strictNullChecks.
  • noUncheckedIndexedAccess makes index access return T | undefined.
  • Path aliases (@/*) eliminate deep relative imports.
  • noEmit: true when a bundler handles transpilation.
  • skipLibCheck: true for faster compilation on large projects.
  • Prefer @ts-expect-error over @ts-ignore with a reason comment.
  • Use extends to share configs across monorepo packages.