TypeScript Strict Mode Best Practices: Eliminating Runtime Errors Before They Happen
Adding "strict": true to your tsconfig.json is one of the highest-ROI things you can do on a TypeScript project. It activates eight compiler checks that collectively eliminate whole categories of runtime errors at compile time. But most teams either skip it entirely or enable it without understanding what each check does. This guide covers every strict flag in depth, with practical examples and a safe migration strategy for existing codebases.
TL;DR
"Enable TypeScript strict mode and understand all 8 strict checks: strictNullChecks, noImplicitAny, strictFunctionTypes, and more. Migrate safely and."
Table of Contents
- The Cost of Permissive TypeScript
- What "strict": true Actually Enables
- strictNullChecks: The Most Impactful Flag
- noImplicitAny: No Hidden any
- strictFunctionTypes: Correct Function Parameter Variance
- strictPropertyInitialization: No Uninitialized Class Fields
- useUnknownInCatchVariables: Safer Error Handling
- strictBindCallApply and alwaysStrict
- noImplicitThis: Typed this in Functions
- Safe Migration Strategy for Existing Projects
- Additional Strict-Like Flags Worth Enabling
- Angular and NestJS specific considerations
- Common Patterns for Handling null Safely
- Conclusion
The Cost of Permissive TypeScript
Without strict mode, TypeScript is fundamentally just JavaScript with optional type annotations. You can write code like this and the compiler says nothing:
// Permissive TypeScript — compiles with no errors
function processUser(user) { // user is implicitly 'any'
return user.profile.name.toUpperCase(); // crashes if user.profile is null
}
let name: string;
name = null; // allowed without strictNullChecks
function logValue(value: string) { console.log(value.length); }
logValue(undefined as any); // 'any' bypasses all checks
All of these produce runtime exceptions: Cannot read property 'name' of null, Cannot read property 'toUpperCase' of undefined. The type system gave you false confidence.
Strict mode eliminates this. It costs you some upfront effort when migrating an existing codebase, but it pays back in fewer production bugs, safer refactoring, and better developer tooling (autocomplete and hover types become accurate rather than misleading).
What "strict": true Actually Enables
The single "strict": true flag in tsconfig.json is a shorthand that enables eight individual flags simultaneously:
{
"compilerOptions": {
"strict": true,
// Equivalent to enabling all of these:
// "strictNullChecks": true,
// "noImplicitAny": true,
// "strictFunctionTypes": true,
// "strictBindCallApply": true,
// "strictPropertyInitialization": true,
// "noImplicitThis": true,
// "alwaysStrict": true,
// "useUnknownInCatchVariables": true (TypeScript 4.4+)
}
}
You can also enable these individually for a gradual rollout. Let's examine each one.
Understanding what each flag does before enabling them matters because some — particularly strictPropertyInitialization and noImplicitAny — can produce a large initial wave of errors in existing code that requires careful, systematic remediation. Enabling flags incrementally, starting with strictNullChecks, lets teams fix one category of error at a time and commit stable code after each phase without blocking the team on a multi-week big-bang migration. Because "strict": true is a living shorthand (TypeScript may add new flags to it in future releases), pinning the individual flags explicitly in legacy codebases gives you full control over exactly when each new check takes effect across your CI pipeline.
strictNullChecks: The Most Impactful Flag
Without this flag, null and undefined are assignable to every type. With it, they form their own types that must be explicitly handled.
// strictNullChecks: false (default, dangerous)
let name: string = null; // allowed
let count: number = undefined; // allowed
// strictNullChecks: true (safe)
let name: string = null; // Error: Type 'null' is not assignable to type 'string'
let name2: string | null = null; // OK — explicitly allows null
// Narrowing is required before use
function greet(name: string | null): string {
// name.toUpperCase(); // Error: Object is possibly 'null'
if (name === null) return 'Hello, Guest';
return `Hello, ${name.toUpperCase()}`; // OK — narrowed to string
}
// Optional chaining + nullish coalescing are the idiomatic solutions
function getDisplayName(user: User | null): string {
return user?.profile?.name ?? 'Anonymous';
}
In practice, enabling strictNullChecks on an existing codebase surfaces latent bugs that have been causing silent undefined is not a function errors in production — the compiler errors are exactly those bugs manifesting at compile time instead of at runtime. The optional chaining (?.) and nullish coalescing (??) operators were designed to work hand-in-hand with strictNullChecks, making null-safe code dramatically more concise than pre-ES2020 chains of explicit guard conditions. A common pitfall is using the non-null assertion operator (!) to suppress errors without fixing the underlying logic — reserve it only for cases where you have out-of-band knowledge that the value cannot be null, such as properties guaranteed by a framework's dependency injection lifecycle.
noImplicitAny: No Hidden any
Without this flag, TypeScript silently assigns the any type to variables and parameters it cannot infer. This defeats the entire purpose of the type system.
// noImplicitAny: false
function processOrder(order) { // order is silently 'any'
return order.items.reduce(...); // no type checking, runtime crash possible
}
// noImplicitAny: true
function processOrder(order) { // Error: Parameter 'order' implicitly has 'any' type
// ...
}
// Fix: always annotate parameters
function processOrder(order: Order): ProcessedOrder {
return order.items.reduce((acc, item) => acc + item.price, 0);
}
// noImplicitAny also catches index access
const config: { [key: string]: string } = {};
const value = config['timeout']; // string — OK
function lookup(key) { // Error: Parameter 'key' implicitly has 'any' type
return config[key];
}
// Fix
function lookup(key: string): string | undefined {
return config[key];
}
The most common source of implicit any in real codebases is unannotated callback parameters in array methods where the containing array type is also unspecified — annotating the array type correctly propagates through all downstream operations without any further changes. Third-party JavaScript libraries without type declarations are another major source of implicit any; installing the corresponding @types/ package resolves most of them, and for libraries without community typings, a minimal declare module 'library-name' stub is far preferable to a blanket // @ts-ignore that silences all type errors in the import. Avoid casting values explicitly to any just to silence noImplicitAny errors — this defeats the flag entirely and reintroduces the same runtime risk that strict mode was designed to prevent.
strictFunctionTypes: Correct Function Parameter Variance
This flag enforces correct variance for function types — specifically, parameters are checked contravariantly rather than bivariantly. This prevents a subtle class of type errors in callbacks and event handlers.
type Logger = (message: string) => void;
type DetailedLogger = (message: string, level: 'info' | 'error') => void;
declare let logMessage: Logger;
declare let logDetailed: DetailedLogger;
// strictFunctionTypes: false (bivariant — both directions allowed, unsafe)
logMessage = logDetailed; // No error, but calling logMessage("hello") would pass
// undefined as 'level' to the DetailedLogger
// strictFunctionTypes: true (contravariant — only safe assignments)
logMessage = logDetailed; // Error: Type 'DetailedLogger' is not assignable to type 'Logger'
// Parameters of type 'DetailedLogger' are not compatible
// Why? Calling logMessage("hello") with no 'level' would break DetailedLogger.
// Contravariance protects against this.
// Safe: assign less specific to more specific (widening)
logDetailed = logMessage; // OK — Logger accepts any string call, which is safe
Note that strictFunctionTypes only applies to function types written using arrow or function syntax; method signatures declared in interfaces and classes use bivariant checking regardless of this flag, for historical compatibility. This distinction matters when designing callback-heavy APIs: prefer arrow function type signatures over method signatures in interfaces to gain the full safety benefit of contravariant parameter checking. The contravariance rule aligns with the Liskov Substitution Principle — a function that accepts a broader input type can safely replace one accepting a narrower input type, but the reverse assignment risks passing incompatible arguments at the call site.
strictPropertyInitialization: No Uninitialized Class Fields
Class properties must be definitively assigned in the constructor, or TypeScript errors. This prevents the common JavaScript pattern of declaring fields and initializing them later (in a lifecycle hook, for example).
// strictPropertyInitialization: true
class UserService {
private readonly repo: UserRepository; // Error: Property 'repo' has no initializer
// and is not definitely assigned in the constructor
constructor() {
// forgot to initialize repo
}
}
// Fix 1: Initialize in constructor
class UserService {
private readonly repo: UserRepository;
constructor(repo: UserRepository) {
this.repo = repo; // definitely assigned
}
}
// Fix 2: Use definite assignment assertion for frameworks that inject post-construction
class UserService {
@Inject()
private readonly repo!: UserRepository; // '!' tells TS: "I guarantee this is set"
// Use sparingly — only when framework handles it
}
// Fix 3: Declare with undefined union
class UserService {
private repo: UserRepository | undefined;
initialize(repo: UserRepository): void {
this.repo = repo;
}
findUser(id: string): Promise<User | null> {
if (!this.repo) throw new Error('Service not initialized');
return this.repo.findById(id);
}
}
The definite assignment assertion (! suffix) is the standard solution for Angular components where @Input() properties are guaranteed to be bound before the component renders, and for NestJS services where the DI container fills fields decorated with @Inject(). Overusing ! as a blanket suppressor restores the pre-strict risk of uninitialized field access — every usage should have a code comment explaining why the framework guarantees the initialization. For class members that are genuinely optional, declare them with | undefined in the type rather than using !, which forces every call site to handle the absent case explicitly rather than assuming the field is always populated.
useUnknownInCatchVariables: Safer Error Handling
Before TypeScript 4.4, caught exceptions were always typed as any. This flag types them as unknown, forcing you to narrow before accessing properties.
// Before TypeScript 4.4 (or without useUnknownInCatchVariables)
try {
await callExternalApi();
} catch (error) {
console.error(error.message); // 'error' is 'any' — no type checking
throw new Error(error.code); // 'code' may not exist, silently undefined
}
// With useUnknownInCatchVariables: true (TypeScript 4.4+ / strict mode)
try {
await callExternalApi();
} catch (error) {
// error is 'unknown' — must narrow before use
if (error instanceof Error) {
console.error(error.message); // OK — narrowed to Error
throw error;
}
if (typeof error === 'string') {
throw new Error(error); // OK — narrowed to string
}
throw new Error('Unknown error occurred');
}
// Utility function for clean error handling
function toError(e: unknown): Error {
if (e instanceof Error) return e;
return new Error(String(e));
}
try {
await riskyOperation();
} catch (e) {
const error = toError(e);
logger.error('Operation failed', { message: error.message, stack: error.stack });
}
The shift from any to unknown in catch blocks reflects a fundamental truth: thrown values are genuinely unpredictable — they can be Error instances, strings, numbers, plain objects, or even null, depending on the library or runtime environment. The toError utility function pattern is the idiomatic solution and belongs in a shared error handling module so that every error boundary in the application handles unknown throws consistently. A common mistake is narrowing only for instanceof Error and re-throwing everything else as a generic error, which discards valuable diagnostic information — log the original unknown value before wrapping it in a new Error so that the original cause is preserved in monitoring systems.
strictBindCallApply and alwaysStrict
// strictBindCallApply: true — typed bind, call, apply
function greet(name: string, age: number): string {
return `${name} is ${age}`;
}
greet.call(undefined, 'Alice', 30); // OK
greet.call(undefined, 'Alice'); // Error: Expected 2 arguments, got 1
greet.call(undefined, 'Alice', '30'); // Error: Argument of type 'string' not assignable to 'number'
// Before strictBindCallApply, bind/call/apply accepted any arguments — no type safety at all
// alwaysStrict: true — emits "use strict" in every output file
// and parses files in strict mode. Prevents accidental use of
// deprecated JavaScript features (octal literals, with statement, etc.)
Before strictBindCallApply, TypeScript typed the return value of .bind() as Function and accepted any arguments for .call() and .apply() — making them as unsafe as an explicit any cast. With the flag enabled, TypeScript validates argument count and types at each call site, which is particularly valuable for catching bugs in decorator factories and framework internals that use Function.prototype.call extensively. The alwaysStrict flag is a lightweight but important companion: emitting "use strict" in every output file ensures engines enforce ES5 strict semantics at runtime, preventing silent failures from legacy JavaScript behaviours such as accidental global variable creation through undeclared assignment.
noImplicitThis: Typed this in Functions
// noImplicitThis: false
const handler = {
name: 'Alice',
greet() {
setTimeout(function() {
console.log(this.name); // 'this' is implicitly 'any' — runtime: undefined
}, 100);
}
};
// noImplicitThis: true — TypeScript errors on implicit 'this'
// Fix: use arrow functions (they capture 'this' lexically)
const handler = {
name: 'Alice',
greet() {
setTimeout(() => {
console.log(this.name); // OK — arrow function, 'this' is the outer handler object
}, 100);
}
};
// Fix: explicitly declare 'this' parameter type
function logContextName(this: { name: string }): void {
console.log(this.name); // OK — 'this' is typed
}
The most common source of implicit this errors is passing class methods as callbacks — the method loses its binding when detached from its instance, causing undefined access at runtime. The definitive fix is arrow function class fields instead of method declarations, since arrow fields capture this lexically at instance creation time and cannot be detached. Explicitly declaring this as the first function parameter is the correct approach for standalone utility functions designed to be used with .call() or .apply(), making the required calling context part of the function's public API contract rather than implicit documentation.
Safe Migration Strategy for Existing Projects
Enabling "strict": true on a large existing project can produce hundreds or thousands of errors at once. Here is a proven incremental approach:
Phase 1: Enable strictNullChecks first
// tsconfig.json — Phase 1
{
"compilerOptions": {
"strictNullChecks": true // Start with the highest-value flag
// noImplicitAny, strict, etc. NOT yet enabled
}
}
Fix all nullability errors. Patterns to use:
- Add
| null | undefinedto types that genuinely can be absent. - Use optional chaining (
?.) and nullish coalescing (??) to handle nulls gracefully. - Use type guards (
if (x !== null)) to narrow types.
Phase 2: Enable noImplicitAny
// tsconfig.json — Phase 2
{
"compilerOptions": {
"strictNullChecks": true,
"noImplicitAny": true
}
}
Fix all parameters and variables that were implicitly any. Use // @ts-expect-error sparingly for third-party code you cannot yet type.
Phase 3: Enable full strict
// tsconfig.json — Phase 3
{
"compilerOptions": {
"strict": true // Enables all remaining strict flags
}
}
File-by-File Migration with ts-migrate
For very large codebases, the ts-migrate tool (by Airbnb) can automatically add // @ts-expect-error annotations to suppress errors, letting you commit strict mode as enabled and then fix errors gradually over time:
# Install ts-migrate
npm install -g @ts-morph/ts-migrate
# Migrate all files — adds @ts-expect-error to suppress existing errors
ts-migrate migrate ./src
# Now enable strict: true in tsconfig.json
# CI passes immediately; fix suppressed errors over time
Additional Strict-Like Flags Worth Enabling
Beyond the eight flags included in "strict": true, these additional flags are worth enabling in any serious TypeScript project:
| Flag | What it catches |
|---|---|
| noUncheckedIndexedAccess | Array/object index access returns T | undefined, not T |
| noImplicitReturns | Function branches that don't return a value |
| noFallthroughCasesInSwitch | Switch cases without break/return |
| exactOptionalPropertyTypes | Prevents assigning undefined to optional props explicitly |
| noPropertyAccessFromIndexSignature | Forces bracket notation for index signatures |
| forceConsistentCasingInFileNames | Prevents case-insensitive import bugs on macOS/Windows |
The most impactful of these additional flags is noUncheckedIndexedAccess, which adds | undefined to every array index and object index signature access — without it, const first = items[0] is typed as Item rather than Item | undefined, leaving empty-array crashes undetected by the compiler. Enable this flag after resolving all core strict errors, and use items.at(0) with a null guard, or .find() with appropriate narrowing, to safely access potentially absent elements. The exactOptionalPropertyTypes flag is worth enabling on new projects: it distinguishes between a property being absent (key not in the object) and explicitly set to undefined, which matters for APIs that treat these two states differently at runtime.
Angular and NestJS specific considerations
Both Angular and NestJS already scaffold projects with "strict": true enabled by default. If you are working on an older Angular project that was created before this was the default:
// Angular migration — update tsconfig.json
{
"compilerOptions": {
"strict": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitOverride": true, // Angular 14+ enforces override keyword
"noImplicitReturns": true
}
}
// Common Angular strict-mode fixes:
// 1. Template expressions are also strict — use the safe navigation operator
// In templates: {{ user?.profile?.name }}
// 2. @Input() properties need definite assignment or ?
@Component({...})
class UserCardComponent {
@Input() user!: User; // '!' — guaranteed by Angular's input binding
@Input() title?: string; // Optional input — correct without '!'
}
// 3. Typed reactive forms (Angular 14+)
const form = new FormGroup({
email: new FormControl('', { nonNullable: true, validators: [Validators.required] }),
age: new FormControl<number | null>(null),
});
// form.value.email is now 'string', not 'string | null | undefined'
Angular's template type checker respects strictNullChecks and will flag template expressions that may be null or undefined — use the safe navigation operator (?.) and the async pipe's null coalescing pattern to keep templates free of strict errors without resorting to non-null assertions. NestJS with strict mode requires explicit typing for all request handler parameters: @Body(), @Param(), and @Query() benefit from DTO classes validated with class-validator, which aligns perfectly with strict mode's requirement to eliminate implicit any from request-handling code. Both frameworks recommend adding noImplicitOverride alongside strict mode to ensure that overriding lifecycle methods and abstract class members is always marked with the override keyword, preventing accidental interface drift as parent classes evolve.
Common Patterns for Handling null Safely
// 1. Null object pattern — eliminate null from a domain type
const GUEST_USER: User = { id: '', name: 'Guest', email: '', roles: [] };
function getUser(id: string | null): User {
if (!id) return GUEST_USER;
return userCache.get(id) ?? GUEST_USER;
}
// 2. Option type — explicit absence
type Option<T> = { some: true; value: T } | { some: false };
function findById(id: string): Option<User> {
const user = db.find(id);
if (!user) return { some: false };
return { some: true, value: user };
}
const result = findById('123');
if (result.some) {
console.log(result.value.name); // TypeScript knows 'value' exists here
}
// 3. Non-null assertion — use sparingly
function connectToDb(): DatabaseConnection {
const conn = createConnection(config);
if (!conn) throw new Error('Failed to connect');
return conn!; // OK here — we just checked and threw
}
// 4. Type guard functions
function isNonNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}
const ids = ['1', null, '2', undefined, '3'];
const validIds = ids.filter(isNonNull); // string[] — nulls removed
The null object pattern is the most ergonomic choice for domain models with a meaningful "absent" representation, since it eliminates defensive null checks at every call site and prevents null pointer exceptions from propagating through the call stack. The custom Option<T> discriminated union makes absence structurally visible to callers and is particularly useful at service boundaries where you want to prevent callers from accidentally bypassing the null check — the compiler enforces the branch before value is accessible. The isNonNull type guard approach is idiomatic in array pipelines: combining it with .filter() narrows the element type to non-nullable without requiring a manual type assertion, keeping the code both correct and readable.
Conclusion
TypeScript strict mode is not optional for production-grade code — it is the minimum bar. The eight strict flags together eliminate null pointer exceptions, accidental any propagation, unsafe function type assignments, and uninitialized class members. The upfront cost of fixing strict errors is a one-time investment; the ongoing benefit is a codebase where the type system is a genuine safety net rather than a documentation layer.
Start with strictNullChecks, then add noImplicitAny, and finally enable the full "strict": true. For new Angular and NestJS projects, strict mode is already the default — there is no reason not to use it. To go further with TypeScript, see the companion post on TypeScript Advanced Types: Generics, Conditional Types & Mapped Types.
Leave a Comment
Related Posts
Software Engineer · TypeScript · Angular · Spring Boot