TypeScript bugs often enter through places where the code looks typed but is not actually protected. any, as User, Record<string, any>, and casual handling of nullable values can turn TypeScript from a safety net into documentation that the compiler is not allowed to question.
The central problem is not that TypeScript is weak. The problem is that teams often use TypeScript to describe what they hope is true, instead of proving what is true at boundaries such as HTTP responses, database rows, feature flags, message queues, local storage, and third-party SDKs. Static types are useful inside trusted code. Runtime validation is required where data crosses into the system.
TypeScript does not validate data at runtime
TypeScript checks code during development and compilation. It does not inspect JSON payloads, database records, environment variables, or API responses at runtime. After compilation, the type information that protected your editor experience is not present in the running JavaScript.
That distinction matters in production systems. A backend endpoint may return a malformed object. A mobile client may send an older payload. A feature flag may be missing. A nullable field may become null only for users imported from a legacy system. TypeScript can help model these cases, but only if the code does not silence the compiler too early.
A type annotation is a claim. Runtime validation is evidence.
The most dangerous TypeScript code is not always visibly unsafe. It is often code that looks clean, compiles without errors, and fails only when real data arrives.
The false safety of
as User
A type assertion tells TypeScript: “trust me, I know more than you.” Sometimes that is reasonable, for example when narrowing a DOM element after a controlled lookup. But asserting parsed or external data into an application model is a common source of hidden bugs.
type User = {
id: string;
email: string;
isActive: boolean;
};
async function loadUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return data as User;
}This function returns Promise<User>, but it does not prove that the response contains a valid User. If email is missing, isActive is "true" as a string, or the API returns an error envelope, the assertion hides the problem from the compiler.
The failure usually appears later:
const user = await loadUser("42");
if (user.isActive) {
sendWelcomeEmail(user.email.toLowerCase());
}The stack trace points to toLowerCase, not to the boundary where invalid data entered the system. That makes debugging harder and pushes validation cost into unrelated parts of the codebase.
A safer pattern is to treat external data as unknown until validated.
type User = {
id: string;
email: string;
isActive: boolean;
};
function parseUser(value: unknown): User {
if (
typeof value === "object" &&
value !== null &&
"id" in value &&
"email" in value &&
"isActive" in value
) {
const candidate = value as Record<string, unknown>;
if (
typeof candidate.id === "string" &&
typeof candidate.email === "string" &&
typeof candidate.isActive === "boolean"
) {
return {
id: candidate.id,
email: candidate.email,
isActive: candidate.isActive,
};
}
}
throw new Error("Invalid user payload");
}
async function loadUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
return parseUser(data);
}This example is intentionally small. In production, teams often use schema validation libraries, generated API clients, or shared contracts. The important rule is the same: do not turn unknown external data into a domain type without a runtime check.
Why Record<string, any> spreads risk
Record<string, any> looks structured because it names the key type. In practice, it often means “an object where every value can bypass the compiler.” That is a broad escape hatch.
function buildUserProfile(input: Record<string, any>) {
return {
id: input.id,
displayName: input.name.trim(),
marketingOptIn: input.preferences.marketing.enabled,
};
}This code assumes several things:
input.idexistsinput.nameis a stringinput.preferencesexistsinput.preferences.marketing.enabledexistsenabledhas the expected type
Because any is contagious, TypeScript will not complain. The compiler stops providing useful feedback exactly where the code needs it most.
A better default is Record<string, unknown> when the shape is not yet known, or a specific input type when the shape is part of the contract.
type RawUserProfile = {
id?: unknown;
name?: unknown;
preferences?: unknown;
};
function buildUserProfile(input: RawUserProfile) {
if (typeof input.id !== "string") {
throw new Error("Missing user id");
}
if (typeof input.name !== "string") {
throw new Error("Missing user name");
}
return {
id: input.id,
displayName: input.name.trim(),
};
}This version forces the developer to handle uncertainty before using the values. It is less permissive, but that is the point. Unknown data should create friction until it is checked.
Escape hatches compared
Pattern | Runtime behavior | Compiler behavior | Typical failure mode | Better default |
|---|---|---|---|---|
any | No runtime checks | Type checking is bypassed | Errors move downstream | unknown at boundaries |
as User on external data | No runtime checks | Compiler accepts the assertion | Invalid payload looks trusted | Parser or schema validation |
Record<string, any> | Any value is allowed | Property access is unrestricted | Nested fields fail at runtime | Record<string, unknown> or explicit DTO |
Non-null assertion value! | No runtime checks | Nullability warning is suppressed | Crash when value is absent | Guard clause or explicit fallback |
Optional chaining everywhere | No runtime checks | Access becomes convenient | Missing data becomes silent | Validate required fields early |
The table is not saying assertions are never allowed. It is saying they should be narrow, local, and justified. A type assertion at a trusted implementation detail is different from a type assertion at an API boundary.
Nullable values need design, not optimism
Nullable values are not edge cases. They are part of the data model. A field can be nullable because the value is optional, not loaded yet, unknown, removed, restricted by permissions, or invalid. Those cases should not all be represented as casual string | null unless the behavior is truly the same.
type Account = {
id: string;
billingEmail: string | null;
};
function sendInvoice(account: Account) {
sendEmail(account.billingEmail!, "Your invoice is ready");
}The non-null assertion ! removes the warning, not the risk. It is especially dangerous because it looks small and harmless. In many codebases, it becomes a habit instead of a decision.
A better implementation makes the business rule explicit.
function sendInvoice(account: Account) {
if (account.billingEmail === null) {
throw new Error(`Account ${account.id} has no billing email`);
}
sendEmail(account.billingEmail, "Your invoice is ready");
}For more complex flows, model the state more precisely.
type BillingContact =
| { status: "available"; email: string }
| { status: "missing" }
| { status: "restricted" };
type Account = {
id: string;
billingContact: BillingContact;
};
function getInvoiceRecipient(account: Account): string {
switch (account.billingContact.status) {
case "available":
return account.billingContact.email;
case "missing":
throw new Error(`Account ${account.id} has no billing contact`);
case "restricted":
throw new Error(`Billing contact is restricted for account ${account.id}`);
}
}This is more verbose than a nullable string, but it makes the domain clearer. It also prevents unrelated parts of the application from guessing why a value is absent.
Where runtime validation belongs
Runtime validation should be placed where data enters a trust boundary. That keeps the rest of the system simpler.
Common validation boundaries include:
HTTP request bodies
HTTP responses from external services
database reads when schema drift or legacy data is possible
environment variables
queue messages
local storage and browser storage
user-controlled configuration
imported files
webhook payloads
The goal is not to validate everything repeatedly. The goal is to validate once, create a trusted type, and pass that trusted type through the application.
A practical flow looks like this:
Receive external data as
unknown.Validate required shape and value constraints.
Convert it into a typed DTO or domain object.
Keep unsafe assertions out of business logic.
Add tests around invalid, missing, and nullable payloads.
This approach makes failures earlier and more local. Instead of a random runtime crash in a component or service, the system rejects invalid input at the boundary with a clear error.
What to adopt first
Teams do not need to rewrite an entire TypeScript codebase to improve safety. Start where the bug risk is highest and the migration cost is lowest.
The first changes usually provide the most value:
Replace
anywithunknownat external boundaries.Remove
as Userfrom JSON parsing, API clients, and message consumers.Replace broad
Record<string, any>usage with explicit DTOs.Treat nullable fields as domain behavior, not temporary inconvenience.
Add runtime validation to API responses that influence payments, permissions, authentication, account state, or delivery workflows.
Keep type assertions small and close to the code that proves them safe.
The team impact is also important. Once developers see unknown, they know validation is required. Once they see User, they should be able to trust it. That convention reduces review burden and makes code easier to reason about.
For engineers who work with TypeScript in production and want to validate senior-level practical skills around type safety, architecture boundaries, and maintainable application code, the Senior TypeScript Developer certification is the most relevant DevCerts track to review.
Conclusion
TypeScript is most valuable when it describes trusted code, not when it hides uncertainty. any, as User, Record<string, any>, nullable shortcuts, and non-null assertions are not just style issues. They change where bugs are discovered, how clear failures are, and how much confidence teams can place in application types.
The practical rule is simple: use TypeScript types to model what your system knows, use runtime validation to prove what external data contains, and keep escape hatches narrow. That gives teams a codebase where a User really is a user, a nullable value has a reason, and the compiler remains a useful partner instead of a silenced observer.