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:
- Define small guards.
- Compose them.
- 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