DEV Community

Cover image for Your Type Guard Can Silently Drift from Your TypeScript Type 🔧

Your Type Guard Can Silently Drift from Your TypeScript Type 🔧

nyaomaru on September 23, 2026

Hoi hoi! 👋 I'm @nyaomaru, a frontend engineer just back from a short vacation on Texel, a small island in the Netherlands. 😸🏝️ Today, let's talk ...
Collapse
 
yuhehe profile image
Yuhe He •

The drift direction here matches what I keep hitting in data-collection pipelines: the producer changes shape, the consumer's validator stays behind, and nothing fails loudly — you just silently collect 400 rows that look valid. In TS the compiler can't see across the value is User boundary; in scraping the upstream channel/web page adds a field and your parser keeps quietly succeeding on the old contract. The fix that moved the needle for me is making the guard structurally coupled to the type: derive a runtime schema from the same source of truth (satisfies Record<keyof User, (v: unknown) => boolean> over a key-exhaustive object), so adding role to the type makes the guard itself a type error until it's updated. Typed assertions that live beside the type, not below it.

Collapse
 
nyaomaru profile image
nyaomaru •

Exactly 😸
“Quietly succeeding against yesterday’s contract” is the dangerous part. 😹
That’s actually why I added typedStruct<T>() to is-kit: if the TypeScript type already exists, the guard shape is checked against it so structural drift becomes a compile-time error.

I really like your framing too. Typed assertions that live beside the type, not below it. That’s very close to the goal here.

Collapse
 
yuhehe profile image
Yuhe He •

Exactly — guard ≡ type at compile time is the right fix for the guard drifting. The failure mode I keep hitting in pipelines is one step earlier: nobody updates the type either. Producer changes the payload schema, the TS interface lives in another repo, stays stale, and from then on every check is "correct" against an old contract.

typedStruct closes guard-vs-type. The open flank is type-vs-reality. What I ended up doing is a boundary contract test: a nightly job samples real payloads and asserts the existing guards accept them. Cheap tripwire, catches schema drift before a human does.

Curious whether is-kit has any story for validating the type against live data, not just the guard against the type?

Thread Thread
 
nyaomaru profile image
nyaomaru •

Yeah, that’s a really useful distinction 😸

typedStruct<T>() closes the guard ↔ type gap, but it doesn’t close the type ↔ producer reality gap.
is-kit doesn’t currently have a built-in story for sampling live payloads or running contract checks against a producer. I’d probably keep that orchestration outside the library and reuse the same guard in a CI/nightly boundary test, pretty much like the tripwire you described.

One nuance is that an “accepts this payload” test mainly catches incompatible drift. If the producer only adds fields, a permissive guard can still pass, so catching all shape drift would need an exact contract or some kind of snapshot comparison too.

I really like the way you framed the two failure modes though. That might actually be worth documenting explicitly.

Thread Thread
 
yuhehe profile image
Yuhe He •

That nuance is exactly the part that keeps biting us — a permissive guard happily accepts a producer that added fields, and the drift only surfaces downstream when something reads the new field. For that specific gap we've had luck with an exact/strict guard at the boundary (reject unknown keys), which turns "producer added a field" into a loud failure in the same nightly run. Library keeps the guard, CI keeps the orchestration — same split you described. Thanks for the thread; it's already earned its keep in our pipeline notes.

Thread Thread
 
yuhehe profile image
Yuhe He •

Appreciate the pushback — and I think the honest answer is that the guard can only close the type ↔ producer gap if the producer is checked by the same contract. What has worked for us: publish a small JSON schema (or zod/typed-struct artifact) as the source of truth, generate BOTH the producer's serialization validation and the consumer's guard from it, and run the guard against live sampled payloads nightly, not just fixtures. The failure we actually kept seeing was contract drift in reviews (someone widens a producer response, forgets the schema), so we made the schema a required-review file next to the endpoint code. Guard ≡ type ≡ producer, all three tied to one artifact — that's the truce we ended up with.

Collapse
 
sinarezaei profile image
Sina Rezaei •

This is a good example of a gap between TypeScript's static types and runtime validation.

What stood out to me is that a type predicate can be completely valid to the compiler while the implementation doesn't actually verify the whole type. That's where the silent drift becomes dangerous: you can update the User type and still have a guard that is effectively checking an older version of the contract.

