Most Next.js projects say TypeScript and don't mean it. The tsconfig.json has
strict: truebut the code is full of type assertions, implicit any fallbacks, and unguarded array index accesses that crash at runtime. Proper TypeScript is a configuration choice first - then a set of patterns.
1. The tsconfig.json that actually matters
strict: true enables eight compiler options but misses several that eliminate entire categories of runtime errors:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
// arr[0] is T | undefined, not T.
// Catches "cannot read property of undefined" at compile time.
"noImplicitOverride": true,
// Subclass methods that override parent must use the override keyword.
"exactOptionalPropertyTypes": true,
// { x?: string } is not the same as { x: string | undefined }.
"forceConsistentCasingInFileNames": true,
// Prevents import path casing bugs that only show on Linux CI.
"noPropertyAccessFromIndexSignature": true
// obj.unknownKey must be obj["unknownKey"] for indexed types.
}
}
2. noUncheckedIndexedAccess - the flag most teams skip
The most impactful flag not included in strict. Without it, array[0] is typed as T even when the array might be empty:
// Without noUncheckedIndexedAccess:
const items: string[] = [];
const first = items[0]; // TypeScript says: string
first.toUpperCase(); // Runtime crash
// With noUncheckedIndexedAccess:
const first = items[0]; // TypeScript says: string | undefined ✓
first?.toUpperCase(); // Must handle undefined
Same for object index signatures:
const map: Record<string, number> = {};
const val = map["key"]; // number | undefined (with the flag)
if (val !== undefined) {
console.log(val * 2); // number - safe
}
3. Type-safe Server Actions
Server Actions receive FormData which is untyped. Parse and validate at the top of every action:
// ❌ Type assertion bypasses safety
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
await db.posts.create({ title });
}
// ✅ Validated with zod - unknown input becomes typed at runtime
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
});
async function createPost(formData: FormData) {
"use server";
const result = PostSchema.safeParse({
title: formData.get("title"),
content: formData.get("content"),
});
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await db.posts.create(result.data); // result.data is fully typed
}
4. Type-safe fetch
response.json() returns Promise<any>. Use a typed wrapper:
async function fetchTyped<T>(
url: string,
schema: z.ZodType<T>,
options?: RequestInit
): Promise<T> {
const res = await fetch(url, options);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return schema.parse(await res.json()); // throws if shape doesn't match
}
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string().email(),
});
const user = await fetchTyped("/api/user/123", UserSchema);
// user: { id: string; name: string; email: string }
5. Replace type assertions with type guards
Type assertions (value as SomeType) tell the type checker to stop checking:
// ❌ Type assertion - crash becomes a runtime problem
const user = data as User;
// ✅ Type guard - check at runtime
function isUser(data: unknown): data is User {
return (
typeof data === "object" &&
data !== null &&
"id" in data &&
"email" in data
);
}
// ❌ Asserting env vars exist
const apiKey = process.env.API_KEY as string;
// ✅ Validate at startup
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) throw new Error(`Missing env var: ${key}`);
return value;
}
const apiKey = requireEnv("API_KEY");
6. Discriminated unions over optional props
Optional props that only make sense together allow invalid state. Discriminated unions make it unrepresentable:
// ❌ Optional props allow invalid combinations
interface ButtonProps {
variant?: "default" | "loading" | "error";
loadingText?: string;
errorMessage?: string;
}
// ✅ Discriminated union - invalid state is unrepresentable
type ButtonProps =
| { variant: "default"; label: string }
| { variant: "loading"; loadingText: string }
| { variant: "error"; errorMessage: string; onRetry: () => void };
function Button(props: ButtonProps) {
if (props.variant === "loading") {
return <Spinner text={props.loadingText} />; // loadingText is string, not string | undefined
}
// ...
}
Common mistakes and their fixes
| Mistake | Correct pattern |
|---|---|
response.json() returns any |
Typed fetch wrapper with zod |
formData.get() as string |
Parse FormData with zod in Server Actions |
arr[0] treated as T
|
Enable noUncheckedIndexedAccess
|
value as SomeType everywhere |
Write type guards or use zod.parse()
|
| Optional props for related data | Discriminated unions |
process.env.KEY as string |
requireEnv() utility |
any in third-party types |
Typed adapter at the integration boundary |
Originally published at thekitbase.app
Top comments (0)