TypeScript Strict Mode: The Settings I Enable for Production Projects
A practical TypeScript strict-mode configuration for production projects, with the bugs each compiler setting catches, trade-offs, and a safe adoption plan.
For a new TypeScript application, I want the compiler to be mildly inconvenient before production rather than the system to be surprisingly inconvenient afterward.
That does not mean enabling every option with a reassuring name. Some compiler settings protect real runtime assumptions. Others enforce style, depend on the build tool, or create noise better handled by a linter. A useful production configuration separates those concerns.
My baseline begins with strict: true, then adds checks that are deliberately outside the strict family. The extra settings target four common sources of defects: unchecked lookups, ambiguous optional properties, accidental control flow, and assumptions hidden in imports or inheritance.
This article explains the configuration, the failures it catches, and the settings I choose case by case. The examples and configuration were verified with TypeScript 7.0.2.
One terminology warning first: TypeScript's strict compiler option is a group of type-checking rules. It is related to, but not the same thing as, JavaScript's runtime strict mode and its "use strict" directive.
The production baseline
This is the type-checking baseline I use for a modern application:
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"forceConsistentCasingInFileNames": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
}
This is not a complete tsconfig.json for every runtime. A Node.js service, a React application built with Vite, and a package published to npm need different target, lib, module, moduleResolution, declaration, and emit settings. Those choices should match the runtime and build pipeline rather than be copied from a generic article.
noEmit: true also assumes another tool emits the application or no JavaScript output is required from this configuration. If tsc owns production emit, I remove noEmit and enable noEmitOnError so a failed type check cannot produce a releasable build.
TypeScript 7 defaults both strict and noUncheckedSideEffectImports to true. I still write them down. An explicit option documents the project's contract, remains clear in an inherited configuration, and makes the intent visible to somebody reading the repository. The TypeScript 7 release notes describe those new defaults and the other configuration changes in the release.
Start with strict
The strict option enables a family of checks that TypeScript considers its stronger correctness mode. In TypeScript 7.0, that family covers:
noImplicitAnynoImplicitThisstrictNullChecksstrictFunctionTypesstrictBindCallApplystrictPropertyInitializationstrictBuiltinIteratorReturnuseUnknownInCatchVariablesalwaysStrict
I prefer the umbrella option to spelling out the current list. The TypeScript team can add a new check to strict in a future release. That may produce new errors during an upgrade, but I want that review: a compiler upgrade should be a deliberate dependency change, not an invisible background event.
The highest-value member for most applications is strictNullChecks. Without it, null and undefined are accepted in places that claim to contain a concrete value:
type User = {
id: string
email: string
}
function findUser(users: User[], id: string) {
return users.find((user) => user.id === id)
}
const user = findUser(users, requestedId)
sendEmail(user.email)
Array.prototype.find can return undefined. With strict null checking, user.email is an error until the missing-user case is handled:
const user = findUser(users, requestedId)
if (!user) {
throw new UserNotFoundError(requestedId)
}
await sendEmail(user.email)
The type error forces a product decision. Should absence return a 404, skip work, create a record, or fail the job? The compiler cannot choose, but it can stop the code from pretending the case is impossible.
noImplicitAny provides a similar boundary. Inferred any is contagious: once a value becomes any, property access, calls, and assignments around it stop being meaningfully checked. An explicit any may still be necessary at a difficult integration, but the escape hatch should be visible in review.
useUnknownInCatchVariables makes caught values honest. JavaScript permits throwing any value, not only Error instances:
try {
await chargeCustomer(order)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.error({ error, orderId: order.id }, message)
}
The narrowing is a small cost. It prevents an error handler, often the code running during failure, from failing again because it assumed error.message exists.
strictPropertyInitialization is particularly valuable in class-heavy code. If a property claims to be a DatabaseClient, a constructor must establish it before another method can use it. I treat the definite-assignment assertion in client!: DatabaseClient as a claim requiring evidence, not as the standard fix for compiler friction.
Make indexed access honest
strict does not include noUncheckedIndexedAccess. I add it in every production application unless a migration constraint temporarily prevents it.
Without the option, an array or index signature can make an unsafe promise:
const pricesBySku: Record<string, number> = {
starter: 1900,
}
const price = pricesBySku[requestedSku]
price.toFixed(2)
The type of price is number without noUncheckedIndexedAccess, even though an arbitrary key may be absent. With the option enabled, the type becomes number | undefined and the call must handle that possibility.
Arrays become more honest too:
const firstRecipient = recipients[0]
if (!firstRecipient) {
throw new Error('At least one recipient is required')
}
await sendNotification(firstRecipient)
This check initially creates many errors in code that indexes arrays inside apparently safe loops or accesses a key immediately after checking another condition. The right response depends on the invariant:
- Narrow the value when absence is expected.
- Change the API to accept a non-empty tuple such as
readonly [Recipient, ...Recipient[]]when at least one value is a real domain requirement. - Use iteration instead of manual indexes when the index has no meaning.
- Use
Map.get, which already communicates possible absence. - Add a non-null assertion only when the relationship is locally obvious and cannot be expressed more clearly.
The noUncheckedIndexedAccess documentation describes the rule as adding undefined to fields reached through an undeclared key. That small union exposes a large class of bad lookup assumptions.
Distinguish missing from undefined
An optional property is not always the same as a present property whose value is undefined.
Consider an update command:
type UpdateProfile = {
displayName?: string
}
For a patch operation, I may want these meanings:
{}means leave the display name unchanged.{ displayName: 'Alvi' }means replace it.{ displayName: undefined }is invalid.
Without exactOptionalPropertyTypes, TypeScript permits the third form. With the option enabled, displayName?: string means the property can be absent, but when present it must contain a string. If explicit clearing is valid, the type should say so:
type UpdateProfile = {
displayName?: string | null
}
This distinction affects more than aesthetic precision. The in operator, Object.keys, object spreading, serialization, and some persistence adapters can observe the difference between a missing key and a key containing undefined. The official option reference uses this same runtime difference to explain why the setting exists.
The option will expose friction in DTOs and React props that casually pass undefined. I do not fix those errors by adding | undefined everywhere. I first decide whether presence has meaning. If it does not, allowing explicit undefined may be accurate. If it does, the call site should omit the property:
const update: UpdateProfile = {
...(nextName === undefined ? {} : { displayName: nextName }),
}
This is one of the more disruptive flags to enable in a mature codebase, but it pays for itself at API boundaries where omission and clearing have different behavior.
Make inheritance and dynamic keys explicit
noImplicitOverride and noPropertyAccessFromIndexSignature are smaller checks, but both reveal intent that ordinary syntax can hide.
Require override for inherited members
Suppose a base class renames handle to execute, but a subclass still contains a method named handle. Is that now a harmless unused method, a broken override, or a separate public API? The compiler cannot know the original intent after the rename.
With noImplicitOverride, a subclass must mark an override:
class Job {
execute(): Promise<void> {
return Promise.resolve()
}
}
class ImportCustomersJob extends Job {
override async execute(): Promise<void> {
// Import customers.
}
}
If the base member disappears or changes incompatibly, the override declaration becomes an error. The TypeScript documentation explains that this prevents subclasses from silently falling out of sync with their base classes.
Inheritance is not my default design tool, but when a framework or domain model uses it, the additional word is cheap evidence.
Show when a key is dynamic
An index signature describes keys that are not known in advance:
type Environment = {
APP_ENV: 'development' | 'test' | 'production'
[name: string]: string | undefined
}
declare const environment: Environment
environment.APP_ENV refers to a declared property. environment.DATABASE_URL is merely accepted by the string index signature. With noPropertyAccessFromIndexSignature, the latter must be written as environment['DATABASE_URL'].
Bracket access is not safer by itself. Its value is that the syntax admits the key is dynamic. Combined with noUncheckedIndexedAccess, it also produces string | undefined, so startup code must validate the setting before using it.
If a key is genuinely required, I declare it. If many keys are genuinely dynamic, I keep the index signature and bracket syntax. If the type becomes awkward, that often indicates one object is mixing a fixed configuration contract with an open-ended bag of values.
Catch incomplete control flow
I enable noImplicitReturns and noFallthroughCasesInSwitch because both catch code that looks complete during review.
An omitted return is easy to miss:
function statusCode(result: PaymentResult) {
if (result.ok) {
return 200
}
if (result.reason === 'declined') {
return 422
}
// A return for the remaining failure case is missing.
}
noImplicitReturns reports that not every code path returns. It does not prove that the returned value is correct, but it prevents accidental undefined from becoming an undocumented outcome.
For closed unions, I go further and make exhaustiveness visible:
type JobState =
| { kind: 'queued' }
| { kind: 'running'; startedAt: Date }
| { kind: 'failed'; reason: string }
function stateLabel(state: JobState): string {
switch (state.kind) {
case 'queued':
return 'Waiting'
case 'running':
return `Running since ${state.startedAt.toISOString()}`
case 'failed':
return `Failed: ${state.reason}`
default: {
const unhandled: never = state
return unhandled
}
}
}
Adding another union member now breaks the function at compile time. This pattern is stronger than relying on noImplicitReturns alone.
noFallthroughCasesInSwitch catches a non-empty case that reaches the next case without break, return, or throw. Intentional fallthrough exists, but it is rare in the application code I work on. I prefer to combine cases explicitly when they share behavior:
switch (role) {
case 'owner':
case 'admin':
return fullAccess
case 'member':
return standardAccess
}
I also set allowUnreachableCode and allowUnusedLabels to false. Their default editor suggestions are easy to overlook in CI. Unreachable code can indicate a botched merge or a stale branch, while an unused label is often an object literal written where a return was intended.
Check side-effect imports and file casing
Some production failures begin before a value receives a type.
Do not silently ignore a missing side-effect import
A side-effect import loads a module for what it does during evaluation:
import './register-observability'
Historically, TypeScript could silently ignore an unresolved side-effect import. With noUncheckedSideEffectImports, a misspelling becomes a compiler error rather than missing telemetry, a missing polyfill, or an unregistered handler at runtime. This check is enabled by default in TypeScript 7, but I keep it explicit.
Frontend projects frequently import CSS or other assets that TypeScript does not resolve itself. The option documentation suggests an ambient module declaration when necessary:
declare module '*.css' {}
That declaration tells TypeScript the category is valid; it does not prove a particular CSS file exists. The bundler and its tests still own that guarantee. I use the narrowest declarations supported by the toolchain instead of adding a wildcard for every unknown extension.
Make casing portable
forceConsistentCasingInFileNames catches imports whose letter casing disagrees with the file on disk. A mismatch may work on a case-insensitive development filesystem and fail in a case-sensitive Linux container.
This option is less about types than reproducible builds. I enable it because the compiler already has the information and the alternative is discovering the mismatch during deployment.
Settings I do not enable blindly
Maximum strictness is not the same as switching every boolean to true. These settings need a decision tied to the repository.
noUnusedLocals and noUnusedParameters
Unused code should be removed, but I usually let ESLint own that policy. A linter can distinguish intentionally unused callback parameters, apply naming conventions, and support local suppression with a clearer reason. Compiler errors can be too blunt for framework signatures, staged refactors, and public library declarations.
For a small application without a type-aware lint step, enabling both is reasonable. I just avoid having two tools report the same rule differently.
skipLibCheck
I leave skipLibCheck at its default of false for a new project. Setting it to true skips checking declaration files, which can shorten builds and unblock incompatible third-party types, but it also reduces the checking performed across those declarations.
If declaration checking is a measured CI bottleneck or an upgrade is blocked by upstream definitions, I may enable it as a documented trade-off. I first look for duplicate versions of a type package, a dependency upgrade, or a targeted patch. The TypeScript reference for skipLibCheck recommends resolving duplicate library types through dependency resolution when possible.
isolatedModules and verbatimModuleSyntax
These options can be excellent, but they belong to the build architecture rather than a universal strictness baseline. isolatedModules warns about code that cannot be transformed safely one file at a time. That matters when Babel, SWC, esbuild, or another single-file transformer emits JavaScript.
verbatimModuleSyntax makes type-only imports and emitted imports more explicit. Whether it fits depends on the module format and emit pipeline. I enable these when the chosen framework or compiler model calls for them, not to make a generic checklist longer.
noEmit, noEmitOnError, declarations, and source maps
These settings describe who builds the program and what artifacts it must produce. A browser application whose bundler emits JavaScript often uses noEmit: true. A Node.js service compiled by tsc needs emit settings and should use noEmitOnError: true. A library may also require declaration, declarationMap, and a carefully designed public export surface.
The type-safety baseline can be shared. The emit configuration should not be.
What strict mode cannot prove
Strict TypeScript improves the consistency of the program's static model. It does not prove that reality matches that model.
This code compiles and remains unsafe:
type WebhookEvent = {
id: string
type: 'invoice.paid'
}
export async function handleWebhook(request: Request) {
const body = (await request.json()) as WebhookEvent
await processPaidInvoice(body.id)
}
The assertion does not validate the HTTP body. It tells the compiler to trust the author. A malformed request, a changed provider payload, or an attacker can still supply a different shape.
Untrusted data should enter as unknown and earn a domain type through runtime validation:
function isWebhookEvent(value: unknown): value is WebhookEvent {
if (typeof value !== 'object' || value === null) return false
const candidate = value as Record<string, unknown>
return candidate.type === 'invoice.paid' && typeof candidate.id === 'string'
}
export async function handleWebhook(request: Request) {
const body: unknown = await request.json()
if (!isWebhookEvent(body)) {
return new Response('Invalid webhook', { status: 400 })
}
await processPaidInvoice(body.id)
return new Response(null, { status: 204 })
}
A schema library is often more maintainable for large payloads, but the architectural rule is the same: validate at HTTP, queue, environment, file, database, and third-party boundaries where static trust begins.
Strict mode also cannot prove authorization, transaction isolation, idempotency, request ordering, capacity, or business correctness. It will accept a perfectly typed function that charges the same order twice. Types are one layer in a production system, alongside tests, constraints, runtime validation, observability, and operational limits.
Finally, explicit any, type assertions, @ts-ignore, and non-null assertions can bypass much of the protection. I do not ban every escape hatch. I make each one small, local, and explainable in review.
Adopt strictness without stopping delivery
Turning on every check in a mature codebase can produce thousands of errors. That is a migration project, not a reason to leave the checks disabled forever.
I use this sequence:
- Pin the TypeScript version and run
tsc --noEmitin CI so editor versions do not define the actual contract. - Record error counts by compiler option and area of the codebase. Generated code, tests, application code, and legacy integrations may need different tactics.
- Enable
strict: truein the shared configuration, temporarily overriding only the specific checks that cannot yet pass. - Remove those overrides one at a time. I usually prioritise
strictNullChecksandnoImplicitAny, then addnoUncheckedIndexedAccessandexactOptionalPropertyTypesafter the main domain model is honest. - Fix uncertainty at its source. Validate external values, model absence, initialise state, and narrow unions instead of distributing
as,!, andanyacross call sites. - Migrate by package or directory when project references or repository boundaries make that practical.
- Prevent regressions immediately. A migrated package should not be allowed to opt back out because another package is still in progress.
A temporary override should have an owner and a removal condition:
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"noImplicitAny": false
}
}
This configuration says something precise: the project accepts the strict family, has already committed to null safety, and still has an explicit-typing migration to finish. It is more useful than setting strict: false, which hides which guarantees are missing.
Avoid measuring progress only by the number of errors silenced. Replacing 500 errors with 500 assertions changes the dashboard without improving the model. Review a representative sample of fixes, track newly introduced escape hatches, and test the runtime boundaries uncovered by the migration.
The CI contract
Strictness matters only when the authoritative build enforces it.
For an application emitted by another tool, I use a dedicated command:
{
"scripts": {
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}
Then CI runs the same command on every pull request. I also keep the compiler in devDependencies and commit the lockfile. A globally installed tsc should not decide whether production code is valid.
In a monorepo, each deployable package should be checked with the configuration that represents its runtime. A root configuration can hold the shared safety policy, while package configurations own their libraries, globals, modules, and emit. The important relationship is simple:
shared safety rules
|
+-- browser application: DOM libraries + bundler module settings
+-- Node.js service: Node libraries + runtime module settings
+-- published package: declaration emit + public API checks
Run the type check independently from tests. A transpiler may happily emit code while ignoring type errors, and a passing test suite covers only the cases it executes. Both signals are necessary and neither replaces the other.
I also treat a TypeScript upgrade as a normal dependency change. The strict documentation warns that future versions may add checks under the umbrella. Upgrade in a focused pull request, read the release notes, run the full type check, and distinguish new useful diagnostics from toolchain compatibility problems.
The goal is not a config that never changes. It is a visible, enforceable agreement about which assumptions the compiler must challenge before code reaches production.
Frequently asked questions
Does strict: true enable every strict TypeScript setting?
No. It enables the compiler's named strict family, but important checks such as noUncheckedIndexedAccess, exactOptionalPropertyTypes, noImplicitOverride, noImplicitReturns, and noFallthroughCasesInSwitch remain separate. TypeScript 7 enables some additional options by default, but I declare the production policy explicitly.
Should I enable strict in a small project?
Yes. A small project is the cheapest time to establish the contract. Strictness is not reserved for large codebases; it becomes harder to adopt only after loose assumptions have spread.
Is noUncheckedIndexedAccess too noisy?
It is noisy when the codebase frequently assumes arrays are non-empty or arbitrary keys exist. Those assumptions are exactly what the option exposes. Improve the domain types or narrow the lookup result. Use a non-null assertion only for a local invariant that is genuinely obvious and stable.
Does strict TypeScript remove the need for runtime validation?
No. Type information is erased from the emitted JavaScript, and external input does not become valid because it is asserted to a type. Validate untrusted values before converting them into domain types.
Should a library use the same configuration as an application?
The safety baseline can be the same, and exactOptionalPropertyTypes is especially useful when designing public object shapes. A library also needs declaration and module settings that an application may not need. Test the published declarations with realistic consumers, not only the library's source build.
Can I enable strict mode gradually?
Yes. Start with strict: true, override specific checks temporarily, and remove those exceptions in a visible sequence. Keep CI from regressing areas that already pass, and fix the underlying models instead of masking diagnostics with assertions.
The best TypeScript configuration is not the one with the most switches. It is the one that turns important production assumptions into compiler errors while leaving the team a clear way to express the cases that are genuinely dynamic.
