DEV Community

Cover image for Trick or Type Guard? Surviving `unknown` with is-kit πŸŽƒ
nyaomaru
nyaomaru

Posted on

Trick or Type Guard? Surviving `unknown` with is-kit πŸŽƒ

Uses a fun Halloween theme to explain type safety

Hoi hoi! πŸ‘‹

I'm @nyaomaru, a frontend engineer who is scared of horror things πŸ™€

Happy Halloween! πŸŽƒπŸ‘»

Tonight, we're exploring a haunted TypeScript project.

There are zombies 🧟, ghosts πŸ‘», mysterious pumpkins πŸŽƒ, and a terrifying vampire πŸ§› waiting for us.

And they all have something to do with TypeScript types!

Our mission?

Survive the night without losing type safety! 😸

Let's go!

halloween is-kit


🧟 Chapter 1: The Unknown Living Dead

It's a dark and spooky night.

Suddenly, something crawls out of the grave! 🧟

const creature: unknown = JSON.parse('{"message":"BRAAAINS!"}');
Enter fullscreen mode Exit fullscreen mode

Oh! An unknown creature!

Let's see what it wants.

console.log(creature.message);
// ❌ 'creature' is of type 'unknown'.
Enter fullscreen mode Exit fullscreen mode

WHAT?! πŸ™€

TypeScript won't let us!

But why?

Well, because we don't actually know what creature is.

It might be a zombie.
It might be a ghost.
It might even be a cute little cat. 😸

unknown means we haven't verified the value yet.

Unlike any, TypeScript won't let us access arbitrary properties on an unknown value.

And that's actually a good thing!

Let's try something simpler.

import { isString } from "is-kit";

const voice: unknown = JSON.parse('"BRAAAINS!"');

if (isString(voice)) {
  console.log(voice.toUpperCase());
  // BRAAAINS!
}
Enter fullscreen mode Exit fullscreen mode

YAY! πŸŽ‰

We survived our first encounter!

Of course, we could also use typeof voice === 'string' here.

For a single check, that's perfectly fine!

But what happens when our monsters become bigger and more complicated?


πŸŽƒ Chapter 2: The Big Pumpkin of Unknown

Look!

A GIGANTIC PUMPKIN! πŸŽƒ

And apparently, it's full of candy.

But nobody knows what's really inside.

const pumpkin: unknown = JSON.parse(`{
  "name": "Big Pumpkin",
  "candies": ["Chocolate", "Lollipop", "Cookie"],
  "spookyLevel": 99,
  "keeper": {
    "name": "cat",
    "friendly": true
  },
  "secret": "Meow!"
}`);
Enter fullscreen mode Exit fullscreen mode

That's a pretty big object.

Can we just grab the candy?

console.log(pumpkin.candies);
// ❌ 'pumpkin' is of type 'unknown'.
Enter fullscreen mode Exit fullscreen mode

Not again! 😿

We need to check what's inside before touching anything.

Fortunately, we have struct!

import {
  arrayOf,
  isBoolean,
  isNumber,
  isString,
  optionalKey,
  struct,
} from "is-kit";

const isPumpkin = struct({
  name: isString,
  candies: arrayOf(isString),
  spookyLevel: isNumber,
  keeper: struct({
    name: isString,
    friendly: isBoolean,
  }),
  secret: optionalKey(isString),
});
Enter fullscreen mode Exit fullscreen mode

We composed several small guards into one big pumpkin guard.

  • isString checks strings.
  • isNumber checks finite numbers.
  • arrayOf(isString) checks an array of strings.
  • struct checks nested object shapes.
  • optionalKey allows a property to be missing.

Now, let's open the pumpkin!

import { safeParse } from "is-kit";

const result = safeParse(isPumpkin, pumpkin);

if (result.valid) {
  console.log(result.value.candies);
  // ['Chocolate', 'Lollipop', 'Cookie']

  console.log(result.value.keeper.name);
  // cat 😸
}
Enter fullscreen mode Exit fullscreen mode

We found the candy!!! πŸ¬πŸŽ‰

When validation succeeds, result.value has the type inferred from our guards.

And when validation fails?

const cursedPumpkin = {
  name: "Cursed Pumpkin",
  candies: "100 candies",
  spookyLevel: 99,
  keeper: {
    name: "cat",
    friendly: true,
  },
};

const result = safeParse(isPumpkin, cursedPumpkin);

console.log(result);
// { valid: false }
Enter fullscreen mode Exit fullscreen mode

The candies were a string, not an array!

Our guard rejected the cursed pumpkin.

And here's the important part.

safeParse doesn't magically fix or convert invalid values.

It simply tells us whether the value passed our guard.

Also, struct allows extra properties by default. If you need to reject additional own enumerable string keys, you can use { exact: true }.

Nice and simple!


🍬 Chapter 3: Trick or Type Guard!

Ding dong! πŸ””

TRICK OR TREAT!!! 😸

Someone hands us a mysterious gift.

