czay.dev
Writing

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.

Furkan ÖzayJune 10, 2025 · 8 min read
Where Does TypeScript Type Safety End?

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.

TypeScript
type User = { id: number; name: string };
 
function greet(user: User) {
	return `Merhaba ${user.name}`;
}

When this function compiles, this is all that's left:

JavaScript
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.

TypeScript
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

TypeScript
const raw = localStorage.getItem("settings");
const settings = JSON.parse(raw!); // any
settings.theme.colors.primary; // compiler is silent

The 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:

TypeScript
const settings: unknown = JSON.parse(raw ?? "{}");
// settings.theme  → compiler error, you need to validate first

Hole 3: The ! operator

The exclamation mark asserts "this is neither null nor undefined" — yet another assertion.

TypeScript
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:

TypeScript
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:

TypeScript
const arr: string[] = [];
const first = arr[0];
console.log(first.length); // at runtime: Cannot read properties of undefined

TypeScript 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

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 | undefined to array and object index access
  • exactOptionalPropertyTypes — prevents explicitly assigning undefined to { a?: string } properties
  • noImplicitOverride — enforces using override on class methods
  • noPropertyAccessFromIndexSignature — 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

TypeScript
const dbUrl = process.env.DATABASE_URL; // string | undefined
const client = new Client({ url: dbUrl! }); // assertion again

An 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:

TypeScript
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

TypeScript
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:

TSX
// 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:

TypeScript
// 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
  • as and ! are assertions; using them on external input means no validation took place
  • JSON.parse and res.json() return any — capture them as unknown to enforce narrowing
  • strict doesn't catch array indexing; turn on noUncheckedIndexedAccess separately
  • Validate environment variables once at startup
  • Push runtime validation to the boundaries (using Zod) and trust your types internally
  • Revisit outdated habits around React.FC and enum

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.