
Published August 11, 2026 · 9 min read
strict is the right baseline for a TypeScript project, but it is not the end of the configuration. It enables a family of type-system checks; it does not turn on every diagnostic that can catch unused code, accidental control flow, unsafe indexed access, or syntax that a type-stripping runtime cannot execute.
TypeScript 6 made strict the default, but I still set it explicitly. The setting documents the project's intent, protects projects that remain on TypeScript 5.x, and makes an inherited configuration easier to audit. The additional options in this guide are not all part of strict.
The right configuration depends on how the code runs: path aliases depend on the runtime, JavaScript checking only applies during a migration, and class-specific checks have less value in a functional codebase. The first group is the baseline I would enable almost everywhere; the later groups should be added when their assumptions match the project.
Start with the checks that reveal real mistakes in your codebase. Configure resolution separately to match the runtime.
paths: absolute aliases with a runtime contractThe paths option maps import specifiers to lookup locations. In an application, one stable alias can remove long chains of ../../.. segments and make the import's destination obvious:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}Imports can then be written relative to the source root rather than the current file:
// Before: the meaning changes with this file's nesting depth
import { Button } from "../../components/Button"
import { formatDate } from "../../../utils/formatDate"
// After: both destinations are clear from anywhere under src
import { Button } from "@/components/Button"
import { formatDate } from "@/utils/formatDate"There is an important boundary: paths changes TypeScript's module lookup, but it does not rewrite the emitted import. The runtime or bundler must understand the same specifier. TypeScript's module-resolution guide therefore warns against using convenience aliases in published libraries, where consumers may not share the mapping. Package workspaces or standard package.json imports are safer at package boundaries.
Vite 8 can read the mapping directly by opting into its built-in resolve.tsconfigPaths support:
import { defineConfig } from "vite"
export default defineConfig({
resolve: {
tsconfigPaths: true,
},
})That Vite option is disabled by default. Other build tools have their own alias behavior, so verify the tool that actually executes or bundles the module. Node's native TypeScript type stripping deliberately ignores tsconfig.json, including paths; for direct Node execution, use supported package.json subpath imports or relative specifiers instead.
noUnusedLocals and noUnusedParametersnoUnusedLocals and noUnusedParameters report declarations that are never read:
{
"compilerOptions": {
"noUnusedLocals": true,
"noUnusedParameters": true
}
}Unused code is often harmless, but it can expose unfinished logic immediately:
// Error: 'taxRate' is declared but its value is never read.
function calculateTotal(price: number, taxRate: number) {
return price * 1.08 // Bug: the caller's tax rate was ignored
}
function getUser(userId: string) {
// Error: 'userId' is declared but its value is never read.
return fetchUser("hardcoded-id")
}When a callback signature requires a parameter that the implementation intentionally ignores, prefix its name with an underscore. TypeScript exempts underscore-prefixed parameters from this check, and the name communicates intent to the next reader:
items.forEach((_item, index) => {
console.log(index)
})These compiler checks complement a linter rather than replacing one. A linter can apply project-specific rules and autofixes; the compiler ensures the basic diagnostics run anywhere the project is type-checked.
allowUnusedLabels: falseJavaScript labels are valid syntax, but they are rare. A stray property-shaped statement is much more likely to be a missing return or object literal than an intentional label. Setting allowUnusedLabels to false promotes the editor suggestion to a compiler error:
{
"compilerOptions": {
"allowUnusedLabels": false
}
}function verifyAccess(isAdmin: boolean) {
if (isAdmin) {
// Error: Unused label.
// The likely intent was `return { allowed: true }`.
allowed: true
}
}The default is not a hard error: TypeScript reports an editor suggestion when this option is unset. Explicit false makes CI enforce the same rule.
noFallthroughCasesInSwitch: trueA non-empty switch case that reaches the next case is often missing a break, return, or throw. noFallthroughCasesInSwitch rejects that ambiguous control flow:
{
"compilerOptions": {
"noFallthroughCasesInSwitch": true
}
}type Status = "pending" | "active" | "inactive"
function getStatusLabel(status: Status): string {
switch (status) {
case "pending":
console.log("Status is pending")
// Error: Fallthrough case in switch.
case "active":
return "Active"
case "inactive":
return "Inactive"
}
}The corrected version gives every non-empty case an explicit exit:
function getStatusLabel(status: Status): string {
switch (status) {
case "pending":
return "Pending"
case "active":
return "Active"
case "inactive":
return "Inactive"
}
}Intentional grouping still works because empty cases are allowed:
switch (status) {
case "pending":
case "active":
return "Visible"
case "inactive":
return "Hidden"
}allowUnreachableCode: falseallowUnreachableCode follows the same three-state pattern as unused labels: unset produces editor suggestions, true ignores unreachable code, and false makes it a compiler error.
{
"compilerOptions": {
"allowUnreachableCode": false
}
}function processPayment(amount: number): string {
if (amount <= 0) {
return "Invalid amount"
// Error: Unreachable code detected.
throw new Error("Amount must be positive")
}
return "Payment processed"
}This check is intentionally narrow. It reports code that JavaScript syntax proves cannot run; it does not report every path that appears impossible only after type analysis. That narrowness is useful because the error has a strong signal and few judgment calls.
These options find more meaningful bugs, but they also ask the codebase to model uncertainty more precisely. A mature project may need a staged rollout.
noUncheckedIndexedAccess: trueThis is the most impactful option in the guide. Without noUncheckedIndexedAccess, an array index or index-signature lookup is commonly treated as though a value must exist. At runtime, the key or index can be absent.
{
"compilerOptions": {
"noUncheckedIndexedAccess": true
}
}const users = ["Alice", "Bob"]
const user = users[5] // string | undefined
// Error: 'user' is possibly 'undefined'.
console.log(user.toUpperCase())
if (user !== undefined) {
console.log(user.toUpperCase())
}The same correction applies to dictionary-like objects:
const scores: Record<string, number> = { alice: 42 }
const score = scores["bob"] // number | undefined
// Error: 'score' is possibly 'undefined'.
console.log(score * 2)
if (score !== undefined) {
console.log(score * 2)
}The option can surface many errors in code that indexes arrays heavily. Most fixes should be real guards, safer APIs, or data-model improvements. Avoid silencing every diagnostic with !; that restores the same assumption the option was meant to expose.
noPropertyAccessFromIndexSignature: trueAn index signature says that unknown keys have a particular value type. By default, TypeScript allows both dot and bracket access for those unknown keys. noPropertyAccessFromIndexSignature reserves dot access for properties declared explicitly and requires brackets for keys covered only by the index signature:
{
"compilerOptions": {
"noPropertyAccessFromIndexSignature": true
}
}type Config = {
timeout: number
[key: string]: number
}
const config: Config = { timeout: 5_000, retries: 3 }
config.timeout // Known property: dot access is valid
// Error: Property 'retries' comes from an index signature.
config.retries
const retries = config["retries"] // number | undefined with noUncheckedIndexedAccessThe brackets make uncertainty visible, but they do not prove that the key exists. A typo such as config["timeot"] still matches the index signature. The value comes from combining this option with noUncheckedIndexedAccess: one makes unknown-key access visually explicit, and the other makes absence part of the type.
erasableSyntaxOnly: trueerasableSyntaxOnly, introduced in TypeScript 5.8, rejects TypeScript-specific constructs that require JavaScript generation rather than simple type removal. It is especially useful when Node.js or another tool executes .ts files by stripping types:
{
"compilerOptions": {
"erasableSyntaxOnly": true
}
}Enums and parameter properties both need a transform, so they fail this check:
// Error: This syntax is not allowed with erasableSyntaxOnly.
enum Direction {
Up,
Down,
Left,
Right,
}
class User {
// Error: Parameter properties require generated assignments.
constructor(
public name: string,
private age: number
) {}
}Use ordinary JavaScript values plus TypeScript types, and write runtime assignments explicitly:
const Direction = {
Up: "Up",
Down: "Down",
Left: "Left",
Right: "Right",
} as const
type Direction = (typeof Direction)[keyof typeof Direction]
class User {
public name: string
private age: number
constructor(name: string, age: number) {
this.name = name
this.age = age
}
isAdult() {
return this.age >= 18
}
}A const enum is still an enum declaration, so it is not an escape hatch. Prefer a constant object or a union when the runtime needs values.
Node's built-in TypeScript documentation makes two further boundaries explicit: type stripping does not type-check the program, and Node ignores tsconfig.json. Keep a separate tsc --noEmit check, and use the rest of Node's recommended module settings when direct execution is the goal. If tsc or another transformer emits the JavaScript, this option is not required for correctness; it remains a useful portability constraint only if the team accepts the syntax it rules out.
These options encode stronger modeling preferences. I enable them in new projects, but their value depends more heavily on the architecture and API contracts.
noImplicitOverride: truenoImplicitOverride requires an override modifier whenever a class member replaces a member inherited from a base class:
{
"compilerOptions": {
"noImplicitOverride": true
}
}class Animal {
speak(): string {
return "..."
}
}
class Dog extends Animal {
// Error: This member must have an 'override' modifier.
speak(): string {
return "Woof!"
}
}
class ExplicitDog extends Animal {
override speak(): string {
return "Woof!"
}
}The modifier also protects the subclass when the base API changes. If Animal.speak() is renamed to makeSound(), override speak() becomes an error instead of silently turning into an unrelated method. In a codebase with little or no inheritance, this option adds almost no value—but also little noise.
exactOptionalPropertyTypes: trueAn optional property means that the key may be absent. Without exactOptionalPropertyTypes, TypeScript also permits the key to be present with the value undefined. Those shapes behave differently with the in operator, Object.keys(), object spreading, and some APIs.
{
"compilerOptions": {
"exactOptionalPropertyTypes": true
}
}type User = {
name: string
nickname?: string
}
const alice: User = {
name: "Alice",
}
const ali: User = {
name: "Alice",
nickname: "Ali",
}
const ambiguous: User = {
name: "Alice",
// Error: 'undefined' is not assignable to 'string'.
nickname: undefined,
}If explicit undefined is part of the domain, say so in the type:
type UserPatch = {
nickname?: string | undefined
}The stricter distinction is particularly useful for request payloads, patches, configuration merging, and any API where “leave this field out” differs from “send this field with no value.” It can require changes to third-party types or object-building code, which is why I treat it as an intentional adoption decision rather than a zero-cost switch.
The next two settings are for projects that still contain JavaScript. A TypeScript-only project should leave them out.
allowJs: trueallowJs lets JavaScript files participate in the TypeScript project, so .js and .ts modules can live side by side during an incremental migration:
{
"compilerOptions": {
"allowJs": true
}
}import { formatDate } from "./utils.js"
export type User = {
id: string
name: string
createdAt: Date
}
export function formatCreatedAt(user: User) {
return formatDate(user.createdAt)
}export function formatDate(date) {
return date.toISOString().split("T")[0]
}This lets a team migrate module by module instead of stopping feature work for a repository-wide conversion.
checkJs: true or file-level @ts-checkWith allowJs enabled, checkJs reports type errors in every included JavaScript file. TypeScript describes it as the project-wide equivalent of adding // @ts-check to those files.
For a small project or an aggressive migration, global checking is simple:
{
"compilerOptions": {
"allowJs": true,
"checkJs": true
}
}For a large JavaScript codebase, keep checkJs false and opt files in as they are touched:
// @ts-check
// Error: Argument of type 'number' is not assignable to parameter of type 'string'.
parseFloat(123.45)The file-by-file approach prevents an initial wall of unrelated diagnostics from blocking the migration. Teams can start at stable domain boundaries, add JSDoc where inference is insufficient, and convert each module to .ts when doing so creates more value than annotation.
For a TypeScript 5.8-or-newer application whose bundler understands @/ aliases, these are additional checks I would consider. Merge them into the framework or runtime configuration; this example intentionally omits project-specific module, target, include, and emit settings:
{
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
},
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnusedLabels": false,
"noFallthroughCasesInSwitch": true,
"allowUnreachableCode": false,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"erasableSyntaxOnly": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true
}
}Treat it as a baseline, not a context-free preset:
paths when the runtime does not support the alias or when publishing a library;erasableSyntaxOnly when transform-required TypeScript syntax is an intentional part of the codebase;noUncheckedIndexedAccess and exactOptionalPropertyTypes when enabling them in an established project;allowJs and either global checkJs or file-level @ts-check only during a JavaScript migration.For that migration, keep the extra settings in the relevant project configuration instead of weakening the TypeScript-only baseline:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false
}
}Configuration should make the project's real execution model more explicit. It should not claim guarantees that the runtime, bundler, or data model does not provide.
strict catches a broad class of type errors, but several high-signal checks sit outside it. Start with unused declarations, unused labels, switch fallthrough, and unreachable code. Then make missing indexed values explicit with noUncheckedIndexedAccess, use bracket access to communicate index-signature uncertainty, and decide whether exact optional properties and explicit overrides fit the codebase.
Treat aliases and erasable syntax as contracts with the tools that execute the code, not as compiler-only preferences. Use the JavaScript flags only while migrating. Each option is a small change, but together they move failures from production behavior into a fast, repeatable type-checking step.
strict and TypeScript 6.0 default changespathsresolve.tsconfigPathsnoUnusedLocals and noUnusedParametersallowUnusedLabels, noFallthroughCasesInSwitch, and allowUnreachableCodenoUncheckedIndexedAccess and noPropertyAccessFromIndexSignatureerasableSyntaxOnly and Node.js built-in TypeScript supportnoImplicitOverride and exactOptionalPropertyTypesallowJs and checkJsOccasional articles on React, full-stack development, performance, and AI workflows.