DEV Community

Cover image for Your Type Guard Can Silently Drift from Your TypeScript Type πŸ”§
nyaomaru
nyaomaru

Posted on Originally published at is-kit.dev AI-assisted

Your Type Guard Can Silently Drift from Your TypeScript Type πŸ”§

Forgotten guard updates risk silent runtime bugs

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 about a type guard that looks completely safe.

const isUser = (value: unknown): value is User => {
  // runtime checks...
};
Enter fullscreen mode Exit fullscreen mode

Looks good, right?

TypeScript knows that when isUser(value) returns true, the value is a User.

But there's a small problem

TypeScript trusts that promise.

It doesn't prove that your runtime checks actually validate every field in User.

And that's where type guards can slowly drift away from the types they claim to protect.

Let's take a look! πŸ‘€


πŸ•³οΈ A Type Guard Can Become Outdated Without an Error

Imagine we start with this type

type User = {
  id: string;
  name: string;
};
Enter fullscreen mode Exit fullscreen mode

And a hand-written type guard

const isUser = (value: unknown): value is User => {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  const candidate = value as Record<string, unknown>;

  return typeof candidate.id === "string" && typeof candidate.name === "string";
};
Enter fullscreen mode Exit fullscreen mode

So far, everything matches.

Later, we update User

type User = {
  id: string;
  name: string;
  role: "admin" | "member";
};
Enter fullscreen mode Exit fullscreen mode

But we forget to update the guard.

const isUser = (value: unknown): value is User => {
  if (typeof value !== "object" || value === null) {
    return false;
  }

  const candidate = value as Record<string, unknown>;

  return typeof candidate.id === "string" && typeof candidate.name === "string";
};
Enter fullscreen mode Exit fullscreen mode

There is no role check.

But this still compiles. 😿


🧠 Why Doesn't TypeScript Catch This?

Because this

(value: unknown): value is User
Enter fullscreen mode Exit fullscreen mode

is a user-defined type predicate.

You are telling TypeScript

Trust me. If this function returns true, the value is a User.

TypeScript can check whether the declared predicate type itself makes sense.

But it cannot generally prove that arbitrary runtime logic actually validates every part of that type.

So this is possible

const isUser = (_value: unknown): _value is User => true;
Enter fullscreen mode Exit fullscreen mode

Terrible guard.

Perfectly valid TypeScript. 😹

The return type is a contract written by us, not a proof generated from the function body.


πŸ”„ This Becomes a Maintenance Problem

The annoying part isn't writing the guard once.

It's keeping these two things synchronized over time

TypeScript type
      ↕
Runtime validation
Enter fullscreen mode Exit fullscreen mode

Types change.

Properties get

  • added
  • removed
  • renamed
  • made optional
  • changed to another type

And every time that happens, we need to remember that some runtime guard somewhere may also need an update.

If we forget, the compiler may not tell us.

That's the kind of bug I really don't want to rely on memory to prevent.


βœ… What If the Type Could Be the Contract?

This is one of the reasons I added typedStruct to is-kit.

Suppose the application type already exists:

type User = {
  id: string;
  name: string;
  age?: number;
};
Enter fullscreen mode Exit fullscreen mode

We can build the guard against that existing type

import { isNumber, isString, optionalKey, typedStruct } from "is-kit";

const isUser = typedStruct<User>()({
  id: isString,
  name: isString,
  age: optionalKey(isNumber),
});
Enter fullscreen mode Exit fullscreen mode

Now the field map has a type-level relationship with User.

At runtime, it still performs ordinary object validation.

But at compile time, TypeScript can check whether the guards we declared match the object type they're supposed to follow.


πŸ’₯ Now Drift Becomes Visible

Let's add a field again

type User = {
  id: string;
  name: string;
  role: "admin" | "member";
  age?: number;
};
Enter fullscreen mode Exit fullscreen mode

But forget to update the guard

