The tsconfig settings that actually enforce the correctness you want

By Sven Lindqvist · 21 July 2026148 views
The tsconfig settings that actually enforce the correctness you want

A backend engineer at a fintech startup inherits a two-year-old TypeScript codebase. The CI pipeline reports zero TypeScript errors. The codebase has 80,000 lines and processes real payment transactions. She opens tsconfig.json and sees "strict": false. She runs git log --follow tsconfig.json and discovers that strict mode was explicitly disabled on day three of the project — "too many errors" according to the commit message.

She adds "strict": true to tsconfig locally and runs the compiler. It produces 847 errors.

Those 847 errors are not TypeScript being overzealous. They represent 847 places where the codebase makes assumptions about null safety, implicit types, or function signatures that the compiler cannot verify. Every one of those 847 errors is a place where the code might silently produce undefined where a value is expected, where a function might receive any data and proceed as though it is a specific type, or where a catch block might access .message on something that is not an Error. The two-year-old codebase has been compiling cleanly while carrying those risks.

TypeScript without strict: true is TypeScript with many of its most valuable safety guarantees disabled. Understanding which flags enable which guarantees — and which flags are not in strict at all but should usually be enabled — makes it possible to deliberately choose the type safety level for a codebase rather than accepting the defaults.

What strict: true enables

The strict flag is shorthand for a group of individual flags. As of TypeScript 5.x, enabling strict: true enables all of these simultaneously:

{
  "compilerOptions": {
    "strictNullChecks": true,
    "noImplicitAny": true,
    "strictFunctionTypes": true,
    "strictBindCallApply": true,
    "strictPropertyInitialization": true,
    "noImplicitThis": true,
    "useUnknownInCatchVariables": true,
    "alwaysStrict": true
  }
}

Each of these catches a specific class of error.

strictNullChecks

Without this flag, null and undefined are assignable to every type. The following compiles without error when strictNullChecks is false:

function getUserEmail(userId: string): string {
  const user = db.findUser(userId);  // Returns User | null
  return user.email;  // Runtime crash if user is null — but TypeScript sees no problem
}

const email: string = null;  // TypeScript says fine
email.toLowerCase();  // Runtime: TypeError: Cannot read properties of null

With strictNullChecks: true, every nullable value must be explicitly handled before use:

function getUserEmail(userId: string): string | null {
  const user = db.findUser(userId);  // User | null
  if (!user) return null;  // Must handle null path
  return user.email;  // TypeScript knows user is User here, not null
}

const email = getUserEmail(userId);
if (email) {
  email.toLowerCase();  // OK — email is string in this branch
}

strictNullChecks is the single most impactful flag for preventing null reference exceptions — the most common runtime error in JavaScript applications. Enabling it in a codebase that was built without it typically reveals hundreds of places where null is passed as a value, returned from a function typed as non-nullable, or accessed without checking.

noImplicitAny

Without this flag, TypeScript infers any for parameters and variables whose type it cannot determine. any disables all type checking for that value — it spreads silently through the codebase as functions that receive any parameters return any values.

// Without noImplicitAny — TypeScript infers 'any' silently
function processConfig(data) {
  // 'data' is 'any' — TypeScript checks nothing about how it is used
  return {
    maxRetries: data.maxRetries,  // No error even if data is null
    timeout: data.config.timeout,  // No error even if data.config doesn't exist
  };
}

// The return type is also inferred as 'any'
const config = processConfig(null);
config.maxRetries.toString();  // TypeScript says fine; runtime says TypeError

With noImplicitAny: true:

function processConfig(data: AppConfig): ProcessedConfig {  // Type annotation required
  // Now TypeScript knows the shape of data and can check all property accesses
  return {
    maxRetries: data.maxRetries,
    timeout: data.config.timeout,
  };
}

The flag does not ban any — it bans implicit any. Explicit as any is still permitted. This distinction is valuable: explicit any is a conscious choice documented in code, while implicit any is often an oversight.

