There are plenty of articles explaining what TypeScript is. This post asks a different question:
You turned on strict: true, you didn't touch any, the compiler passed — is your program correct?
No, it isn't. And knowing where it falls short is the difference between actually leveraging the type system and blindly trusting it.
The fundamental truth: types don't exist at runtime
When TypeScript compiles, types are erased. What actually ships to the browser or Node is plain JavaScript; not a single type check survives into runtime.
type User = { id: number; name: string };
function greet(user: User) {
return `Merhaba ${user.name}`;
}When this function compiles, this is all that's left:
function greet(user) {
return `Merhaba ${user.name}`;
}So if greet receives null at runtime, nothing stops it. The guarantee TypeScript gives you is this: you cannot make an invalid call from within your own code. Anything entering from outside your codebase — API responses, form data, localStorage, environment variables — falls completely outside that guarantee.
The five holes below all stem from this exact reality.
Hole 1: as is not a check, it's an assertion
This is the most common and the most dangerous one.
const res = await fetch("/api/user");
const user = (await res.json()) as User;
console.log(user.name.toUpperCase());There is zero validation happening here. Writing as User simply means "I am telling you this is a User." If the API returns { error: "not found" }, the compiler stays completely silent, and the line blows up at runtime with Cannot read properties of undefined.
Warning: When should you use as?
Only when you genuinely know something the compiler cannot possibly deduce: like document.getElementById("x") as HTMLInputElement. If you're using as on data coming from the outside world, you aren't validating anything — you just think you are.
Hole 2: JSON.parse treats everything as any
const raw = localStorage.getItem("settings");
const settings = JSON.parse(raw!); // any
settings.theme.colors.primary; // compiler is silentThe return type of JSON.parse is any. And any essentially disables the type system: anything accessed through any bypasses checks unchecked, and this behavior spreads wherever it's assigned.
The same goes for fetch: res.json() returns Promise<any>.
Tip: A small but effective habit
Capture the result of JSON.parse as unknown. unknown also accepts any value, but it forces you to narrow the type before using it:
const settings: unknown = JSON.parse(raw ?? "{}");
// settings.theme → compiler error, you need to validate firstHole 3: The ! operator
The exclamation mark asserts "this is neither null nor undefined" — yet another assertion.
const el = document.querySelector(".modal")!;
el.classList.add("open"); // blows up at runtime if the element doesn't exist
const user = users.find((u) => u.id === id)!;The second line is especially sneaky: if find doesn't match anything, it returns undefined, but ! silences it. The right approach is to handle the check explicitly:
const user = users.find((u) => u.id === id);
if (!user) {
return notFound();
}Hole 4: strict doesn't catch array indexing
I verified this firsthand. The following code passes without a single error under strict: true:
const arr: string[] = [];
const first = arr[0];
console.log(first.length); // at runtime: Cannot read properties of undefinedTypeScript assumes the type of arr[0] is string, even though the array is completely empty. The exact same issue applies to Record<string, T> property access.
The fix doesn't live inside strict; it requires an explicit flag:
Example: tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}Now the exact same code throws an error: 'first' is possibly 'undefined'.
When you enable this flag, you might see a flood of errors across existing code — but every single error it flags points to a real runtime risk.
Explanation: Other flags not included in strict
strict: true is a bundle, but it doesn't include everything. These need to be enabled separately:
noUncheckedIndexedAccess— adds| undefinedto array and object index accessexactOptionalPropertyTypes— prevents explicitly assigningundefinedto{ a?: string }propertiesnoImplicitOverride— enforces usingoverrideon class methodsnoPropertyAccessFromIndexSignature— prevents dot notation access on index signatures
The first two should be turned on from day one in any new project; enabling them later becomes a full migration effort.
Hole 5: process.env and configuration
const dbUrl = process.env.DATABASE_URL; // string | undefined
const client = new Client({ url: dbUrl! }); // assertion againAn environment variable isn't code; it's a deployment detail. If a variable isn't configured on the server, the failure surfaces in the worst possible spot: not during startup, but the moment that specific code path runs for the first time. The correct pattern is to validate once at boot:
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Eksik ortam değişkeni: ${name}`);
}
return value;
}
export const env = {
databaseUrl: requireEnv("DATABASE_URL"),
resendApiKey: requireEnv("RESEND_API_KEY"),
};Now the app simply refuses to start with missing configuration — failing early, clearly, and at the right time.
Plugging the holes at the boundary
All five holes share one common thread: they occur at the exact boundary where external data enters the system. That's why there is only one real solution: validate at runtime at the boundaries, and trust your types on the inside.
Zod solves this in a single step — it validates the runtime data and automatically infers the TypeScript type from the schema, so you never declare types twice:
Example: Validating at the boundary
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
});
// Type is inferred from the schema — no need to write `type User` separately
type User = z.infer<typeof UserSchema>;
export async function getUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
const json: unknown = await res.json();
// This actually validates at runtime; it's not an assertion like `as`
return UserSchema.parse(json);
}From that point forward, you know that user.name is a string; you aren't guessing. The exact same approach applies to form data, webhook payloads, and localStorage.
Related read: Form validation in Next.js: client and server-side
Two outdated habits
Stop using React.FC. It was standard practice for a while, but it's completely unnecessary now. Typing props directly is both cleaner and plays much nicer with generic components:
// Old
const Button: React.FC<ButtonProps> = ({ children }) => { /* ... */ };
// Now
function Button({ children }: ButtonProps) { /* ... */ }Consider as const instead of enum. When compiled, enum emits actual runtime code — while types get stripped away, enums remain. A union type, on the other hand, emits absolutely nothing:
// Generates an object at runtime
enum LogLevel { DEBUG = "debug", INFO = "info" }
// Generates nothing, does the exact same job
const LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
type LogLevel = (typeof LOG_LEVELS)[number];Warning: Watch out for const enum
You might see advice claiming "const enum is more performant," but it comes with a major caveat: when isolatedModules is enabled — which Next.js enforces — accessing an ambient const enum inside a .d.ts triggers a build error:
TS2748: Cannot access ambient const enums when 'isolatedModules' is enabled.
A const enum defined in your own file and imported works fine; an ambient one coming from a third-party package fails. If you aren't aware of this distinction, debugging it will cost you hours.
Summary
Summary: Checklist
- Types do not exist at runtime; guarantees only apply within your own code
asand!are assertions; using them on external input means no validation took placeJSON.parseandres.json()returnany— capture them asunknownto enforce narrowingstrictdoesn't catch array indexing; turn onnoUncheckedIndexedAccessseparately- Validate environment variables once at startup
- Push runtime validation to the boundaries (using Zod) and trust your types internally
- Revisit outdated habits around
React.FCandenum
TypeScript isn't a formal verification engine; it's a communication tool. It lets you write down what you know into your code — but if you lie to it, it quietly believes you. The hard part isn't writing types; it's making sure you aren't lying to the compiler.