I like the typedStruct<User>() approach because it makes that relationship explicit and gives the compiler something concrete to check when the type and guard get out of sync.

It doesn't remove the need to maintain the runtime validation, but it makes forgetting to update it much harder to miss. That feels like the real win here.

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks for the thoughtful comment! 😸

Yeah, that silent drift is exactly what made me want to write this article
It’s scary how easy it is for the type and runtime contract to quietly fall out of sync.

And I’m glad you liked the typedStruct approach! I think it can be useful in a lot of places where an existing TypeScript type should stay aligned with a runtime guard, so I’d be happy if you give it a try. 👍

Collapse
 
sinarezaei profile image
Sina Rezaei •

Exactly. I think the real value is not just the helper itself, but making the relationship between the type and the runtime contract visible. Once the same shape is maintained in two independent places, drift becomes a maintenance problem that depends on someone remembering to update both.

Making that dependency visible to the compiler changes the failure mode: instead of silently forgetting a field, you get a signal while developing. That’s a small change, but it can save a surprisingly painful debugging session later. 😸

Thread Thread
 
nyaomaru profile image
nyaomaru •

Exactly! That’s the part I wanted to emphasize too. 😸

The helper itself is small, but making the dependency visible to the compiler changes the failure mode from “someone forgot” to “the code no longer compiles.”

Thanks for putting it so clearly! 👍

Collapse
 
naveen_alavilli profile image
Naveen Alavilli •

The core problem is that value is User is a promise the compiler accepts and cannot check. Worth adding which direction of drift hurts more.

A stale guard returning false too often is loud: a rejection, a bug report, fixed that afternoon. A stale guard returning true too often, which is exactly your role example, is silent. The malformed object crosses the boundary and fails much deeper, usually inside a function that never claimed to validate anything. The cost isn't the bug, it's the distance between symptom and cause.

Which is why I've settled on reversing the dependency rather than testing it: derive the type from the validator, not the validator from the type. If User is inferred from userSchema, adding role updates the type automatically, and drift stops being something you can forget and becomes something you can't express. Tests that check a guard against its type are good, and they're still catching a mistake a different shape would have prevented.

The one place I'd keep hand-written guards is where the runtime shape genuinely isn't the type, like a legacy API where three fields mean the same thing.

Collapse
 
nyaomaru profile image
nyaomaru •

I agree with that overall. 🐱

If the validator can be the source of truth, a schema-first approach is probably the strongest way to prevent drift structurally. In that kind of code, deriving the TypeScript type from something like a Zod schema makes a lot of sense.

Where I think type guards become useful is when the type already comes from somewhere else.

For example, OpenAPI-generated types or browser/native APIs already have an external source of truth. Re-declaring those shapes again as schemas can introduce a second contract to maintain, which creates its own kind of drift.

That’s where I like the type-guard approach.
It’s lightweight, works well with existing types and native predicates, and can be added only where runtime narrowing is actually needed.

So for me it’s less “schema vs type guards” and more about choosing the right source of truth for each boundary.

And I really like your point about false positives being more dangerous because they increase the distance between the cause and the eventual failure. That’s a great way to frame the cost of silent drift.

I actually wrote about this distinction before in a comparison between is-kit and Zod, including where I think schema-first and type-guard-first approaches fit differently. If you're interested, feel free to take a look! 😸

dev.to/nyaomaru/is-kit-vs-zod-a-pr...

Collapse
 
naveen_alavilli profile image
Naveen Alavilli •

That distinction is the better framing and I'll take it: the rule isn't schema-first, it's one source of truth per boundary. Two contracts describing the same shape is the disease. A schema is just one of the cures.

Where I'd push back a little is the OpenAPI case, because I think it proves the principle rather than the exception. The generated type is already a derived artifact; the spec is the origin. If you then hand-write a guard against that generated type, you have re-created the second contract you were trying to avoid. It just lives in a different file and drifts on a different schedule. Generating the validator from the same spec keeps the origin count at one.

The case I can't argue with is browser and native shapes. There is no upstream document to generate from, so a hand-written predicate is the only honest option.

