5 cases where TypeScript types are not enough to guarantee data correctness
TypeScript is excellent at describing the shape of values inside your codebase.
But many correctness problems in real applications do not come from writing the wrong property name.
This article explores five common situations where TypeScript alone is not enough to guarantee correctness — and why runtime validation, semantic boundaries, and controlled transformations matter in real-world scenarios.
1. External boundaries destroy compile-time guarantees
Section titled “1. External boundaries destroy compile-time guarantees”Consider this code:
type User = { email: string;};
const user = (await response.json()) as User;Looks kind of safe (as we assume to know what the response looks like), but it isn’t.
At runtime, response.json() returns:
unknownnot User.
The server could return:
{ "email": null}or:
{ "wrongField": true}or:
"some weird error"Of course, TypeScript cannot validate runtime input.
This applies everywhere external data enters your system, for example
- HTTP responses
- forms
- local storage
- environment variables
- queues
- route params
The important realization is:
Compile-time types stop at runtime boundaries.
This is why runtime validation exists in the first place.
A value only becomes trustworthy after crossing an explicit validation boundary:
unknown → validate → trusted valueThat pattern is the starting point of the mental model behind xndrjs, and the motivation behind posts like What problems does @xndrjs/domain actually solve?.
2. Structural typing confuses semantically different values
Section titled “2. Structural typing confuses semantically different values”TypeScript uses structural typing.
That means two values are considered compatible if their structure matches.
Example:
type UserId = string;type PostId = string;
function loadPost(postId: PostId) {}
const userId: UserId = "u_123";
loadPost(userId);TypeScript accepts this: structurally, both are just strings.
Semantically, they represent completely different concepts. So TypeScript isn’t “wrong”, but it’s not the right tool for enforcing this kind of concept.
This becomes dangerous at scale because many important domain values share the same primitive representation:
EmailUrlUserIdCurrencyCodeSlugSessionToken
Without nominal semantics, the type system cannot distinguish them.
This is where branded or nominal types become useful:
type UserId = Branded<"UserId", string>;type PostId = Branded<"PostId", string>;Now the system can distinguish:
“this is a string”from:
“this is specifically a PostId”In @xndrjs/domain, primitives carry that distinction at the type level and enforce membership at runtime via a Validator — see Primitives and shapes.
The important insight is:
Structural compatibility is not semantic compatibility.
3. Valid data can become invalid after creation
Section titled “3. Valid data can become invalid after creation”Validation at creation time is not enough if values can later be mutated arbitrarily.
Example:
const user = validateUser(payload);
user.email = "";user.role = "super-admin";The object was valid once.
Now it is not.
The problem is not validation: the problem is uncontrolled evolution.
In many codebases, data is validated at boundaries but then freely mutated across components, hooks, services, reducers, and utilities.
At that point:
correctness becomes assumed, not guaranteed.
A more reliable approach is:
- validate values once
- make representations immutable
- allow transformations only through explicit operations
For example:
validate → trusted value → controlled transformationsinstead of:
validate → mutable object → hope for the bestxndrjs shapes are immutable after create, and evolution goes through capabilities - named transitions instead of ad hoc property writes.
The goal is not “absolute safety”.
The goal is:
making invalid evolution harder than correct evolution.
4. Types cannot express contextual guarantees
Section titled “4. Types cannot express contextual guarantees”Sometimes a value is structurally valid, but still missing an important guarantee.
Example:
type User = { isVerified: boolean;};Now imagine a function:
function accessPremiumFeature(user: User) { // ...}The type allows both:
{ isVerified: true;}and:
{ isVerified: false;}But maybe the function actually requires:
a verified usernot merely “a user with a boolean field”.
The issue is that many guarantees are contextual, not structural.
A boolean field alone does not encode:
this value has already passed verificationWhat we really want is something closer to:
type VerifiedUser = User & { isVerified: true;};or a dedicated proof/refinement step.
In xndrjs, that second path is a proof: an explicit runtime semantic step. You call assert or test when a workflow needs a stronger guarantee than the base shape already carries — usually after validation or a capability transition. The proof re-checks the value at that moment; only then does the system treat the stronger meaning as established.
If you also want a narrower TypeScript view, you layer it on top with refineType — for example so that, after VerifiedUser.test(user) succeeds, isVerified is known to be true. The proof is the executed step; the narrowed type is what the compiler can infer from that step, not a substitute for it.
The distinction worth keeping:
A proof is something the runtime did. A narrowed type is something the compiler can learn after that step succeeded.
5. Type aliases cannot validate semantic constraints
Section titled “5. Type aliases cannot validate semantic constraints”Consider this:
type Email = string;This looks right, but of course TypeScript still accepts
const email: Email = "not-an-email";because
Email is still just a stringThe alias changes the name, not the runtime semantics.
TypeScript cannot:
- run regex validation
- parse values
- check formats
- enforce runtime invariants
- validate cross-field relationships
So this:
type Email = string;does not mean:
this is a valid emailIt only means:
someone decided to call this string “Email”This is why runtime validation matters.
A semantic type is not just a renamed primitive.
It is:
a validated guarantee about membership in a set of allowed valuesIn other words:
unknown → validate → Emailis fundamentally different from:
type Email = string;The first establishes trust.
The second only establishes terminology.
Use domain.primitive("Email", validator) and you get both: a nominal type and runtime membership checks at the boundary.
The missing concept: trust
Section titled “The missing concept: trust”All five problems above point to the same underlying issue:
TypeScript does not model trust.
It models structure. And it’s completely fine: it’s what it’s meant to do.
Your application, however, interacts constantly with:
- unknown runtime input
- external systems
- evolving state
- semantic guarantees
- contextual correctness
This is where types are not enough, and trust begins to matter.
The core idea behind xndrjs is simple:
data should cross explicit trust boundaries exactly once, then become hard to corrupt accidentally.
That leads to a model where:
- external data starts as
unknown - runtime validation establishes trust
- representations become immutable
- transformations are explicit
- stronger guarantees can be layered progressively
The goal is not theoretical purity.
It is reducing ambiguity in real systems.
Because at scale, most correctness bugs are not caused by missing semicolons.
They are caused by the system believing something that was never actually guaranteed.
If you want the toolkit side of this story next, start with the mental model and first model guides, or the interop demo for mixed validators (Zod, Valibot, core) on the same domain types.