Strict mode

Strict mode exposes unsafe assumptions about nulls, callbacks, class fields, iterators, and untyped code before JavaScript runs.

level intermediate time 9 min at Standard depth
version TypeScript 6
what

TypeScript’s strict setting is a family of compiler checks that rejects assignments, calls, and property reads that cannot be shown to be safe.

trap

strict: true does not validate JSON or include every hardening option, such as noUncheckedIndexedAccess; assertions can also bypass its findings.

fix

Enable strict in the project config, accept external data as unknown, and resolve each error with narrowing, initialization, and tests.

What it is and why it exists

TypeScript strict mode is not a runtime mode. It is a related set of static checks. With strict: true, the compiler takes a more conservative approach to nullish values, function parameters, class fields, this, catch variables, and places where it cannot infer a type.

TypeScript has to accommodate a large amount of existing JavaScript, so projects can add type constraints gradually. A permissive configuration lets old code compile first, but it also accepts assumptions that have not been proved. A lookup might have no result, a callback might accept only a narrower input, and a class field might still be uninitialized when read.

Strict mode turns those assumptions into compiler errors so you can fix them before the code runs. You meet it when creating a tsconfig.json, taking over an older project, upgrading the compiler, or adding a boundary around a library with weak declarations. It does not require annotations on every local variable; code stays concise when inference has enough information.

These guarantees end at compilation. TypeScript performs type erasure , so interfaces, unions, and most checks do not remain in the emitted JavaScript. Strict mode constrains source code that the compiler sees, but it cannot prove that network responses, file contents, or JavaScript callers match their declarations.

How it works

strict is an umbrella switch. TypeScript 6 enables nine related options through it, though you can still set an individual child option to false in the same configuration. That exception does not disable the rest of the family.

tsconfig.json
{
  "compilerOptions": {
    "strict": true
  }
}

These are the strict options in TypeScript 6. A future version may add stricter checking under strict, so a compiler upgrade can produce new diagnostics.

OptionWhat changes when enabled
noImplicitAnyReports an implicit any produced when the compiler cannot infer a type.
noImplicitThisReports a this value that can only be given the type any.
strictNullChecksTreats null and undefined as distinct types that must be handled explicitly.
strictFunctionTypesChecks parameter and return compatibility more safely when function types are assigned.
strictBindCallApplyChecks arguments to bind, call, and apply against the original function signature.
strictPropertyInitializationRequires instance fields to be initialized at their declaration or along constructor paths.
strictBuiltinIteratorReturnGives built-in iterators a completion return type of undefined instead of any.
useUnknownInCatchVariablesTypes an unannotated catch variable as unknown, requiring narrowing before use.
alwaysStrictParses source as ECMAScript strict mode and emits "use strict" where needed.

These options change the evidence the compiler requires. strictNullChecks exposes the undefined in a lookup function’s return type; an existence check then triggers control-flow narrowing . strictPropertyInitialization analyzes constructor paths instead of waiting for an object to fail on its first use.

noImplicitAny forbids only an any that the compiler would silently infer. It does not ban an explicit any. Likewise, useUnknownInCatchVariables protects the default catch variable, but catch (error: any) still opts out. Strict mode raises the default threshold; it cannot prevent code from explicitly bypassing that threshold.

Examples

All four programs below were type-checked with TypeScript 6.0.3 and strict: true, then run through tsx on Node 24. They cover nulls, callback parameters, runtime boundaries, and built-in iterators in that order.

Narrow a nullable lookup result

Array find() can return undefined. Strict null checking preserves that possibility in the type, so the missing branch must be handled before account properties are read.

nullable-user.ts
type Account = { id: number; displayName: string | null };

const accounts: Account[] = [
  { id: 1, displayName: "Ada" },
  { id: 2, displayName: null },
];

function findAccount(id: number): Account | undefined {
  return accounts.find((account) => account.id === id);
}

function labelFor(id: number): string {
  const account = findAccount(id);
  if (account === undefined) return "missing";
  return account.displayName ?? `account-${account.id}`;
}

console.log(labelFor(1));
console.log(labelFor(2));
console.log(labelFor(3));
Ada
account-2
missing

After account === undefined, the compiler knows that account is an Account on the remaining path. displayName can still be null, so nullish coalescing supplies a fallback label that agrees with the data model.

There is no non-null assertion here. Writing findAccount(id)!.displayName would remove the diagnostic but restore a runtime failure when the ID is absent.

Keep callback parameters safe

Function assignment has to account for every value the caller may pass. A function that accepts any Animal can occupy a position that receives only Dog values. A dog-only function cannot pretend to be the general handler.

callback-variance.ts
interface Animal {
  name: string;
}

interface Dog extends Animal {
  bark(): string;
}

type AnimalHandler = (animal: Animal) => string;
type DogHandler = (dog: Dog) => string;

const describeAnimal: AnimalHandler = (animal) => `Checking ${animal.name}`;
const describeDog: DogHandler = (dog) => `${dog.name} says ${dog.bark()}`;