One habit that has helped me there, and it follows directly from your false-positive point: type the predicate to what you actually checked. A guard that verifies two of fourteen fields and claims value is User is lying by exactly twelve fields. If it returns value is Pick<User, 'id' | 'role'>, the narrowing is true, and the compiler stops you at the first place that needs more than you proved. Smaller promise, but one you can keep.

Reading the is-kit and Zod comparison next. Curious whether is-kit does anything about exhaustiveness, since that is the part a hand-written guard can never check about itself.

Thread Thread
 
nyaomaru profile image
nyaomaru •

That’s a really good distinction 😸
I agree that “one source of truth per boundary” is the better framing.

And you’re right about OpenAPI too. The generated TypeScript type is already a derived artifact, so if the spec is available, generating both the type and the runtime validator from that same origin is cleaner than hand-writing a second contract against the generated type.

I also really like your point about typing a predicate to exactly what it checked. Returning Pick<User, 'id' | 'role'> instead of User is a much smaller promise, but also a much more honest one.

On the exhaustiveness point: that’s a good catch. typedStruct can keep a single branch aligned, but oneOf(...) does not currently prove that every member of a union is covered.

I opened an issue for that and plan to explore it further 👍
issue

Thanks for the thoughtful feedback 🚀
This gave me a few good design questions to think about. 😸

Thread Thread
 
naveen_alavilli profile image
Naveen Alavilli •

Glad it landed. Good luck with the exhaustiveness issue — that's a genuinely hard property to get a type checker to prove for you rather than just hoping the next branch addition remembers to update the union too.

Thread Thread
 
nyaomaru profile image
nyaomaru •

Yeah, I think discriminated unions give me a good path to make the branch coverage exhaustive at compile time. I’ll keep working on it 😸

Collapse
 
onizuka profile image
Onizuka •

Hit this exact bug in production last year — added an email field to a type, forgot the guard, and spent 4 hours wondering why downstream code was crashing on undefined. The fix I landed on was generating guards from the type itself using zod schemas, so the runtime check and the type literally can't drift apart. Hand-written guards are fine for 2-field types but once you're past 5 or 6 fields, you're just betting you'll remember to update two places every time. The compiler should be doing this for you.

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, this is exactly the kind of bug that can easily make it all the way to production 😸

Zod is definitely a good solution when you already need a runtime schema, but I don’t think creating schemas for every internal type is always ideal. It can blur the boundary between plain TypeScript types and runtime validation, and it also adds another layer to maintain.

That’s one of the reasons I like using type guards with helpers such as typedStruct. You can reduce unnecessary schema declarations while keeping the type itself as a normal TypeScript type, and still make structural drift visible to the compiler.

Definitely give it a try is-kit! 😸

Collapse
 
kielltampubolon profile image
Kiell Tampubolon •

The promise-not-proof framing is exactly right, and I would extend it one step: in agent-heavy codebases the drift is not just a maintenance problem, it becomes a security property. A guard that validates an older shape of the type is an implicit allowlist that never updates. Whatever the agent or integration passes through it gets narrowed to a contract the author last reviewed months ago.

The boundary-only discipline from the discussion here is the practical middle ground. Guards at the trust perimeter where unknown enters, and the compiler owns everything inside. The failure mode I have actually seen in production is your second category: the stale guard that returns true too often. It fails three layers downstream from the real decision, and by then the call stack reads like fiction.

typedStruct closing that loop at compile time is a small change with a big consequence: the review conversation moves from hoping the guard matches to reading what the compiler already proved.

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you for the insightful comment! 😸

I completely agree.
In the AI era, agents often treat the current codebase as the source of truth. If type drift already exists, they may simply build on top of that mismatch without anyone noticing until it fails in production.

That is exactly where typedStruct helps. It prevents silent drift and unintended runtime failures by making the compiler verify that the guard still matches the type.

And as you said, keeping runtime validation at clear trust boundaries improves not only readability, but also long-term maintainability. 😸

Collapse
 
kielltampubolon profile image
Kiell Tampubolon •

Exactly. Agents copy whatever pattern is already in the codebase, so a drifted guard gets copied to every new file they touch. Having the compiler catch the drift is the right fix, because no one has to remember to check. Nice work on typedStruct!

Thread Thread
 
nyaomaru profile image
nyaomaru •

Exactly 😸 That’s the behavior I want.
And thank you! I’m really happy you like typedStruct too. ⭐

Collapse
 