strictFunctionTypes

This flag enables proper contravariant checking for function parameter types. Without it, TypeScript uses bivariant checking for method parameters, which allows incorrect subtype relationships.

type StringHandler = (s: string) => void;
type NumberOrStringHandler = (s: string | number) => void;

// With strictFunctionTypes, this is correctly flagged:
const handler: StringHandler = (s: string | number) => console.log(s);
// A function that accepts string|number cannot safely replace a StringHandler
// because the StringHandler contract says the parameter is always string
// but callers of StringHandler might pass only strings, while the implementation
// might receive numbers in other contexts

// The practical effect: method callback types are checked more precisely

In practice, strictFunctionTypes catches subtle typing errors in callback-heavy code — event handlers, array methods, and Promise chains.

useUnknownInCatchVariables (TypeScript 4.4+)

In a catch block, the caught value is any without this flag. JavaScript allows throwing any value — strings, numbers, plain objects, not just Error instances. The any type in catch blocks means TypeScript cannot help prevent incorrect access.

try {
  await processPayment(order);
} catch (error) {
  // Without useUnknownInCatchVariables: error is 'any'
  // These all compile, but may throw at runtime:
  console.error(error.message);     // Fine if error is Error, crashes if it's a string
  logError(error.code, error.type); // TypeScript sees no problem

  // With useUnknownInCatchVariables: error is 'unknown'
  if (error instanceof Error) {
    console.error(error.message);   // OK — narrowed to Error
    if ('code' in error) {
      console.error((error as { code: string }).code);  // Explicit narrowing required
    }
  } else {
    console.error('Non-Error thrown:', String(error));
  }
}

The unknown catch variable type forces explicit handling of the reality that thrown values are not guaranteed to be Error instances. Libraries throw plain objects, third-party code throws strings, and network errors sometimes manifest as values with unexpected shapes.

strictPropertyInitialization

Class properties must be initialized in the constructor or declared with a definite assignment assertion. Without this flag, TypeScript allows properties that are never initialized, leading to undefined values that are typed as something else.

// Without strictPropertyInitialization:
class UserService {
  private db: Database;  // Never assigned in constructor
  // TypeScript sees db as Database, not Database | undefined
  // Runtime: TypeError when db.query() is called
}

// With strictPropertyInitialization:
class UserService {
  private db: Database;

  constructor(db: Database) {
    this.db = db;  // Required — or TypeScript error
  }
}

// Or using definite assignment assertion when initialization is deferred:
class UserService {
  private db!: Database;  // '!' asserts it will be initialized before use

  async initialize() {
    this.db = await createDatabase();
  }
}

Flags not in strict that should usually be enabled

noUncheckedIndexedAccess

This is the most impactful flag not included in strict. Array indexing and object index signature access return T | undefined when this flag is enabled, rather than T. The distinction matters because arrays can be empty and index signature objects may not contain the key being accessed.

const items = ['a', 'b', 'c'];

// Without noUncheckedIndexedAccess:
const first: string = items[0];  // TypeScript sees 'string', accepts the assignment
const missing: string = items[999];  // TypeScript sees 'string' — but this is undefined at runtime
missing.toUpperCase();  // Runtime: TypeError

// With noUncheckedIndexedAccess:
const first: string | undefined = items[0];  // TypeScript forces handling of undefined
if (first !== undefined) {
  first.toUpperCase();  // OK — narrowed to string
}

// Also applies to object index signatures
const cache: Record<string, User> = {};
const user: User | undefined = cache['alice'];  // Must check before use
if (user) {
  sendWelcomeEmail(user);
}

// And to named tuple access in some cases
const pair: [string, number] = ['hello', 42];
const first2: string = pair[0];  // OK — index 0 is always string in a named tuple
// But numeric index on any generic array type is still T | undefined

