Where Does TypeScript Type Safety End?
Just because the compiler passes doesn't mean your program is correct. Five places where you can lie to the type system without ever writing any, what strict mode misses, and how to plug the holes at the boundary.

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.