const handleDog: DogHandler = describeAnimal;

// @ts-expect-error A dog-only handler cannot safely handle every Animal.
const handleEveryAnimal: AnimalHandler = describeDog;

const pixel: Dog = { name: "Pixel", bark: () => "woof" };
console.log(handleDog(pixel));
console.log(describeDog(pixel));
Checking Pixel
Pixel says woof

@ts-expect-error is a compile-time test here: if the unsafe assignment stops producing a diagnostic, the directive itself becomes an error. The sample does not call the rejected assignment, so the output comes from the two safe calls only.

This rule is function parameter variance . Its direction is easy to reverse in your head. The deciding fact is not the animal named in the function, but which values the receiving caller may later pass.

Validate a boundary and initialize fields

Strict checking cannot validate JSON, but it can make boundary code preserve uncertainty. This example assigns the parsed result to unknown, checks its object shape and domain constraint, then creates an instance whose fields are fully initialized.

retry-policy.ts
class RetryPolicy {
  constructor(
    readonly endpoint: string,
    readonly maxAttempts: number,
  ) {}
}

function parsePolicy(text: string): RetryPolicy {
  const value: unknown = JSON.parse(text);
  if (
    typeof value !== "object" ||
    value === null ||
    !("endpoint" in value) ||
    !("maxAttempts" in value) ||
    typeof value.endpoint !== "string" ||
    typeof value.maxAttempts !== "number" ||
    !Number.isInteger(value.maxAttempts) ||
    value.maxAttempts < 1
  ) {
    throw new Error("Invalid retry policy");
  }
  return new RetryPolicy(value.endpoint, value.maxAttempts);
}

for (const raw of [
  '{"endpoint":"/v1/retry","maxAttempts":3}',
  '{"endpoint":"/v1/retry","maxAttempts":0}',
]) {
  try {
    const policy = parsePolicy(raw);
    console.log(`policy=${policy.endpoint}:${policy.maxAttempts}`);
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);
    console.log(`error=${message}`);
  }
}
policy=/v1/retry:3
error=Invalid retry policy

The constructor parameter properties are assigned before the object is returned, so strictPropertyInitialization can prove that both fields exist. There is no need for a definite assignment assertion or a partly initialized instance that callers could read too early.

The catch variable is unknown because JavaScript can throw a string, an object, or null, not only an Error. The instanceof Error branch preserves a standard error message, while the fallback safely converts another value to text.

Check whether an iterator is done

A built-in iterator’s next() returns both a completion marker and a value. strictBuiltinIteratorReturn stops the completion value from entering the program as any, so code checks done before reading it.

iterator-result.ts
function printJobs(jobs: Set<string>): void {
  const iterator = jobs.values();

  while (true) {
    const next = iterator.next();
    if (next.done) break;
    console.log(next.value.toUpperCase());
  }
}

printJobs(new Set(["compile", "test"]));
console.log("done");
COMPILE
TEST
done

After the next.done check, TypeScript knows that next.value is a string in the unfinished branch. Calling jobs.values().next().value.toUpperCase() directly would ignore the empty-set case and expose a possible undefined under strict checking.

This diagnostic often appears after a compiler upgrade because older versions gave built-in iterator completion values the default type any. The repair is to follow the iterator protocol, not to append ! to value.

Pitfalls

Fix: accept untrusted values as unknown, check the container type, required fields, and domain constraints, then construct the domain object. A type assertion can record a proof already made; it cannot replace that proof.

Fix: repair the source of each error by typing the boundary, narrowing a union, covering a missing branch, or initializing a field in the constructor. If an escape hatch is necessary, confine it to a small adapter and document the runtime condition it relies on.

Fix: evaluate noUncheckedIndexedAccess, exactOptionalPropertyTypes, and noImplicitOverride against the project’s contracts. They impose real migration costs, so enable them individually and confirm behavior with tests instead of describing them as parts of strict.

Fix: run an explicit project command in continuous integration, such as tsc -p tsconfig.json --noEmit, and inspect the final result from tsc --showConfig -p tsconfig.json. When the config uses extends, look for downstream overrides of individual strict options.

Fix: express callback contracts as function properties, such as handle: (event: Event) => void, so strict function checking applies. Values coming from third-party methods still need tests over their real input range; one structural assignment is not enough.

Deep Where strict mode stops

Where strict mode stops

Strict mode supplies useful default checks, but it does not prove a program correct. You need to know what it leaves uncovered before deciding what a clean build actually proves.

The umbrella is not maximum strength

The options under strict can evolve between TypeScript versions. TypeScript 6 includes strictBuiltinIteratorReturn, so an upgrade from an earlier compiler may report new errors even when tsconfig.json did not change. That is a result of the umbrella’s design, not the compiler ignoring the config.

Projects often choose three additional hardening options outside strict. They are not an unconditional best configuration. Each one closes a gap for a different kind of contract.

tsconfig.hardened.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}

