TypeScript Strict Mode: Why We Never Ship Without It
The Case for Strict Mode
Every project at Rosecraft Studios starts with the same TypeScript configuration: strict: true. No exceptions, no negotiations. After 20 years of writing software, we've learned that the bugs you catch at compile time are infinitely cheaper than the ones you find in production at 2 AM.
TypeScript strict mode enables a family of compiler checks that can catch mistakes before execution. Static types do not validate network responses or eliminate every runtime error.
What Strict Mode Actually Enables
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
The strict flag enables a family of checks, including the examples below. noUncheckedIndexedAccess and the unused-code checks above are separate options, not implied by strict. Consult the strict reference for the complete set for your compiler version:
strictNullChecks— Variables aren't nullable unless explicitly typed as suchstrictFunctionTypes— Function parameter types are checked contravariantlystrictBindCallApply—bind,call, andapplymethods are correctly typedstrictPropertyInitialization— Class properties must be initializednoImplicitAny— No inferredanytypesnoImplicitThis—thismust have an explicit typealwaysStrict— Emits"use strict"in every file
Real Bugs Caught by Strict Mode
1. The Null Reference That Wasn't
Without strictNullChecks, this code compiles fine and crashes at runtime:
// Without strict: compiles, crashes at runtime
function getUserName(users: User[], id: string) {
const user = users.find((u) => u.id === id);
return user.name; // Runtime error: Cannot read property 'name' of undefined
}
// With strict: compile error forces proper handling
function getUserName(users: User[], id: string) {
const user = users.find((u) => u.id === id);
if (!user) {
throw new Error(`User not found: ${id}`);
}
return user.name; // Safe — TypeScript knows user is not undefined
}
Array.find()returnsT | undefined. Without strict null checks, TypeScript pretends theundefinedcase does not need handling. An explicit check lets you decide how the application should respond when the requested record is absent.
2. The Index Access Trap
Even with strictNullChecks, array and object index access is unsafe by default. That's why we add noUncheckedIndexedAccess:
const colors = ['red', 'green', 'blue'];
// Without noUncheckedIndexedAccess
const first = colors[0]; // Type: string (wrong — could be undefined)
// With noUncheckedIndexedAccess
const first = colors[0]; // Type: string | undefined (correct)
// Forces safe access patterns
if (first) {
console.error(first.toUpperCase()); // Safe
}
3. The Implicit Any That Hid a Bug
// Without noImplicitAny: compiles, returns wrong type
function parseConfig(raw) {
// 'raw' is implicitly 'any'
return raw.settings.theme; // No type checking at all
}
// With noImplicitAny: forces explicit typing
function parseConfig(raw: RawConfig): string {
return raw.settings.theme; // Fully type-checked
}
The unknown vs any Discipline
When a type is genuinely uncertain, reach for unknown instead of any. The difference is critical:
// 'any' disables ALL type checking
function processData(data: any) {
data.forEach((item) => item.transform()); // No errors, no safety
}
// 'unknown' requires narrowing before use
function processData(data: unknown) {
if (!Array.isArray(data)) {
throw new Error('Expected an array');
}
// TypeScript now knows data is an array
data.forEach((item: unknown) => {
if (isTransformable(item)) {
item.transform(); // Safe — type guard verified the shape
}
});
}
Think of
anyas "I'm lying to the compiler" andunknownas "I don't know yet, but I'll check before I use it." Prefer honesty.
Zod: Runtime Validation Meets Static Types
At system boundaries — API routes, form handlers, external API responses — we use Zod schemas as the single source of truth for both validation and types:
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
message: z.string().min(10).max(2000),
});
// Infer the TypeScript type from the Zod schema
type ContactForm = z.infer<typeof contactSchema>;
// API route uses the same schema for validation
export async function POST(request: Request) {
const body = await request.json();
const result = contactSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten() },
{ status: 400 },
);
}
// result.data is fully typed as ContactForm
await saveSubmission(result.data);
}
This pattern eliminates the type/validation drift problem where your TypeScript interface says one thing and your runtime validation checks something different.
Migrating to Strict Mode
If you have an existing codebase without strict mode, migrating all at once can be overwhelming. Our recommended approach:
- Enable one flag at a time — Start with
strictNullChecks, thennoImplicitAny - Fix errors file by file — Use
// @ts-expect-errortemporarily with a tracking comment - Never add new
anytypes — All new code must be properly typed - Add
noUncheckedIndexedAccesslast — It generates the most changes
Start with checks that address known risks in your codebase. Track compiler errors and real defects separately; an error count is not a measured percentage of production bugs prevented.
Pair Static Checks with Runtime Checks
Strict mode helps make assumptions visible during development. Pair it with input validation, realistic tests, and runtime error handling. Type assertions, untyped dependencies, and external data can still invalidate assumptions that appear safe to the compiler.
Building a TypeScript project and want it done right? Let's talk about how rigorous engineering practices translate to reliable software.