typedStruct<User>()({
  id: isString,
  name: isString,
  age: optionalKey(isNumber),

  // TypeScript error:
  // role is missing
});
Enter fullscreen mode Exit fullscreen mode

Nice.

The runtime bug became a compile-time problem.

The same thing happens if the guard uses an incompatible field type

import {
  isNumber,
  isString,
  oneOfValues,
  optionalKey,
  typedStruct,
} from "is-kit";

typedStruct<User>()({
  id: isString,

  name: isNumber,
  // TypeScript error:
  // User["name"] is string

  role: oneOfValues("admin", "member"),
  age: optionalKey(isNumber),
});
Enter fullscreen mode Exit fullscreen mode

This is the part I care about most.

typedStruct doesn't eliminate maintenance.

It makes forgotten maintenance visible.


🧩 Optional and Nullable Are Different

Another place where object guards can get confusing is optional properties.

Consider:

type User = {
  id: string;
  nickname?: string | null;
};
Enter fullscreen mode Exit fullscreen mode

There are two separate ideas here

nickname may be absent
Enter fullscreen mode Exit fullscreen mode

and

nickname may exist with the value null
Enter fullscreen mode Exit fullscreen mode

Those are different runtime contracts.

With typedStruct

import { isString, nullable, optionalKey, typedStruct } from "is-kit";

const isUser = typedStruct<User>()({
  id: isString,
  nickname: optionalKey(nullable(isString)),
});
Enter fullscreen mode Exit fullscreen mode

Now

isUser({ id: "user-1" });
// true

isUser({
  id: "user-1",
  nickname: null,
});
// true

isUser({
  id: "user-1",
  nickname: "Neko",
});
// true

isUser({
  id: "user-1",
  nickname: 42,
});
// false
Enter fullscreen mode Exit fullscreen mode

I like keeping these two decisions explicit:

  • optionalKey(...) β†’ the property may be absent
  • nullable(...) β†’ the value may be null

They look similar at first, but they describe different things.


🌳 Nested Types Don't Need to Be Duplicated Either

Now imagine a larger type:

type Account = {
  readonly id: string;

  readonly profile: {
    readonly displayName: string;
    readonly bio: string | null;
  } | null;

  readonly tags: readonly string[];
};
Enter fullscreen mode Exit fullscreen mode

We could manually copy the profile shape into another type.

But that creates another thing that can drift.

Instead, we can reference the type we already have

import { arrayOf, isString, nullable, typedStruct } from "is-kit";

const isProfile = typedStruct<NonNullable<Account["profile"]>>()({
  displayName: isString,
  bio: nullable(isString),
});

const isAccount = typedStruct<Account>()({
  id: isString,
  profile: nullable(isProfile),
  tags: arrayOf(isString),
});
Enter fullscreen mode Exit fullscreen mode

This is the model I like:

Reuse the existing type at compile time. Compose small guards at runtime.

The application type remains the source we want the guard to follow.


πŸ”’ What About Extra Runtime Properties?

There is another distinction worth making.

These are two different questions:

  1. Does my guard definition match the TypeScript type?
  2. Should a runtime object be allowed to contain additional properties?

By default, an object can still have additional keys.

If you want the runtime object shape to be closed as well, you can enable exact mode:

import { isString, typedStruct } from "is-kit";

type User = {
  id: string;
  name: string;
};

const isExactUser = typedStruct<User>()(
  {
    id: isString,
    name: isString,
  },
  {
    exact: true,
  },
);
Enter fullscreen mode Exit fullscreen mode

Then:

isExactUser({
  id: "user-1",
  name: "Ada",
});
// true

isExactUser({
  id: "user-1",
  name: "Ada",
  debug: true,
});
// false
Enter fullscreen mode Exit fullscreen mode

Whether extra properties should be rejected is a runtime policy decision.

It shouldn't be confused with keeping the guard definition synchronized with the TypeScript type.


βš–οΈ Which Should Be the Source of Truth?

I don't think there is one correct validation style for every project.

