DEV Community

nyaomaru
nyaomaru

Posted on

Building Type Guards Like LEGO Blocks: Making Reusable Logic with is-kit

Hey folks!

Even in October, I’m still rocking short sleeves and shorts 😎
I’m @nyaomaru, a frontend engineer!

When writing TypeScript, we often end up creating type guard functions (value is Foo) over and over again.

But you know the pain:

  • The same isXXX functions appearing everywhere
  • Copy → tweak → repeat = infinite boilerplate hell
  • Complex type gymnastics making maintenance painful

Sound familiar? 😅

So today, I’ll show a pattern for turning your type guards into reusable logic.
I’ll use my lightweight, zero-dependency library is-kit to illustrate the idea — but the mindset applies to vanilla TypeScript too.

Alright, let’s dive in! 🧩

 

What’s a Type Guard Anyway?

In short: a function that performs runtime validation while narrowing types at the same time.

// Checks if the argument is a string at runtime
// and narrows the type to string in the true branch
function isString(value: unknown): value is string {
  return typeof value === 'string';
}
Enter fullscreen mode Exit fullscreen mode

Simple, right?
Here’s a slightly more realistic version:

// A simple User type
type SimpleUser = {
  id: number;
  name: string;
};

// Checks if a value is a SimpleUser
function isUser(value: unknown): value is SimpleUser {
  return (
    typeof value === 'object' &&
    value !== null &&
    typeof (value as any).id === 'number' &&
    typeof (value as any).name === 'string'
  );
}
Enter fullscreen mode Exit fullscreen mode

That’s the usual if (isUser(x)) pattern — inside that block, x.id is safely a number.

Handy, but writing it manually every time is inefficient.
It’s also hard to reuse or compose.

 

♻️ Build Type Guards Through “Definition” and “Composition”

If you split type guards into smaller, reusable pieces, you can mix and match them across your project.

Let’s make the earlier example more declarative.

Example: Declare the User Shape

The previous isUser was fine, but not very reusable.
Let’s define each isXXX individually:

function isObject(value: unknown): value is object {
  return typeof value === 'object';
}

function isNull(value: unknown): value is null {
  return value === null;
}

function isString(value: unknown): value is string {
  return typeof value === 'string';
}

function isNumber(value: unknown): value is number {
  return typeof value === 'number';
}

function isUser(value: unknown): value is SimpleUser {
  return (
    isObject(value) &&
    !isNull(value) &&
    isNumber((value as any).id) &&
    isString((value as any).name)
  );
}
Enter fullscreen mode Exit fullscreen mode

Now each guard is modular — reusable, composable, and clean.

If you need a stricter version of the object guard (to exclude arrays, dates, etc.), you can write:

// Checks for a plain object (not Array, Date, etc.)
// Inference: Record<string, unknown>
function isPlainObject(value: unknown): value is Record<string, unknown> {
  if (typeof value !== 'object' || value === null) return false;
  const proto = Object.getPrototypeOf(value);
  return proto === null || Object.getPrototypeOf(proto) === null;
}
Enter fullscreen mode Exit fullscreen mode

In is-kit, the same logic looks like this:

import { isNumber, isString, struct } from 'is-kit';

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

Clean, concise, and fully type-safe! ✨

Why?

  • struct infers InferSchema automatically → id: number and name: string are guaranteed
  • It only accepts plain objects, not Arrays or Dates
  • You can harden boundaries via struct(schema, { exact: true }) to reject extra keys

is-kit gives you small building blocks (primitive guards) and composition utilities to build logic like LEGO.

📘 So Far

Splitting guards into small isXXX pieces makes them reusable.
But in real-world code, we often need combinations:

  • “Add extra conditions” (AND)
  • “Pass if any condition matches” (OR)

If you chain those manually with if statements… welcome to conditional hell.
Let’s fix that.

 

🛡️ Express Complex Guards Smartly

Ever seen a snowballing if statement mess?
Like when a field must be even and start with SP\_ and have a specific property?

Let’s walk through a before → after refactor.

Before: a tangled monster

type SimpleUser = {
  id: number;
  name: string;
};

type SpecialUser = {
  id: number;
  name: string;
  specialSetting: string;
};

function isSpecialUser(value: SimpleUser | SpecialUser): value is SpecialUser {
  return (
    isObject(value) &&
    !isNull(value) &&
    isNumber((value as any).id) &&
    isString((value as any).name) &&
    (value as any).id % 2 === 0 &&
    (value as any).name.startsWith('SP_') &&
    isString((value as any).specialSetting)
  );
}
Enter fullscreen mode Exit fullscreen mode

It works, but you’ll go mad maintaining it.

After: small reusable predicates

type UserBase = {
  id: number;
  name: string;
};

type SimpleUser = UserBase;

type SpecialUser = UserBase & {
  specialSetting: string;
};

function isEven(value: unknown): value is number {
  return isNumber(value) && value % 2 === 0;
}

function isSpecialName(value: unknown): value is string {
  return isString(value) && value.startsWith('SP_');
}

function isSpecialSetting(value: unknown): value is string {
  return isString(value);
}

function isSpecialUser(value: SimpleUser | SpecialUser): value is SpecialUser {
  return (
    isObject(value) &&
    !isNull(value) &&
    isEven((value as any).id) &&
    isSpecialName((value as any).name) &&
    isSpecialSetting((value as any).specialSetting)
  );
}
Enter fullscreen mode Exit fullscreen mode

Now it’s modular and clear — easy to extend or reuse elsewhere.

With is-kit

We can express the same thing more elegantly:

import {
  and,
  guardIn,
  isNumber,
  isString,
  predicateToRefine,
  struct,
} from 'is-kit';

const isSimpleUser = struct({
  id: isNumber,
  name: isString,
});

const isSpecialUser = struct({
  id: and(
    isNumber,
    predicateToRefine((id: number) => id % 2 === 0)
  ),
  name: and(
    isString,
    predicateToRefine((name: string) => name.startsWith('SP_'))
  ),
  specialSetting: isString,
});

const isSpecialUserInUnion = guardIn<SimpleUser | SpecialUser>()(isSpecialUser);
Enter fullscreen mode Exit fullscreen mode

And then:

declare const candidate: SimpleUser | SpecialUser;

if (isSpecialUserInUnion(candidate)) {
  candidate.specialSetting.toUpperCase(); // candidate: SpecialUser
}
Enter fullscreen mode Exit fullscreen mode

That’s all.

  • struct handles the shape,
  • and adds conditions,
  • guardIn safely narrows within unions.

Type-safe, declarative, and beautifully reusable 😸

Note:
guardIn helps safely narrow within unions,
but even plain if (isSpecialUser(x)) often works fine —
TypeScript is smart enough to infer it in many cases.

 

🎯 Summary

Type guards don’t have to be one-off defense lines.
They can be reusable logic blocks.

With struct to define shape,
predicateToRefine to add constraints,
and and / or to compose conditions —

you can declare isXXX functions that are short, safe, and shareable.

Try converting just one of your existing isXXX into an is-kit version.
You’ll instantly feel the difference 🚀

https://github.com/nyaomaru/is-kit

If you like it, drop a ⭐ — it really helps! 😻

Top comments (0)