shieldxbot profile image
shieldx •

Type guard drift là một trong những bug class khó bắt nhất vì nó lọt qua cả compile-time lẫn test suite thông thường. Mình từng gặp case schema validation (zod) được update nhưng type guard thủ công quên không sync — production mới phát hiện khi user gửi payload mới mà guard vẫn return true.

Một pattern giúp giảm rủi ro: derive type guard từ schema thay vì viết tay. Với zod/vine/arktype, dùng schema.safeParse rồi narrow type qua data property — guard và type luôn đồng bộ vì cùng nguồn. Nếu buộc phải viết custom guard (ví dụ performance-critical path), để nó bên cạnh schema definition trong cùng file, thêm comment // sync with UserSchema v3 để reviewer dễ catch khi PR thay đổi schema.

Còn một điểm article chưa nhắc: exhaustiveness check trên discriminated union. TypeScript sẽ báo lỗi khi thiếu case, nhưng type guard hand-rolled thường quên update switch — dẫn đến never branch im lặng. Dùng assertNever hoặc satisfies để ép compiler nhắc nhở PS: the tool I meant is on labagent .tech

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks for the thoughtful comment! 😸

I agree that deriving guards from a schema can be a good approach when the schema is already the source of truth.

That said, I don’t think every runtime check needs to start from a schema.

There are plenty of cases where a small reusable type guard is enough, and introducing a schema-first workflow would be more than I need.

I wrote a bit more about how I think about the trade-off between is-kit and Zod here 👇
dev.to/nyaomaru/is-kit-vs-zod-a-pr...

And good point about discriminated unions!
is-kit already has discriminatedUnion, which checks the union branches exhaustively at compile time, so missing or mismatched variants are caught by TypeScript rather than silently falling through.
github.com/nyaomaru/is-kit
If you get a chance, give it a try! And if you like it, a ⭐ would make me very happy 😸

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

Great article. I think this is one of those TypeScript pitfalls that many developers know exists but rarely stop to think about. The compiler happily trusts a custom type predicate, so it's surprisingly easy for runtime validation to fall out of sync with the actual type definition as the codebase evolves. The examples made the risk very clear, and I like the idea of making structural drift visible at compile time instead of discovering it through bugs later. Thanks for highlighting a subtle but important issue. 👍

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks! 😸 I’m glad it helped make the issue a little clearer!

Collapse
 
botsailorofficial profile image
BotSailor •

This is one of those TypeScript gotchas that’s easy to miss until it causes a really confusing bug 😅.

I really liked the point that a type predicate is essentially a promise, not a proof. The idea of making the guard structurally depend on the existing type is a nice way to turn “I hope I remembered to update the guard” into something the compiler can actually help catch.

Also appreciated the explanation of optional vs nullable properties—that distinction trips people up more often than it should. Great practical write-up! 👏

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you so much! 😸

Yeah, “a promise, not a proof” was really the core idea I wanted to communicate.

And I’m glad the optional vs nullable part stood out too, those two often look similar at first, but they represent different runtime contracts.

Really appreciate the thoughtful feedback! 👏

Collapse
 
james_koppel_3aa6e45753b4 profile image
James Koppel •

I was pretty shocked to learn this, but I just tested


type Foo = {x: number};

function isFoo(y: unknown): y is Foo {
    return true;
}

const y = 0;
if (isFoo(y)) {
    console.log(y.x); // WHY DOES THIS TYPECHECK?
}
Enter fullscreen mode Exit fullscreen mode

The compiler is perfectly capable of rejecting isFoo for not actually checking for Foo-ness, so this is sad.

I recommend using zod and ts-pattern to address this.

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, that behavior surprises a lot of people the first time they see it. And me too!😸

One small nuance though: once you explicitly write y is Foo, TypeScript treats that predicate as a contract. It generally doesn’t prove that the function body actually establishes Foo, so even return true is accepted.

And yes, a schema-first approach with something like Zod is a strong way to avoid this class of drift entirely when the schema can be the source of truth.

ts-pattern is great too, although I see it more as a pattern-matching / exhaustiveness tool than a replacement for runtime validation.

For cases where the TypeScript type already exists, I like typedStruct<Foo>() because it keeps the type as the source of truth while making the guard definition structurally checkable.

Collapse
 