The important question is

What already owns the shape of this data?

Manual predicate

const isSomething = (value: unknown): value is Something => {
  // custom logic
};
Enter fullscreen mode Exit fullscreen mode

Great when the validation is unusual or not primarily structural.

Guard-first

const isUser = struct({
  id: isString,
  name: isString,
});
Enter fullscreen mode Exit fullscreen mode

Useful when the guard itself should define the resulting type.

Type-first

const isUser = typedStruct<User>()({
  id: isString,
  name: isString,
});
Enter fullscreen mode Exit fullscreen mode

Useful when User already exists and the runtime guard needs to stay aligned with it.

Schema-first

A schema library or code generation may be the better source of truth when you need things like:

  • structured validation errors
  • coercion
  • transforms
  • defaults
  • generated artifacts

These solve different problems.

I don't think every boolean validation check needs to become a schema. 😸


🚫 What typedStruct Does Not Do

There are some important boundaries.

typedStruct does not generate runtime validation from a TypeScript type.

Types are erased at runtime, so you still need to declare the guards you want to execute.

It also doesn't:

  • prove that every custom predicate is honest
  • coerce values
  • return rich structured validation errors
  • replace schema-first workflows
  • validate numeric or symbol properties as part of its string-keyed object contract

It's intentionally smaller than that.

The goal is simply to create a typed bridge between

the object type you already have

and

the runtime guards you choose to run


🎯 The Important Part

The main point isn't really typedStruct.

It's this

A type predicate is a promise, not a proof.

This

(value): value is User
Enter fullscreen mode Exit fullscreen mode

doesn't mean TypeScript inspected your implementation and proved that every User field was validated.

We made that promise.

So when a TypeScript type is the source of truth, I think it's useful to make the runtime guard structurally depend on that type instead of relying on us to remember every future change.

That's what I wanted typedStruct to help with. 😸

If your guard defines the type, use a guard-first approach.

If an existing TypeScript type should define the contract, connect the guard to that type.

And if you need rich parsing, transforms, coercion, or detailed errors, that's where a schema starts to earn its weight.

I wrote a more complete guide about this on the is-kit documentation site:

Keep Type Guards in Sync with TypeScript Types | is-kit

Keep hand-written runtime guards aligned with existing string-keyed TypeScript object types using typedStruct and field-level checks.

favicon is-kit.dev

If you like small reusable TypeScript type guards, is-kit is open source too!

GitHub logo nyaomaru / is-kit

Build small guards. Compose them. Lightweight, zero-dependency TypeScript type guards for runtime validation and natural narrowing. Runtime-safe πŸ›‘οΈ, composable 🧩, and ergonomic ✨.

is-kit

is-kit logo

npm version JSR npm downloads License

Build small guards. Compose them.

is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.

It helps you write small isFoo functions, compose them into richer runtime checks, and keep TypeScript narrowing natural inside regular control flow.

Runtime-safe πŸ›‘οΈ, composable 🧩, and ergonomic ✨ without asking you to adopt a heavy schema workflow.

  • Build and reuse typed guards
  • Compose guards with and, or, not, oneOf
  • Validate object shapes and collections
  • Parse or assert unknown values without a large schema framework

πŸ“š Documentation Site Β· 🧭 Practical Guides

Best for app-internal narrowing, filtering, and reusable guards.

πŸ€” Why use is-kit?

Tired of rewriting the same isFoo checks again and again?

is-kit is a good fit when you want to:

  • write reusable isX functions instead of one-off inline checks
  • keep runtime validation lightweight and dependency-free
  • narrow values directly in if, filter…

Thanks for reading! πŸ™Œ

Top comments (28)

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
 
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
 
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
 
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
 
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
 
kyisaiah47 profile image
kyisaiah47 •

I'd test extra keys next. Does typedStruct<User>() reject runtime objects with fields outside the declared map, or only validate the fields it knows about?

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
 
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
 
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. 😸