When TypeScript “Works” but You Still Lose Type Safety

At the beginning of 2025, I ran into a “bug” (caught before PRed!) that forced me to rethink what “type safety” actually means in a dynamic code path.
My code was working, the data was correct, and the TS linter wasn’t yelling at me.
The problem was that TypeScript had quietly stopped protecting me, and I didn’t notice until someone suggested testing the assumption directly.
TL;DR
I assumed TypeScript would preserve key-level safety when dynamically filtering an object. The runtime behavior was correct, but the return type widened and silently dropped all guarantees. Hard-coding values exposed the gap. The fix was making the runtime boundary explicit so the type system could enforce it.
The Setup
I was working on a table-like UI where certain columns could be hidden dynamically. I needed the row type to adapt based on which columns were visible.
I started with a fixed set of keys:
type ColumnKey =
| "title"
| "owner"
| "status"
| "createdAt"
| "actions";
type Row = Record<ColumnKey, string>;
Then I introduced a conditional row type that excluded hidden keys:
type ConditionalRow<
HiddenKeys extends ColumnKey | never = never
> = {
[Key in Exclude<ColumnKey, HiddenKeys>]?: string;
};
On paper, this was exactly what I wanted. The type precisely described the shape of a row once certain columns were hidden.
What Worked (and Why That’s Important)
At runtime, the filtering logic was correct.
function buildRow(fullRow: Row, hiddenKeys: ColumnKey[]) {
return Object.fromEntries(
Object.entries(fullRow).filter(
([key]) => !hiddenKeys.includes(key as ColumnKey)
)
);
}
If I hid "status" and "actions", they were gone.
The resulting object was correct.
This is important because the bug was not a runtime mismatch. The logic did what it was supposed to do.
The Actual Problem
The problem was what happened to the type.
Once I built the object dynamically, TypeScript lost all knowledge of which keys were present. The return type widened to something like:
{ [key: string]: string }
At that point, TypeScript could no longer help me.
That meant all of this compiled with no errors:
result.status; // hidden at runtime
result.actions; // hidden at runtime
result.chihuahua; // never existed at all
Even though:
those keys weren’t present
and in some cases never could be present
TypeScript had no way to express that distinction anymore.
The filtering worked, but the type safety was gone.
The Moment It Clicked
I didn’t notice this immediately because the runtime output appeared correct. TypeScript seemed satisfied, and everything seemed to work fine.
While walking through the code on a call with my engineering manager, I was explaining my logic with the types and the filtering. He paused and said:
“Can you just hard-code the values and see what TypeScript thinks?”
When I did that, it became obvious. Hovering over the result showed a completely generic object type. The compiler wasn’t enforcing anything anymore.
TypeScript hadn’t failed.
I had crossed a boundary where it could no longer prove anything.
Why This Happens
TypeScript can describe relationships between keys in types.
But it can’t track those relationships through dynamic object construction.
Once you go through:
Object.keysObject.entriesObject.fromEntriesreducers or dynamic spreads
TypeScript has to fall back to a broad index signature. At that point, the compiler is no longer protecting you from invalid access.
What Actually Fixed It
The fix wasn’t “more advanced types.”
It was restructuring the code so that:
runtime checks were explicit
and TypeScript could follow along
In my case, that meant introducing a type predicate for visible keys and rebuilding the object in a way that preserved type information:
function isVisibleKey(
key: ColumnKey,
hidden: ColumnKey[]
): key is Exclude<ColumnKey, typeof hidden[number]> {
return !hidden.includes(key);
}
This didn’t magically make dynamic code type-safe.
But it created a clear boundary where runtime logic and static types agreed.
What This Taught Me
This experience changed how I think about “safe” TypeScript code:
Runtime correctness and type safety are related, but not the same
Dynamic transformations are a common place to lose guarantees
If TypeScript can’t see how you built an object, it can’t protect you
The safest fix is often making the boundary explicit, not the types more clever
The code worked, but the assumptions didn’t.
And that distinction is something I’m much more aware of now.
