Building Modern Web Apps with Next.js 15
A deep dive into Next.js 15's App Router, React Server Components, and how they change the way we build for the web.
Read the articleHow TypeScript's strict compiler flags catch real bugs before they reach production, with examples from our engineering practice.

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.
{
"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, and apply methods are correctly typedstrictPropertyInitialization — Class properties must be initializednoImplicitAny — No inferred any typesnoImplicitThis — this must have an explicit typealwaysStrict — Emits "use strict" in every fileWithout 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.
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
}
// 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
}
unknown vs any DisciplineWhen 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.
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.
If you have an existing codebase without strict mode, migrating all at once can be overwhelming. Our recommended approach:
strictNullChecks, then noImplicitAny// @ts-expect-error temporarily with a tracking commentany types — All new code must be properly typednoUncheckedIndexedAccess last — It generates the most changesStart 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.
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.
With your permission, Google Analytics helps us understand which pages people visit. Analytics cookies stay off until you accept. You can change this in Cookie settings.