Strict TypeScript: the flags and types that catch bugs
A strict tsconfig.json with noUncheckedIndexedAccess, plus advanced types, catches errors before production. The setup, the key types and where it annoys. →
Software engineer · Málaga

On this page
Short answer: strict: true isn’t the finish line. The flag most strict configs still leave off is noUncheckedIndexedAccess, which types arr[i] as T | undefined. Run npx tsc --noEmit --noUncheckedIndexedAccess and count the errors: each one is a place that can blow up at runtime today. The rest of this post is the setup I would use and which kind of error each piece catches.
TypeScript isn’t just “JavaScript with types.” It’s an error prevention system that starts paying on day one, but only if you configure it to actually say no. I have not measured how many bugs it prevents in any one project, so I won’t give you a number: I’ll tell you what each flag catches.
The five practices that do the work
The whole post boils down to a strict configuration plus advanced use of the type system, so errors die at compile time instead of in production.
- Enable strict mode: turn on all the
strictflags in yourtsconfig.jsonfor maximum safety. - Use
noUncheckedIndexedAccess: preventundefinederrors when accessing arrays and objects. - Differentiate
typeandinterface:typefor unions and complex types,interfacefor objects and public APIs that can be extended. - Master utility types:
Awaited,Parameters,ReturnType,Extract, andExcludemanipulate types so you don’t repeat them. - Apply type narrowing: type guards, discriminated unions, and the
assertskeyword refine types and guide the compiler.
A strict tsconfig.json is your first line of defense
Most developers don’t enable all the strict flags. That’s the costly mistake.
The core flags
The foundational settings for a robust setup.
{
"compilerOptions": {
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true
}
}
The flags that catch the weird stuff
Go beyond the basics and more bugs die at compile time.
{
"compilerOptions": {
// ... core flags
"noUncheckedIndexedAccess": true, // Critical
"noImplicitOverride": true, // Avoids inheritance bugs
"noImplicitReturns": true, // Forces explicit returns
"noFallthroughCasesInSwitch": true, // Catch switch bugs
"noUnusedLocals": true, // Cleans dead code
"noUnusedParameters": true, // Detects unused params
"exactOptionalPropertyTypes": true, // Differentiates undefined from absent
"noPropertyAccessFromIndexSignature": true // Forces bracket notation
}
}
Config for modern tooling
This keeps you compatible with Vite, Astro, and Next.js.
{
"compilerOptions": {
// ... other flags
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
noUncheckedIndexedAccess: the flag that stops the compiler lying
Without this flag, accessing an array index is always assumed to be safe. That assumption is a common source of runtime errors.
With
noUncheckedIndexedAccess: false(the default),users[10]has the typestring, which is a lie. WithnoUncheckedIndexedAccess: true,users[10]has the typestring | undefined, which is the truth.
This single flag forces you to add a check at every indexed access and shows you the places where the code assumed the element exists.
Here’s the honest part. “Forces” is the right word: the flag is annoying exactly as often as your code was wrong, and you pay that bill up front, at every indexed access, before you’ve shipped anything. What it finds are potential bugs, not confirmed production crashes; the section below has the real count on this site. Strictness is friction by design. You’re trading an afternoon of compiler complaints for the 2 a.m. undefined is not a function. I’ll take the compiler.
type vs interface: the rule that ends the debate
The general rule: interface for the shape of objects and for public APIs (because it can be extended), type for everything else (unions, primitives, complex types).
The side-by-side:
| Feature | interface | type |
|---|---|---|
| Ideal for | Object structures (OOP), public APIs | Unions, primitives, complex types, functions |
| Extending | Yes, with extends and declaration merging | Not directly; achieved with intersections (&) |
| Declaration Merging | Yes (allows adding new fields) | No (throws a duplicate identifier error) |
| Unions & Primitives | No, cannot be used for string | number or string | Yes (type ID = string | number;) |
| Tuples | No | Yes (type Point = [number, number];) |
| Mapped Types | No | Yes (type Readonly<T> = ...) |
The short version
- Use
interfacewhen:- Defining the “shape” of an object or class.
- You want users of your API to be able to extend the definition (e.g., plugins).
- Use
typewhen:- You need unions, tuples, or function types.
- You need complex types built with mapped types or conditionals.
Utility types that earn their keep
Awaited<T>
Unwraps the type from a Promise. Essential for inferring the return type of async functions.
async function fetchUser() {
return { id: '1', name: 'Alice' };
}
type User = Awaited<ReturnType<typeof fetchUser>>;
// User is { id: string; name: string }
Parameters<T> and ReturnType<T>
Extract parameter and return types from a function. Perfect for wrappers and decorators.
function createUser(name: string, age: number) { /* ... */ }
type CreateUserParams = Parameters<typeof createUser>; // [string, number]
Extract<T, U> and Exclude<T, U>
Filter types from a union based on a condition.
type Event =
| { type: 'click'; x: number; y: number }
| { type: 'keypress'; key: string };
// Extracts only the click event
type MouseEvent = Extract<Event, { x: number }>;
Narrowing: tell the compiler what you already know
User-defined type guards
A function that returns a boolean and signals a type to TypeScript.
function isCat(animal: Animal): animal is Cat {
return animal.type === 'cat';
}
Discriminated unions
A common property (like status or type) turns your states into a machine TypeScript can follow.
type LoadingState =
| { status: 'loading' }
| { status: 'success'; data: string };
function handleState(state: LoadingState) {
if (state.status === 'success') {
console.log(state.data); // TS knows `data` exists
}
}
The asserts keyword
A function that throws when a type condition isn’t met, asserting the type for the rest of the block.
function assertIsString(value: unknown): asserts value is string {
if (typeof value !== 'string') {
throw new Error('Not a string');
}
}
This site runs strict, but skips noUncheckedIndexedAccess
Advice is cheap, so on October 6, 2026 I ran the same checks on the code behind this blog (TypeScript 6.0.3).
It extends astro/tsconfigs/strict, which adds strict on top of Astro’s base config and no other strictness flags. So no noUncheckedIndexedAccess, the flag I just told you to turn on. astro check is clean across 128 files: 0 errors, 0 warnings, 0 hints. Turning the flag on throws 35 errors in 13 files. 22 of them are in one-off scripts under scripts/; the other 13 are in 8 files under src/, all of them indexed lookups (an array element or a record entry) that the code assumes exist.
Explicit any: 10 lines in 4 files. Six are in the throttle/debounce/rafThrottle helpers: three (...args: any[]) => any constraints and three this: any. The other four are a loosely typed cookie adapter (two), the i18n lookup and a Record<string, any> on Markdown props.
That’s the size of my gap, written down: 35 errors I’ve agreed to find at runtime instead, and four anys that are just debt. If you want your own number, it’s two commands: the tsc line above and grep -rn ': any\|as any' src | wc -l.
Your move
So open your tsconfig.json and count the flags from this post that are missing. Each one is a category of bug you’ve agreed to find in production instead.
Related reading: strict types catch what your laptop forgives, and CI is the other half of that story. See why tests pass locally and fail on Vercel.
P.S. Got a strict flag horror story, or a flag you think is overrated? Tell me on LinkedIn.
Last updated:


