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

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

1 7
calendar_today agoschedule5 min read
— Originally published at dev.to

Hoi hoi!

Still writing TypeScript guards by hand again and again? 😎
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
  • Runtime checks and type narrowing slowly drifting apart
  • Complex guards becoming painful to reuse

Sound familiar? 😅

So today, I’ll show a pattern for turning type guards into reusable logic blocks.

I’ll use my lightweight, zero-dependency library is-kit to illustrate the idea — but the mindset applies to vanilla TypeScript too.

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

Alright, let’s dive in! 🧩


What’s a Type Guard Anyway?

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

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

declare const input: unknown;

if (isString(input)) {
  input.toUpperCase(); // input is string
}

Simple, right?

Here’s a slightly more realistic version:

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

function isObject(value: unknown): value is Record<PropertyKey, unknown> {
  return typeof value === 'object' && value !== null;
}

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

function isUser(value: unknown): value is SimpleUser {
  return (
    isObject(value) &&
    isNumber(value.id) &&
    isString(value.name)
  );
}

This works.

But as your app grows, these guards start multiplying like crazy.

You often need the same checks again and again:

  • primitive checks
  • object-shape checks
  • literal checks
  • optional/nullish checks
  • extra domain rules

Handy, but repetitive.
That’s where composition comes in ♻️


♻️ Build Guards Through Definition and Composition

The core idea is simple:

  1. Define small guards.
  2. Compose them.
  3. Reuse the result anywhere TypeScript narrowing matters.

With is-kit, the previous isUser example becomes:

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

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

Clean, concise, and fully type-safe! ✨

declare const input: unknown;

if (isUser(input)) {
  input.id; // number
  input.name; // string
}

struct handles the object-shape check for you.

It also only accepts plain objects — not arrays, dates, maps, or class instances.

Want to reject extra own enumerable string keys too? Use exact mode:

const isExactUser = struct(
  {
    id: isNumber,
    name: isString
  },
  { exact: true }
);

Nice and strict 🛡️

For existing TypeScript object types, use typedStruct<T>() to keep your handwritten guard aligned with the type:

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

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

const isUser = typedStruct<User>()({
  id: isNumber,
  name: isString
});

If the User type changes, TypeScript helps you notice when the guard no longer matches.

That means fewer silent runtime/type mismatches.
Very good. Very useful. 😎


Define Custom Guards

Sometimes a primitive guard is not enough.

For reusable custom checks, use define<T>(...).

import { define, isString } from 'is-kit';

const isSlug = define<string>(
  (value) => isString(value) && /^[a-z0-9-]+$/.test(value)
);

isSlug('release-110'); // true
isSlug('Release 110'); // false

This is great when you want to name a runtime rule and reuse it as a guard.

No mystery regex scattered everywhere.
Just one small reusable block ✨


Compose Existing Guards

Need “A or B”? Use or.

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

const isId = or(isString, isNumber);

isId('user-1'); // true
isId(123); // true
isId(false); // false

Need “base guard plus extra rule”? Use and with predicateToRefine.

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

const isPositive = predicateToRefine<number>((value) => value > 0);

const isPositiveNumber = and(isNumber, isPositive);

isPositiveNumber(1); // true
isPositiveNumber(-1); // false
isPositiveNumber('1'); // false

The important part:

isNumber runs first.

So after that, the refinement can safely check value > 0.

No unsafe guessing.
No “please TypeScript trust me” energy. 😅


Example: Role-Based User Guards

Let’s build something closer to real app code.

import {
  and,
  isNumber,
  isString,
  narrowKeyTo,
  oneOfValues,
  or,
  predicateToRefine,
  struct
} from 'is-kit';

type User = {
  id: string;
  name: string;
  age: number;
  role: 'admin' | 'member' | 'guest';
};

const isUser = struct({
  id: isString,
  name: isString,
  age: isNumber,
  role: oneOfValues('admin', 'member', 'guest')
});

Now we can create role-specific guards from the base guard.

const byRole = narrowKeyTo(isUser, 'role');

