When TypeScript “Works” but You Still Lose Type Safety

4 min read
Cover Image for 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.keys

  • Object.entries

  • Object.fromEntries

  • reducers 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.