TypeScript strict mode in code editor

TypeScript Modern Checks That Prevent Runtime Bugs

Practical TypeScript compiler options and coding patterns that catch real bugs before they reach production. Covers strict mode, branded types, and exhaustive checks.

typescriptstrict-modetype-safetyruntime-bugs

TypeScript has become the default language for serious web development, but many teams use only a fraction of its type system. Enabling strict mode is a good start, but the real value comes from understanding which checks and patterns catch the bugs that actually cause production incidents.

This article walks through the most useful TypeScript compiler options, type patterns, and project configurations for 2026. It is aimed at developers who already write TypeScript and want to get more value from the type system without descending into type-level gymnastics. The programming languages hub covers broader language resources, and the web fundamentals path connects TypeScript skills to front-end engineering context.

The web development hub links to JavaScript and HTML resources that complement TypeScript knowledge.

Compiler options that earn their keep

strict: true

This is the baseline. It enables strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables. If you do not have strict: true in your tsconfig.json, you are leaving significant safety on the table.

noUncheckedIndexedAccess

When you access an array element or object property by index (arr[0], obj[key]), TypeScript normally assumes the result matches the element type. With noUncheckedIndexedAccess, the type includes undefined, forcing you to handle the case where the element does not exist.

This catches a large class of runtime errors: off-by-one bugs, missing object keys, and assumptions about array length.

exactOptionalPropertyTypes

By default, TypeScript treats { foo?: string } as equivalent to { foo: string | undefined }. With exactOptionalPropertyTypes, there is a distinction: the property can be absent (not set) or present with a string value, but explicitly setting it to undefined is an error.

This matters for APIs where "not provided" and "explicitly null/undefined" have different semantics (e.g., PATCH requests, configuration objects with defaults).

noPropertyAccessFromIndexSignature

Index signatures ([key: string]: SomeType) create a catch-all that matches any property name. This option forces you to use bracket notation for index signature access, making it visually distinct from known properties. It prevents accidentally relying on properties that the type system cannot verify exist.

Type patterns that prevent real bugs

Discriminated unions

Discriminated unions use a literal type field to distinguish between variants:

type Result =
  | { status: "ok"; data: User }
  | { status: "error"; message: string };

When you switch on result.status, TypeScript narrows the type in each branch. This eliminates an entire class of "property does not exist" runtime errors and makes impossible states unrepresentable.

The satisfies operator

The satisfies operator validates that a value matches a type without widening it. This is useful for configuration objects where you want type checking but also want TypeScript to infer the specific literal types.

Template literal types

Template literal types let you constrain strings to specific patterns. Use them for route paths, API endpoint strings, CSS class names, and any domain where arbitrary strings are a bug source.

Branded types

TypeScript's structural typing means two types with the same shape are interchangeable. Branded types add a phantom property to create nominal-like types:

A UserId and an OrderId might both be strings, but branding prevents you from passing one where the other is expected. This catches logic errors that structural typing misses.

Project configuration for teams

Use project references

For monorepos and large codebases, project references (references in tsconfig.json) let you split the codebase into independently compiled units. This improves build times and enforces module boundaries.

Separate configs for source and tests

Tests often need looser type rules (e.g., allowing any for mocking). Use a separate tsconfig.test.json that extends your base config with relaxed settings, rather than loosening the main config.

Pin the TypeScript version

TypeScript occasionally introduces stricter checks in minor releases. Pin the exact version in your package.json and update deliberately, running your full test suite before accepting a new version.

Common mistakes

Mistake: using any to silence errors. Every any is a hole in your type system. Use unknown when you genuinely do not know the type, and narrow it explicitly.

Mistake: excessive type assertions (as). Type assertions bypass checking. They are appropriate for FFI boundaries and test fixtures, but should be rare in application code. If you need more than a few per module, the types are wrong.

Mistake: ignoring strictNullChecks failures. The temptation is to add ! (non-null assertion) everywhere. This is just any with extra steps. Handle null cases explicitly.

Mistake: complex generic chains. If a type requires a PhD to read, it will not be maintained. Simpler types that cover 95% of cases are more valuable than perfect types that nobody understands.

Trade-offs

  • Stricter checks slow down initial development but dramatically reduce debugging time in production.
  • noUncheckedIndexedAccess adds verbosity for array-heavy code. The trade-off is worth it for data processing; it may feel heavy for simple UI code.
  • Branded types add boilerplate but are invaluable in domains with many ID types or measurement units.

Further reading on EBooks-Space