noUncheckedIndexedAccess causes more friction in code that uses frequent array indexing — especially array-heavy algorithmic code where the developer can prove the index is in bounds. For most application code (React components, API handlers, business logic), the friction is worth the safety: bugs caused by assuming an array is non-empty are common and difficult to debug.

noImplicitReturns

All code paths in a function must return a value when the return type is not void | undefined:

// Without noImplicitReturns — TypeScript does not flag the missing return
function getPermissionLevel(role: string): 'read' | 'write' | 'admin' {
  if (role === 'admin') return 'admin';
  if (role === 'editor') return 'write';
  // No return for reader or unknown roles — implicitly returns undefined
  // But the return type says it returns a string literal
}

// The caller gets undefined where they expect 'read' | 'write' | 'admin'
const level = getPermissionLevel('reader');
level.toUpperCase();  // Runtime: TypeError — level is undefined

// With noImplicitReturns:
function getPermissionLevel(role: string): 'read' | 'write' | 'admin' {
  if (role === 'admin') return 'admin';
  if (role === 'editor') return 'write';
  return 'read';  // Required — covers the unmatched case
}

This flag is particularly valuable for functions with multiple conditional return paths, where it is easy to miss a branch.

noFallthroughCasesInSwitch

Switch case fallthrough — where a case with no break or return falls through to the next case — is a common source of bugs:

// Without noFallthroughCasesInSwitch:
function processEvent(event: string): void {
  switch (event) {
    case 'login':
      logLoginEvent();
      // Missing break — falls through to 'signup'!
    case 'signup':
      sendWelcomeEmail();
      break;
  }
}
// processEvent('login') calls both logLoginEvent() AND sendWelcomeEmail()

// With noFallthroughCasesInSwitch:
// Error: Fallthrough case in switch — must add break, return, or throw

exactOptionalPropertyTypes

Without this flag, { field?: string } accepts { field: undefined } even though undefined differs semantically from the property being absent. With exactOptionalPropertyTypes: true, the two are distinct:

interface UpdateUserRequest {
  name?: string;    // Optional — can be absent, but if present, must be string
  email?: string;
}

// Without exactOptionalPropertyTypes:
const request: UpdateUserRequest = { name: undefined };  // Allowed
// The API receives { name: undefined } instead of {} — may cause a null update

// With exactOptionalPropertyTypes:
const request: UpdateUserRequest = { name: undefined };  // Error
// Error: Type '{ name: undefined; }' is not assignable to type 'UpdateUserRequest'

const request2: UpdateUserRequest = { name: 'Alice' };   // OK
const request3: UpdateUserRequest = {};                   // OK — no property

This matters for PATCH API semantics where undefined and absent have different meanings — { name: undefined } might clear the name field, while {} leaves it unchanged.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022", "DOM"],

    // Type safety — all of strict plus the critical extras
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,

    // Module behavior
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "verbatimModuleSyntax": true,

    // Output
    "outDir": "./dist",
    "declaration": true,
    "sourceMap": true,
    "skipLibCheck": true
  }
}

skipLibCheck: true deserves explanation: it skips type checking of .d.ts files in node_modules. In theory, checking library types catches bugs in library type definitions. In practice, many popular libraries have minor type errors in their published types, and checking all of them significantly increases compile times without providing meaningful safety benefits for application code. skipLibCheck: true is the pragmatic choice for almost all projects.

verbatimModuleSyntax ensures that import statements are preserved as-is in the output rather than being transformed. This prevents a category of issues where TypeScript removes type-only imports that bundlers expect to find.

Module resolution and import hygiene flags

Beyond type safety, several tsconfig flags affect how modules are resolved and imported. These interact with bundlers and runtimes in ways that produce silent failures if misconfigured.

moduleResolution: "bundler" (TypeScript 5.0+) — the correct resolution mode for projects using Vite, esbuild, Webpack 5, or similar bundlers. It allows importing TypeScript files with .js extensions (the standard in ESM modules) and allows extensionless imports. Without this, TypeScript may fail to resolve imports that the bundler handles correctly.