noUncheckedIndexedAccess adds a possible undefined to undeclared indexed results. exactOptionalPropertyTypes distinguishes a missing property from an explicit undefined write unless the property type allows the latter. noImplicitOverride requires override when replacing a base member, exposing accidental new members caused by spelling mistakes or refactors.

These options touch different code shapes, so assess them separately. Keep tests that exercise the new boundaries after enabling them. Otherwise it is easy to restore the old behavior with assertions and lose the point of the option.

Function properties and the method exception

Strict function checking primarily governs assignments of function types. In parameter position, a function that accepts a wider type can replace one accepting a narrower type because callers supply values from that narrow set. The opposite replacement might receive an object it cannot handle.

TypeScript preserves a compatibility exception for method syntax. In an interface, compare(a: T, b: T): number is a method, while compare: (a: T, b: T) => number is a function property; the latter gets strict parameter checking. The difference often hides in library declarations, event interfaces, and types rewritten by generators.

The exception does not mean a method call will fail. It means this option does not reject some unsafe assignments. Function properties express a stronger checking intent in a new callback API; when implementing an existing method interface, cover the static gap with wider-input tests.

Effective values after config inheritance

Real projects often compose configurations with extends. A base config can enable strict while a downstream config disables one child option. Reviewing only the base file then gives the wrong picture of the effective checks.

tsconfig.base.json
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true
  }
}
tsconfig.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "strictNullChecks": false
  }
}

The downstream strictNullChecks: false overrides the umbrella’s default for that option, while the other strict children remain enabled. Such an exception can help a migration, but it can also remain indefinitely and leave a team believing that null checking is complete.

tsc --showConfig -p tsconfig.json prints the resolved configuration and file set, making it useful for confirming inheritance, defaults, and targets. It shows what the compiler actually read and is more reliable than mentally merging several JSON files.

Continuous integration should use the same -p entry point. An editor may choose a nearer config, and a build tool may pass overrides. A single command ensures that green editor diagnostics and the publication gate refer to the same checks.

Checking gaps still exist

Explicit any is an escape hatch that strict mode leaves open. Once a value becomes any, property access, calls, and assignments bypass most proof, and the unsafe type can propagate through return values. One any at a boundary is often harder to detect than an error inside a function.

Escape hatches are easier to review when classified by the proof they skip:

FormCheck skippedEvidence to add
Explicit anyMost later type operationsBoundary validation and propagation scope
value as TargetThis assignability decisionRuntime check before the assertion
@ts-expect-errorKnown diagnostic on the next lineExpected error and removal condition
skipLibCheckChecks between declaration filesDependency version and integration tests

A type assertion can also cross an assignability boundary. It is appropriate when the compiler cannot derive a fact that the program has already checked at runtime. Without that check, it merely moves risk from the diagnostic list to an execution path.

@ts-expect-error and @ts-ignore suppress a diagnostic on the following line, while skipLibCheck skips type checking between declaration files. Their scopes differ. They do not substitute for one another or prove that skipped declarations match the runtime library.

A clean build needs an inventory of escape hatches before it means much. A review can require each suppression to name the expected diagnostic, external dependency, and removal condition, with a test covering the path the compiler no longer checks.

Two strict modes are not the same

alwaysStrict concerns ECMAScript runtime strict semantics, such as rejecting certain silent failures and legacy syntax. TypeScript’s strict umbrella includes it, but the other options on this page mostly change static type checking.

Do not infer whether type checking is active from the presence of "use strict" in output. Module targets, emit behavior, and source-file form affect whether the directive needs to be written. The effective compiler config decides whether checks such as strictNullChecks apply.

The converse is also true. JavaScript running in ECMAScript strict mode has not necessarily passed TypeScript strict checking. The mechanisms have similar names but operate at different stages and make different guarantees.

Preserve boundaries during gradual migration

When an older project cannot fix every strict error at once, multiple project configurations or package boundaries can stage the work. Each migration unit should be independently checkable, and a newly strict area should not spread any back through untyped exports.

Temporary suppressions should be searchable, explained, and tied to a removal condition. @ts-expect-error is more suitable than @ts-ignore for a known diagnostic because it fails when the error disappears. It should still sit beside one specific compatibility claim rather than cover a large section of business logic.

Fixing public inputs, outputs, and data boundaries first usually reduces downstream errors. When many leaf functions fail, the root cause is often an upstream parameter or library declaration that has already become any. Adding assertions to each leaf hides that propagation path.

Migration is complete when the intended config is effective, escape hatches have clear scope, and runtime tests cover important boundaries. A zero error count alone is not enough. The progressive-migration topic owns the detailed rollout; strict mode defines the target checking baseline here.

Further reading

checkpoint

4 questions · 1 predict-the-output · 1 spot-the-bug

before this Basic types
next up Tsconfig soon Type narrowing Type coverage Progressive migration soon
Copy as Markdown Interview bank Edit on GitHub Report an error Was this clear?