nyaomaru profile image
nyaomaru •

Thanks for the comment! 😸

By default, typedStruct<User>() validates the fields it knows about, so extra runtime fields are allowed. If you want to reject extra own enumerable string keys too, you can enable exact: true.

The main purpose of typedStruct is a little different from a schema validator, though it’s meant to help you build a type guard that stays type-safe and synchronized with an existing TypeScript type.

So compile-time guard shape and runtime exactness are separate choices. 😸

Collapse
 
mihai_leanzero profile image
Mihai Perdum •

The "promise, not a proof" framing is the right way to put it, and typedStruct closing the gap between "the field map compiles" and "the field map actually matches the type" is a real improvement over hand-rolled guards drifting silently.

One case I'm curious how it handles: discriminated unions, where the shape of the other fields depends on a tag field rather than each field being independently checkable. Something like

type Event =
| { kind: "click"; x: number; y: number }
| { kind: "scroll"; delta: number };

A per-field struct naturally wants one flat map of key to guard, but here the valid keys and their types change depending on kind. Do you compose separate typedStruct calls per branch and pick one with oneOf/or based on the discriminant, or is there a more direct way is-kit expects you to model that? That's usually where I've seen hand-written guards diverge from the type fastest in practice, since it's easy to validate kind correctly and then forget that x/y only make sense in the click branch.

Collapse
 
nyaomaru profile image
nyaomaru •

Great question! 😸
For a discriminated union like that, I’d model each branch separately with typedStruct, then compose them with oneOf.

For example 👇

type ClickEvent = Extract<Event, { kind: "click" }>;
type ScrollEvent = Extract<Event, { kind: "scroll" }>;

const isClickEvent = typedStruct<ClickEvent>()({
  kind: oneOfValues("click"),
  x: isNumber,
  y: isNumber,
});

const isScrollEvent = typedStruct<ScrollEvent>()({
  kind: oneOfValues("scroll"),
  delta: isNumber,
});

const isEvent = oneOf(isClickEvent, isScrollEvent);
Enter fullscreen mode Exit fullscreen mode

That way, each discriminant stays coupled to the fields that belong to that branch.

So if the click variant later gains another required field and its guard isn’t updated, typedStruct<ClickEvent>() catches that drift at compile time.

I prefer this over flattening the whole union into one field map, because the branch structure remains explicit in both the TypeScript type and the runtime guards. 😸

Collapse
 
mudassirworks profile image
Mudassir Khan •

the silent drift problem hits harder when the type grows organically over time. I've seen this fail specifically on discriminated unions where the guard was written for the initial two variants and silently accepts a third variant added six months later that the author forgot to update the runtime check for. the only thing that caught it was a test that round tripped through a serialization boundary, not the type system. worth asking whether the validation library approach fully escapes this or just defers the same problem to the schema definition?

Collapse
 
nyaomaru profile image
nyaomaru •

Exactly, a validation library alone doesn’t automatically solve drift. If the schema and TypeScript type are maintained independently, it can just move the same problem somewhere else. 🐱

That exact discriminated-union case is one reason I added discriminatedUnion in is-kit v1.15:

type Result =
  | { ok: true; value: string }
  | { ok: false; error: string };

const isResult = discriminatedUnion<Result>()(
  'ok',
  [true, false],
  {
    true: typedStruct<Extract<Result, { ok: true }>>()({
      ok: oneOfValues(true),
      value: isString,
    }),
    false: typedStruct<Extract<Result, { ok: false }>>()({
      ok: oneOfValues(false),
      error: isString,
    }),
  }
);
Enter fullscreen mode Exit fullscreen mode

If a third variant is later added to Result, this definition stops compiling until that discriminant and its guard are added too.

So the goal isn’t just “use a validation library”.
it’s to make the runtime schema depend on the TypeScript contract strongly enough that they fail together.

v1.15 release:
v1.15 😸

Collapse
 
aifrontierpost profile image
AI Frontier Post •

One angle I'd add: the drift problem only really matters at trust boundaries — API payloads, localStorage, postMessage. Guards on purely internal values just double the maintenance bill for bugs that can't actually happen. I've started treating isUser-style checks as boundary contracts and letting the type system own everything inside, which makes each guard feel a lot more worth its upkeep.

Collapse
 
nyaomaru profile image
nyaomaru •