verbatimModuleSyntax (TypeScript 5.0+) — replaces the older importsNotUsedAsValues and preserveValueImports flags. It requires that type-only imports use import type, and it ensures that import statements are preserved in the output rather than being elided. This prevents a class of bugs where TypeScript removes an import because it believes the value is only used as a type, but the import has runtime side effects.

// Without verbatimModuleSyntax — TypeScript may remove this import
// if 'User' is only used as a type annotation
import { User } from './models';

// With verbatimModuleSyntax — explicit about the import purpose
import type { User } from './models';         // Type-only: guaranteed removed
import { createUser } from './models';         // Value: guaranteed kept
import { type User, createUser } from './models';  // Mixed: fine

forceConsistentCasingInFileNames: true — prevents case-sensitivity bugs on case-insensitive file systems (macOS, Windows). Code developed on macOS might import './UserService' while the file is named userService.ts. This compiles on macOS but fails on Linux CI servers. The flag catches the mismatch at compile time.

isolatedModules: true — required for projects using Babel or esbuild for transpilation (including most Vite and Next.js projects). These transpilers process one file at a time and cannot see the full type graph. isolatedModules flags patterns that break in single-file transpilation:

// PROBLEM: re-exporting a type looks like a value re-export to single-file transpilers
export { User } from './models';  // Error with isolatedModules — is User a type or value?

// CORRECT: explicit type re-export
export type { User } from './models';  // Clearly a type — safe to remove at transpile time

Migrating an existing codebase

Enabling strict: true in an existing codebase typically produces hundreds or thousands of compiler errors. The practical migration path is incremental:

Phase 1: Enable the highest-impact flags individually.

{
  "compilerOptions": {
    "strictNullChecks": true,  // Highest impact — exposes null/undefined risks
    "noImplicitAny": true      // Prevents any propagation
  }
}

Fix all errors these two flags produce before proceeding. These two flags together expose the majority of genuine runtime risks.

Phase 2: Triage with @ts-ignore for complex cases.

Some errors require significant refactoring — data access patterns, third-party integration code, complex generic utilities. Use // @ts-ignore sparingly for cases that require more time:

// @ts-ignore TODO: fix after strict migration — user.settings may be null
const maxRetries = user.settings.maxRetries;

Track the count of @ts-ignore comments. It should decrease over time as the deferred work is addressed.

Phase 3: Enable remaining strict flags.

Once phase 2 is complete, add the remaining strict: true flags individually, fixing errors before enabling the next flag.

Phase 4: Enable the non-strict extras.

noUncheckedIndexedAccess often produces the most errors in phase 4. Common patterns that need updating:

// Before noUncheckedIndexedAccess:
const first = items[0];
processItem(first);  // first is string — TypeScript says fine

// After noUncheckedIndexedAccess:
const first = items[0];  // first is string | undefined
if (first !== undefined) {
  processItem(first);
}

// Or use destructuring, which makes the undefined explicit:
const [first, ...rest] = items;
if (first) {
  processItem(first);
}

// Or use at() with a check:
const last = items.at(-1);  // string | undefined — same behavior as [items.length - 1]

The payoff from migration. The fintech backend engineer in the opening scenario spent three weeks fixing the 847 errors. At the end, the codebase had found and corrected eleven real bugs — places where null values propagated to functions that assumed non-null, where array access assumed non-empty arrays, and where catch blocks accessed .message on non-Error values. Those eleven bugs were found by the type checker, not by users.

The migration cost is proportional to how long the codebase ran without strict checking. For a new project, starting with the full configuration from day one costs nothing — the strictness shapes how code is written from the beginning, and the developer habit of handling nullability becomes automatic rather than requiring a later audit.

Comments

No comments yet. Be the first!

Sign in to leave a comment.