function receiveGift(): unknown {
  return Math.random() > 0.5 ? "Chocolate" : { spiders: 100 };
}

const gift: unknown = receiveGift();
Enter fullscreen mode Exit fullscreen mode

Wait.

Is it candy?
Or is it a box full of spiders?! πŸ•·οΈπŸ˜±

We need a way to check!

And this time, we want to accept either a string or a number.

Let's combine our guards with or!

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

const isTreat = or(isString, isNumber);

function trickOrTreat(gift: unknown) {
  if (isTreat(gift)) {
    // gift: string | number
    return `🍬 Treat: ${gift}`;
  }

  return "πŸ‘» Trick!";
}
Enter fullscreen mode Exit fullscreen mode

Now let's try it!

trickOrTreat("Chocolate");
// 🍬 Treat: Chocolate

trickOrTreat(100);
// 🍬 Treat: 100

trickOrTreat({ spiders: 100 });
// πŸ‘» Trick!
Enter fullscreen mode Exit fullscreen mode

YAY!!! 🍬

No spiders for us today!

This is one of my favorite things about is-kit.

We don't need to write one giant validator.

We can build small guards, combine them, and reuse them wherever we need.

Small guards. Simple composition. Useful TypeScript narrowing.

That's the idea!

But wait,
Something is coming.
Something TERRIFYING.


πŸ§› Final Boss: The Vampire Called any

Suddenly, the lights go out.

The door opens.

A mysterious figure appears in the darkness.

It's... COUNT ANY!!! πŸ§›πŸ’€

The most terrifying vampire in our TypeScript project! And he has a very dangerous power.

He can make TypeScript stop checking our operations!

const visitor: unknown = JSON.parse('{"name":666}');
Enter fullscreen mode Exit fullscreen mode

A mysterious visitor has arrived.

Normally, TypeScript protects us from accessing properties before we verify them.

But Count Any has a trick.

THE CURSE OF as any! πŸ§›

const vampire = visitor as any;

// The vampire ignores type safety!
console.log(vampire.name.toUpperCase());
Enter fullscreen mode Exit fullscreen mode

No TypeScript error!

Wait, What?! πŸ™€

Does that mean the code is safe?

At runtime

TypeError: vampire.name.toUpperCase is not a function
Enter fullscreen mode Exit fullscreen mode

πŸ’€ GAME OVER πŸ’€

Count Any didn't turn the number into a string.

He just convinced TypeScript to stop checking it!

That's what makes any dangerous.

It allows operations without requiring proof that they'll work.

But unknown behaves differently.

const visitor: unknown = JSON.parse('{"name":666}');

console.log(visitor.name.toUpperCase());
// ❌ TypeScript stops us!
Enter fullscreen mode Exit fullscreen mode

TypeScript refuses to continue until we check the value.

So how do we defeat Count Any?

With a Type Guard, of course! 😸

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

const isFriendlyGuest = struct({
  name: isString,
});

if (isFriendlyGuest(visitor)) {
  console.log(visitor.name.toUpperCase());
} else {
  console.log("πŸ§„ Not tonight, Count Any!");
}
Enter fullscreen mode Exit fullscreen mode

And just like that

THE VAMPIRE HAS BEEN DEFEATED! πŸ§„πŸŽ‰

Well, at least this particular cursed value has been rejected.

A Type Guard checks the actual runtime value, while a type assertion only changes what TypeScript believes about it.

One important detail, neither unknown nor any changes the value at runtime.

They are TypeScript types.

The difference is what the compiler allows us to do before verifying the value.

And that's why unknown is usually the safer choice for untrusted data.


😸 Epilogue: We Survived Halloween!

What a night!

We met the Unknown Living Dead, opened a gigantic pumpkin, collected some candy, and defeated Count Any!

So, what did we learn?

Monster TypeScript lesson
🧟 Unknown Living Dead unknown prevents unchecked operations
πŸŽƒ Big Pumpkin struct validates object shapes
🍬 Trick or Treat or combines reusable type guards
πŸ§› Count Any any bypasses normal type checking

Of course, any isn't always evil.

Sometimes it's useful when dealing with legacy code or difficult integrations.

But when working with API responses, parsed JSON, and other untrusted values, keeping them as unknown until validation is usually a much safer approach.

And is-kit makes those checks easy to compose and reuse!

If you want to explore more, check out the official guide πŸ‘‡

πŸ“– How to Validate unknown in TypeScript Without a Schema Library

And remember

The real monster isn't unknown. It's unchecked any. πŸ§›

Happy Halloween!!! πŸŽƒπŸ‘»

If you enjoyed this little TypeScript adventure, check out is-kit!

GitHub logo nyaomaru / is-kit

Build small guards. Compose them. A lightweight, zero-dependency toolkit for building reusable TypeScript type guards that compose, refine, and preserve 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.

is-kit is not just a collection of isX helpers. Its main focus is composing small runtime checks into reusable guards while preserving useful TypeScript narrowing.