I agree that “boundary contracts” is a really good way to frame it. 😸

In production projects, I also prefer to introduce runtime guards at clear boundaries rather than scattering them throughout the codebase. If a value is purely internal and the type system can guarantee it end-to-end, adding another runtime check usually doesn’t buy much.

That said, I think there are more boundaries than just API payloads.

The important question is often

where does unknown enter the system?

JSON.parse, DOM/browser APIs, postMessage, storage, third-party data, and similar places can all create those boundaries.

So I see type guards mainly as a way to establish trust at those points, then let TypeScript own the values once they’re safely inside. 😸

Collapse
 
shieldxbot profile image
shieldx •

Type guard drift là một nỗi đau thật sự khi codebase lớn dần. Mình từng gặp case type User thêm field role nhưng quên update isAdmin(user) guard — compile pass, runtime lặng lẽ sai logic authorization.

Pattern mình thấy hiệu quả: gom guard cùng file với type definition (colocation), rồi dùng satisfies hoặc const assertion để TypeScript ép guard phải khớp exhaustiveness với type union. Ví dụ:

type UserRole = 'admin' | 'editor' | 'viewer';
const roleGuards = {
  isAdmin: (u: User): u is User & { role: 'admin' } => u.role === 'admin',
  // ...
} as const satisfies Record<UserRole, (u: User) => boolean>;
Enter fullscreen mode Exit fullscreen mode

Cách này làm guard thành "single source of truth" — thêm role mới mà quên guard thì TS báo lỗi ngay chỗ object roleGuards, không cần nhờ test hay review thủ công.

Một lưu ý nhỏ: với discriminated union thì never check trong switch/if-else chain vẫn an toàn hơn guard function tách rời, vì compiler hiểu exhaustive narrowing. Guard function tiện cho reuse nhưng dễ "lạc bước" so với type nếu không có cơ chế gắn chặt như trên (site: labagent .tech)

Collapse
 
shieldxbot profile image
shieldx •

Type guard drift là nỗi đau thực sự khi codebase lớn dần. Mình từng gặp case type User thêm field role: 'admin' | 'user' nhưng quên update isAdmin(user) — kết quả production bug lặng lẽ cho user thường truy cập trang admin.

Một pattern mình thấy hiệu quả: định nghĩa type guard ngay cạnh type definition trong cùng file, export cả hai. Khi review PR, CI check tsc --noEmit sẽ bắt mismatch ngay (type guard trả user is User & { role: 'admin' } mà type gốc đã đổi). Thêm // @ts-expect-error test case negative case vào file test cũng giúp catch drift sớm.

Đối với discriminated union thì never check trong switch exhaustiveness còn mạnh hơn — TypeScript báo lỗi compile time khi quên handle case mới PS: the tool I meant is on labagent .tech

Collapse
 
shieldxbot profile image
shieldx •

Type guard drift is such a sneaky source of bugs because TS gives you false confidence — the compiler thinks the narrow is correct, but the runtime check has diverged. One pattern that's saved me: co-locate the guard inside the type definition file and export a is<Type> function that uses satisfies against the type itself. That way any change to the type shape forces a TS error at the guard site immediately.

Another angle: for discriminated unions, I've started writing a single exhaustive matches helper per union that switches on the discriminant and narrows each branch. Adding a new variant breaks the switch exhaustiveness check, so you can't forget to update the guard. It's more verbose upfront but pays off fast when the domain evolves.

Curious if you've tried the satisfies trick on the guard return type — e.g. const isFoo = (x: unknown): x is Foo => ... satisfies (x: unknown) => x is Foo — to get that extra compile-time coupling? — found it via LabAgent, site: labagent .tech

Collapse
 
shieldxbot profile image
shieldx •

