What you'll learn
Quick Answer
The options worth understanding are strict, which turns on about eight separate checks including strictNullChecks, target and lib, which control emitted syntax and available global types, module and moduleResolution, which decide output format and how imports are found, esModuleInterop for CommonJS default imports, and paths for aliases. The rest are mostly defaults. The biggest trap is paths, which affects type checking only and does not rewrite the emitted JavaScript.
strict is not one check, it is about eight
"strict": true is a switch that enables a family of flags together, including noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitThis, useUnknownInCatchVariables and alwaysStrict. The exact membership grows over releases, which is itself an argument for setting strict rather than listing flags individually.
One of them carries most of the value. Without strictNullChecks, null and undefined are assignable to every type in the language. Your User might be null. Your string might be undefined. The compiler will not mention it.
// with strictNullChecks off
function greet(u: { name: string }) {
return "Hi " + u.name;
}
greet(null); // compiles cleanly. Crashes at runtime.Turn the flag on and that call is an error, as is every unguarded access to an optional property. Given that Cannot read properties of undefined is one of the most common runtime failures in JavaScript, a type system that stays quiet about it is doing perhaps half the job you think you are paying for.
noImplicitAny is the other one people feel immediately. It rejects parameters whose type cannot be inferred, which is what stops a codebase from being TypeScript in file extension only. strictPropertyInitialization catches class fields that are declared but never assigned in the constructor. useUnknownInCatchVariables types the caught error as unknown instead of any, which is correct because JavaScript lets you throw anything, and it forces the e instanceof Error check that your logging code should have had all along.
For a new project, set strict on day one. For an existing loosely typed codebase, enabling it produces a large error count on the first run, and it matters that you read that number correctly: those are not new bugs the flag created, they are existing bugs it has made visible. If the count is unmanageable, enable noImplicitAny first, then strictNullChecks, then the rest.
target and lib: emitted syntax versus available globals
target decides which JavaScript version tsc emits. Set it to ES5 and the compiler rewrites async/await into a generator state machine, classes into functions, and arrow functions into function expressions. The output works on older engines and is substantially larger and harder to read in a stack trace. Set it to a modern ES2022 and most syntax passes through untouched.
target also picks a default lib, which is the set of built-in type declarations available to you. This coupling causes a specific, confusing failure:
// "target": "es5", no explicit lib
const m = new Map<string, number>();
// Error: Cannot find name 'Map'. Do you need to change your target library?
const p = new Promise<void>(() => {});
// Error: 'Promise' only refers to a type, but is being used as a value
Object.entries({ city: "Pune" });
// Error: Property 'entries' does not exist on type 'ObjectConstructor'Nothing is wrong with your code. The ES5 library declares no Map at all, declares the Promise type without its constructor, and has no Object.entries, so you must add "lib": ["ES2020", "DOM"] explicitly.
Now the important half. lib does not polyfill anything. It is a promise you make to the compiler about what the runtime provides. Declare ES2022 while deploying to an old Node version and Object.hasOwn type-checks perfectly and throws is not a function in production. If you need old runtimes plus modern methods, you need an actual polyfill shipped in the bundle, and lib only stops the compiler complaining about it.
Two practical rules. For a Node service, match target and lib to the Node version you deploy on, and do not include "DOM" in lib. Including it is why document type-checks inside server code that has no DOM, and why setTimeout appears to return a number when Node returns a Timeout object, producing the classic Type 'Timeout' is not assignable to type 'number' error that people fix with a cast instead of fixing the config. For browser code, "lib": ["ES2022", "DOM", "DOM.Iterable"] is a sensible baseline.
module, moduleResolution and esModuleInterop
These three answer different questions and get confused constantly. module is what import syntax tsc emits: commonjs produces require calls, esnext leaves import statements alone, nodenext decides per file based on the nearest package.json. moduleResolution is how the compiler finds the file an import refers to.
A resolution mismatch has a distinctive symptom. A package installs fine, works at runtime, and the editor says Cannot find module 'x' or its corresponding type declarations. Usually the package publishes its types through the exports field in its package.json, and the legacy node10 resolution algorithm does not read exports at all. Setting "moduleResolution": "bundler" for bundled front-end code, or "nodenext" for a Node service, fixes it properly. Adding a paths entry pointing at the package's internal files, which is the usual workaround people find first, is not a fix.
esModuleInterop handles the fact that CommonJS modules have no real default export.
import express from "express"; // needs esModuleInterop
import * as express from "express"; // works without it, but then...
const app = express(); // "This expression is not callable" once interop is onWith interop enabled, TypeScript emits a small helper that wraps the CommonJS export object so the default import works, and it correctly makes a namespace import non-callable, since a namespace object is not a function. Enable it, use default imports, and be aware that flipping it on in an old repo generates exactly that not-callable error on every import * as that was being called. It also implies allowSyntheticDefaultImports, so you do not need both.
One more that has become important: isolatedModules, joined in TypeScript 5.0 by verbatimModuleSyntax, which replaced the older importsNotUsedAsValues and preserveValueImports pair. Modern build tools such as esbuild, swc and Babel transpile one file at a time and never see your other files, so they cannot tell a type export from a value export. That breaks re-exports:
export { User, createUser } from "./types"; // User is a type-only exportThe transpiler cannot know User is a type, so it emits a runtime re-export of a binding that does not exist at runtime. isolatedModules makes TypeScript reject that with Re-exporting a type when 'isolatedModules' is enabled requires using 'export type', and the fix is export { type User, createUser }. verbatimModuleSyntax goes further and requires the type modifier on any import or export that is types-only, so nothing is silently elided. If your build runs through Vite, Next.js or anything using esbuild, you want isolatedModules on.
paths: the alias that type-checks and then crashes
Path aliases turn ../../../lib/api into @/lib/api. The configuration is two lines:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}Here is the part that catches everyone. paths affects type checking only. It tells the compiler where to look for the declarations behind @/lib/api. It does not rewrite the import in the emitted JavaScript. Run tsc and open the output and you will find require("@/lib/api") sitting there verbatim. Then:
$ tsc && node dist/index.js
Error: Cannot find module '@/lib/api'Type checking passed. Editor autocomplete worked. The build produced files. Only the running program fails, which is the worst possible time to find out.
Whether this bites you depends on who produces the final output. Bundlers resolve modules themselves, so Vite, webpack and Next.js will honour an alias, but only if it is also declared in their config. Next.js reads paths from tsconfig automatically; Vite needs a resolve.alias entry or the vite-tsconfig-paths plugin; Jest needs moduleNameMapper. Each tool keeps its own copy of the mapping, and they drift.
For a Node service where tsc itself emits the output that Node runs, the honest answer is to use Node's own subpath imports instead, because the runtime actually implements them:
// package.json
{
"imports": {
"#lib/*": "./dist/lib/*.js"
}
}
// then in your source
import { getUser } from "#lib/api";Node resolves # imports natively, TypeScript understands them under nodenext resolution, and there is no second config to keep in sync. If you do stay with paths in a Node project, you need a runtime resolver such as tsconfig-paths/register, or a post-build rewrite step, and you should treat that as a permanent piece of infrastructure rather than a temporary hack.
The remaining options that earn their place
"skipLibCheck": true stops TypeScript from type-checking the .d.ts files inside node_modules. Almost every real project sets it, because two dependencies pinning different versions of the same type package will otherwise break your build with errors in code you did not write and cannot edit. The trade-off is real: a genuinely broken type definition in a dependency will not be reported. Most teams accept that.
"noEmit": true is right whenever a bundler produces the JavaScript and tsc is only there to check types. This is the normal setup for Vite and Next.js projects, and it leads to the single most important CI step in a TypeScript repo: tsc --noEmit. Vite, esbuild and swc strip types; they do not check them. Your dev server will happily run code with type errors in it. If tsc --noEmit is not in your pipeline, your project is not actually type-checked before deploy.
"noUncheckedIndexedAccess": true is the highest-value flag outside strict. It adds | undefined to the result of any index access, so arr[0] and dict[key] must be checked before use. It is noisy, and it is the flag that catches the off-by-one and missing-key crashes that strict alone misses.
include and exclude are not access control. exclude removes files from the initial file list, but if any included file imports an excluded one, it gets pulled back in and checked anyway. That is why excluding your test folder does not stop test type errors appearing. Use separate tsconfig files with project references if you genuinely need two checking scopes.
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"sourceMap": true
},
"include": ["src"]
}One note on that last flag. forceConsistentCasingInFileNames matters more than it looks in an Indian college lab or any Windows machine, because Windows and macOS file systems are usually case-insensitive while Linux CI servers are not. Importing ./Utils when the file is utils.ts works on your laptop and fails on the deploy server. This flag catches it locally.