const isAdmin = byRole('admin');
const isMember = byRole('member');
const isGuest = byRole('guest');

Want multiple roles? Use or.

const isStaff = or(isAdmin, isMember);

declare const input: unknown;

if (isStaff(input)) {
  input.role; // 'admin' | 'member'
  input.name; // string
}

Want a role plus another condition? Use and.

const isAdult = predicateToRefine<Readonly<User> & { role: 'admin' }>(
  (user) => user.age >= 18
);

const isAdultAdmin = and(isAdmin, isAdult);

declare const candidate: unknown;

if (isAdultAdmin(candidate)) {
  candidate.role; // 'admin'
  candidate.age; // number
}

That’s all.

  • isUser validates the object shape
  • isAdmin narrows the role
  • isAdult adds the age rule
  • isAdultAdmin composes them

No giant nested if.
No repeated object checks.
No copy-pasted guard logic.

Just small reusable pieces snapping together like LEGO blocks 🧱✨


Parse Unknown Values Safely

Guards become especially useful at boundaries:

  • API responses
  • localStorage
  • form input
  • JSON parsing
  • message events
  • feature flags

Basically, anywhere unknown enters your app.

Use safeParse when you want a small result object.

import { safeParse } from 'is-kit';

declare const payload: unknown;

const result = safeParse(isUser, payload);

if (result.valid) {
  result.value.id; // string
  result.value.role; // 'admin' | 'member' | 'guest'
}

For JSON text, use safeJsonParse.

import { safeJsonParse } from 'is-kit';

const result = safeJsonParse(
  '{"id":"u1","name":"Ada","age":36,"role":"admin"}',
  isUser
);

if (result.valid) {
  result.value.name.toUpperCase();
}

Invalid JSON? { valid: false }
Guard mismatch? { valid: false }

Simple and predictable. Very nice 😎


Handle Nullish Values Explicitly

Nullish handling is another place where guard logic gets messy fast.

is-kit gives you small helpers for that too.

import { isNil, isString, nullish, optional } from 'is-kit';

isNil(null); // true
isNil(undefined); // true
isNil(0); // false

const isNullishString = nullish(isString);
const isOptionalString = optional(isString);

isNullishString(null); // true
isNullishString('hello'); // true

isOptionalString(undefined); // true
isOptionalString('hello'); // true

Use:

  • isNil(value) to check null | undefined directly
  • nullable(guard) to allow null
  • optional(guard) to allow undefined
  • nullish(guard) to allow both

For optional object keys inside struct, use optionalKey.

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

const isProfile = struct({
  id: isString,
  nickname: optionalKey(isString)
});

This means nickname may be missing.
But if it exists, it must be a string.

Small rule. Clear behavior. No confusion 👍


🎯 Summary

Type guards do not have to be one-off defensive functions.

They can be reusable logic blocks.

With is-kit, you can build guards from small pieces:

  • define for custom runtime checks
  • struct and typedStruct for object shapes
  • and, or, and not for composition
  • predicateToRefine for extra conditions
  • narrowKeyTo for discriminant-style narrowing
  • safeParse and safeJsonParse for boundary validation

Try converting just one existing isXXX function into an is-kit version.

Once your guards are small and composable, they become much easier to share, test, and maintain.

You’ll feel the difference pretty quickly 🚀

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

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

Part 1 of 1 in is-kit
🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

I Wrote a Script to Fix Audible's Unreadable PDF Filenames

snapsynapseverified - Apr 20

The Sovereign Vault — A Comprehensive Guide to Protocol-Driven AI

Ken W. Algerverified - Jun 4

Tuesday Coding Tip 02 - Template with type-specific API

Jakub Neruda - Mar 10

Merancang Backend Bisnis ISP: API Pelanggan, Paket Internet, Invoice, dan Tiket Support

Masbadar - Mar 13
chevron_left
165 Points8 Badges
Heerlen, the Netherlandsnyaomaru-portfolio.vercel.app
2Posts
2Comments
2Connections
Funny Frontend Engineer! ? Living in the Netherlands.

Related Jobs

View all jobs →

Commenters (This Week)

2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!