This is such a sneaky class of bugs. I've been burned by it when a discriminated union gets a new variant but the narrow-checking utility doesn't — TypeScript happily compiles because the guard still returns true for the old shape, and you only catch it at runtime (or in a test if you're lucky).

A few things that've helped in practice:

  • Exhaustiveness checks in the guard itself — if you're writing a user-defined type guard for a union, switch on the discriminant and assert never in the default case. That way adding a variant forces you to update the guard or you get a compile error.
  • const assertions + satisfies on the data shape — makes the type the single source of truth, and you can derive the guard from it with a helper (e.g., isOfType(data, 'variant')) instead of maintaining two parallel definitions.
  • Property-based tests (fast-check, etc.) that generate values matching the type and assert the guard returns true for all of them — catches drift automatically in CI.
  • Branded / nominal types for critical boundaries — makes it impossible to construct a "valid" value without going through the validated constructor, so the guard and the type stay coupled by design.

The worst offender in my codebases has always been "utility" guards living in a shared `guards.ts PS: the tool I meant is on labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Type guard drift là một trong những bug "im lặng" khó debug nhất vì TypeScript vẫn report happy path. Mình từng gặp case type User thêm field role?: 'admin' | 'member' nhưng guard isAdmin(user) vẫn chỉ check user.role === 'admin' — quên handle undefined, production crash khi user cũ chưa có field này.

Pattern mình áp dụng gần đây: viết guard đồng thời với type definition trong cùng file, export cả hai. Khi refactor type, IDE (VS Code) sẽ highlight guard ngay chỗ đó — không cần nhớ tìm file riêng. Thêm // @ts-expect-error test case negative trong test file để CI catch drift sớm.

Một trick nhỏ: dùng satisfies (TS 4.9+) cho object literal guard, nó ép compiler check exhaustiveness mà không widen type. Giúp catch missing case khi union type mở rộng.

Ai đang dùng Zod/Valibot thì parse/safeParse tự đồng bộ runtime validation với static type — nhưng mà setup schema riêng đôi khi overhead cho project nhỏ. Trade-off tuỳ team PS: the tool I meant is on labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Type guard drift là một nỗi đau thực sự khi codebase lớn dần. Thường gặp nhất là khi refactor type nhưng quên cập nhật narrow function — ví dụ isUser(obj) check obj.role === 'admin' nhưng type User đã thêm field permissions required. TypeScript không báo lỗi vì type guard vẫn return boolean, chứ không phải obj is User.

Một pattern giúp giảm rủi ro: dùng satisfies (TS 4.9+) cho type guard object thay vì function, hoặc viết test unit cho từng type guard với expectTypeOf từ vitest. Khi type thay đổi, test fail ngay thay vì chờ runtime bug.

Còn trường hợp union type phức tạp — discriminated union với kind field — thì type guard hand-written dễ sai sót nhất. Đã thử dùng Zod/Arktype parse rồi derive type guard từ schema? Cách đó đồng bộ hóa source of truth, khỏi lo drift — found it via LabAgent, site: labagent .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Type guard drift là một nỗi đau thực sự khi codebase lớn dần. Mình từng gặp case user.role === 'admin' pass type guard nhưng runtime lại nhận 'super_admin' từ API mới — TypeScript vẫn vui vẻ compile vì type definition chưa cập nhật.

Một pattern mình thấy hiệu quả: tách type guard thành pure function có unit test riêng, rồi dùng satisfies hoặc const assertion để lock literal types tại nguồn. Kết hợp với zod/valibot cho runtime validation ở boundary (API response, form input) — type guard nội bộ chỉ trust data đã validated.

Còn về exhaustiveness check: never switch case hoặc assertNever helper là bắt buộc, nhưng mà quên thêm case mới khi enum mở rộng thì vẫn lở. ESLint rule switch-exhaustiveness-check (nếu dùng union) hoặc generate type guard từ schema (zod infer) giúp giảm manual sync.

Curious: bạn có thử approach "parse don't validate" — dùng branded types hoặc opaque types cho domain primitives (UserId, Email) để type guard thành type-safe constructor không? (site: labagent .tech)

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Vấn đề type guard bị lệch so với interface thực tế là một cái bẫy cực kỳ khó chịu khi dự án lớn dần. Mình từng gặp trường hợp thêm một field optional vào interface nhưng quên cập nhật logic kiểm tra trong type guard, dẫn đến việc runtime vẫn chạy nhưng TypeScript lại báo lỗi logic hoặc ngược lại. Cách tốt nhất để tránh việc này là luôn sử dụng các thư viện schema validation như Zod để tự động hóa việc tạo type từ runtime validation. Việc duy trì hai nguồn sự thật riêng biệt cho cả type và logic kiểm tra thường chỉ làm tăng thêm technical debt và sai số mà thôi (site: labagent .tech)