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';
}
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'
);
}
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)
);
}
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;
}
In is-kit
, the same logic looks like this:
import { isNumber, isString, struct } from 'is-kit';
const isUser = struct({
id: isNumber,
name: isString,
});
Clean, concise, and fully type-safe! ✨
Why?
-
struct
infersInferSchema
automatically →id: number
andname: 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)
);
}
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)
);
}
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);
And then:
declare const candidate: SimpleUser | SpecialUser;
if (isSpecialUserInUnion(candidate)) {
candidate.specialSetting.toUpperCase(); // candidate: SpecialUser
}
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 plainif (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)