It helps you write small isFoo functions, compose them into richer runtime checks, refine properties on values you already know about, and keep TypeScript narrowing natural inside regular control flow. Use it at runtime boundaries when needed, without requiring a schema-first workflow.

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
  • Use refineKey to narrow a child property while preserving its parent type
  • Validate object shapes and collections when that is useful
  • Parse or assert unknown values without a large schema framework
Documentation Site

Best…

And if you like the project, a ⭐ would make this little cat very happy! 😸

Thank you for reading! 🀲

Top comments (12)

Collapse
 
stackmasteraliza profile image
Aliza Ali •

A vampire called any is the most accurate final boss TypeScript has. One spooky footnote for the zombie hunters: a hand-written type guard is a promise the compiler never checks. If isZombie returns true for the wrong shape, everything downstream narrows confidently to the wrong type, which is exactly why small, composable, well-tested guards beat one big hand-rolled one. Bonus treat: since TS 5.5, simple filters like arr.filter(x => x !== null) infer the predicate for you. Happy Halloween, and congrats again on 100 stars!

Collapse
 
nyaomaru profile image
nyaomaru •

That’s such a good footnote πŸŽƒ

A type guard really is a promise to the compiler, so if the guard lies, everything after it can become confidently wrong. That’s exactly why I prefer small, composable guards with focused tests instead of one giant hand-written validator. πŸ”§

Happy Halloween, and thank you again for the 100-star congrats! 😸⭐

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 • • Edited

"The real monster isn't unknown. It's unchecked any." β€” Count Any has definitely claimed a few production servers over the years! πŸ§›πŸ’€

Love the Halloween framing here, nyaomaru. Explaining the difference between a runtime type guard (proving the value is safe) vs. a type assertion (as any just convincing the compiler to look the other way) through these spooky examples makes it super intuitive. Composing small guards with struct and or keeps the code so clean without needing a heavy schema setup.

Collapse
 
nyaomaru profile image
nyaomaru •

Thx, john! 😸

I’m really glad the runtime guard vs type assertion difference came through clearly. That was one of the main things I wanted the story to make intuitive. πŸ‘

And yes, I really like keeping guards small and composable with things like struct and or.

any really is a monster. Terrifying πŸ™€

Another one that scares me just as much is @ts-ignore... but I think that’s a different monster for another story. πŸ˜ΉπŸ‘»

Collapse
 
mudassirworks profile image
Mudassir Khan •

safeParse returning just { valid: false } is the part i would poke at. on a real webhook payload with 30 fields you want to know which key failed, otherwise the first production bug is a log line that says invalid and nothing else.

also struct allowing extra properties by default is a quiet footgun at a trust boundary. we turned exact on for anything crossing a service edge and it surfaced two producers sending fields nobody had documented.

do you plan to add a path or reason to the failed result, or is plain boolean on purpose?

Collapse
 
nyaomaru profile image
nyaomaru •

That’s a fair point 😸

The plain { valid: false } is intentional for now. is-kit is mainly focused on small composable type guards and narrowing, rather than rich validation diagnostics.

So TypeScript helps us after the value is narrowed, but of course it can’t tell us which field in an external runtime payload was invalid.

For production service boundaries, I usually keep validation centralized and use { exact: true } when the contract should reject extra fields.

A path/reason API could definitely be useful, but I’d want to add it without making the simple guard model much heavier. πŸ‘€

Collapse
 
mp3sss31 profile image
mp3sss •

Fun Halloween framing, and the composable guards hit the real pain point β€” this maps almost one-to-one onto Electron IPC: every payload crossing the contextBridge boundary arrives as unknown, and a small set of composable guards is exactly what keeps that trust boundary from becoming a security hole. One tip: centralize the checks in schema-style validators at the boundary instead of scattering typeof checks through the renderer, so one malformed message can't silently narrow its way past.

Collapse
 
nyaomaru profile image
nyaomaru •

That’s a great example 😸

Electron IPC is exactly the kind of boundary where unknown + reusable guards makes sense.

In production services, I usually create a clear boundary in utils or lib and use is-kit there, rather than scattering checks throughout the app.

And I agree, centralizing validation at the boundary is much safer. πŸ‘€

Collapse
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ • • Edited

Happy Halloween!! πŸ‘»πŸŽƒπŸ§Ÿβ€β™€οΈπŸ§›πŸ»β€β™€οΈβš°οΈπŸšοΈ

Collapse
 
nyaomaru profile image
nyaomaru •

Happy Halloween!!! πŸ‘»πŸŽƒπŸ˜Έ
Hope your night is full of treats and zero Count Any! πŸ§›πŸ’€πŸ¬

Collapse
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

😰

Collapse
 
paullovelace profile image
Paul Lovelace •

The vampire called any as final boss is annoyingly accurate β€” and the footnote about a hand-written guard being a promise the compiler never checks is the part that actually worries me. Small composable guards help, but only if they're tested like they can lie. Do you test the guards themselves against wrong shapes, or is that the bit everyone assumes and nobody does?