Hoi hoi!
I'm @nyaomaru, a frontend engineer who is trying to lose weight. ๐๐
I maintain a type guard library, is-kit.
I spend an unreasonable amount of time asking
โBut what if this value is actually unknown???โ
Recently, is-kit crossed 50 GitHub stars ๐๐๐
A star is not a benchmark. And 50 stars do not suddenly make a library production-ready.
But each one still means
โSomeone found this idea useful.โ
That makes me very happy!! Each star gives me more motivation to keep improving the library!!!!
There is also something more concrete I want to share
is-kitis now used in a production TypeScript application serving more than 100,000 users. ๐
This article explains:
- What problem we had
- How we introduced
is-kit - What actually changed
- Where it is used today
- What its practical advantages are
Let's dive in!
๐ The Problem Was Not โValidationโ
The application already had many small checks like ๐
typeof value === "string";
typeof value === "number";
value === null || value === undefined;
It also had user-defined type guards for:
- HTTP client errors
- Status codes
- Literal unions
- Arrays
- Plain objects
- Values coming from JSON or API responses
Each check was reasonable by itself.
The problem appeared when they started repeating.
For example, several error guards had almost the same structure.
type HttpClientError<T = unknown> = Error & {
isHttpClientError: true;
response?: {
status: number;
data: T;
};
};
function isUnauthorizedError(error: unknown): error is HttpClientError {
return (
!!error &&
(error as HttpClientError).isHttpClientError === true &&
(error as HttpClientError).response?.status === 401
);
}
function isValidationError(error: unknown): error is HttpClientError {
return (
!!error &&
(error as HttpClientError).isHttpClientError === true &&
(error as HttpClientError).response?.status === 422
);
}
This works.
But it has three practical problems:
- The same base check is repeated
- Assertion casts appear inside every guard
- Adding another status means adding another copy
The code was not broken.
It was simply asking for a reusable abstraction. ๐ง
๐โโ๏ธ The Production Pattern
We replaced the repeated checks with small composable guards.
Here is a business-neutral version of the production pattern.
import { define, equalsKey, or } from "is-kit";
type HttpClientError<T = unknown> = Error & {
isHttpClientError: true;
code?: string;
response?: {
status: number;
data: T;
};
};
const isHttpClientError = define<HttpClientError>((value) =>
equalsKey("isHttpClientError", true)(value),
);
const isHttpErrorWithStatus = (status: number) =>
define<HttpClientError>(
(value) => isHttpClientError(value) && value.response?.status === status,
);
export const isUnauthorizedError = isHttpErrorWithStatus(401);
export const isValidationError = isHttpErrorWithStatus(422);
const hasTimeoutCode = define<HttpClientError>(
(value) => isHttpClientError(value) && value.code === "TIMEOUT",
);
const hasTimeoutMessage = define<HttpClientError>(
(value) => isHttpClientError(value) && value.message.includes("timed out"),
);
export const isTimeoutError = or(hasTimeoutCode, hasTimeoutMessage);
There are a few important details here.
define
define<T> turns a runtime boolean check into a reusable predicate.
const isHttpErrorWithStatus = (status: number) =>
define<HttpClientError>(...);
The responsibility is still ours. The runtime check must actually prove T.
is-kit cannot make an incorrect predicate correct.
But it gives custom guards one consistent shape.
equalsKey
The base error is not plain JSON.
It is an error instance with a marker property.
So a plain-object schema is not the right abstraction here.
equalsKey("isHttpClientError", true) expresses exactly what we need,
โThis value owns this key, and its value is exactly
true.โ
or
A timeout can be detected in more than one way.
Instead of creating another large conditional, we compose two reusable guards.
const isTimeoutError = or(hasTimeoutCode, hasTimeoutMessage);
That is the core idea of is-kit,
Build small guards, then compose them.
โจ What Actually Changed
The first adoption refactor was not just ๐
pnpm add is-kit
It changed the structure of the guard layer.
| Observable result | Change |
|---|---|
| Error guards | 7 separate modules became 1 shared module |
| Adoption diff | 335 lines added, 584 removed |
| Net diff | 249 fewer lines |
| Direct imports today |
is-kit is isolated to 7 app helper modules |
| App reach today | Those helpers are consumed by 39 non-test source files |
The diff includes rewritten tests and helper adapters, so 249 fewer lines is not a claim that a library magically deletes code.
It is the measured result of that specific consolidation.
The more important change is the shape.
is-kit primitives
โ
app guard helpers
โ
features, routes, services, and UI
The production application does not import is-kit from every component.
Instead, most call sites use application-owned helpers.
๐ค Why Keep an Application Boundary?
For primitives, the application wraps or re-exports the library guards ๐
import {
isNumber as isFiniteNumberGuard,
isNumberPrimitive,
isString as isStringGuard,
} from "is-kit";
export const isString = isStringGuard;
export const isNumber = isNumberPrimitive;
export const isFiniteNumber = isFiniteNumberGuard;
This looks like a small detail, but it is an important design choice.
JavaScript has more than one useful meaning for โnumberโ.
typeof NaN === "number";
typeof Infinity === "number";
In the application:
-
isNumberfollows primitivetypeofsemantics -
isFiniteNumberrejectsNaNandInfinity
The application owns those names. is-kit provides the reusable implementation.
This boundary also means:
- Call sites do not depend on library naming decisions
- Semantics stay consistent across the app
- A future migration has one clear place to start
This is how I prefer to introduce small libraries into large applications.
Adopt them behind a local vocabulary.
๐ Other Real Usage Patterns
The HTTP error guards are the largest example, but not the only one.
Arrays
import { arrayOf, isNumberPrimitive } from "is-kit";
export const isNumberArray = arrayOf(isNumberPrimitive);
This replaces
const isNumberArray = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.every((item): item is number => typeof item === "number");
Literal unions
import { oneOfValues } from "is-kit";
const VIEW_MODES = ["compact", "comfortable"] as const;
const isViewMode = oneOfValues(VIEW_MODES);
declare const input: unknown;
if (isViewMode(input)) {
// "compact" | "comfortable"
input;
}
Nullish values
import { isNull, isUndefined, or } from "is-kit";
export const isNullish = or(isNull, isUndefined);
Because this is a function, it can be reused directly.
const definedItems = items.filter((item) => !isNullish(item));
The current application uses the same idea for:
- Error branching
- JSON and API-derived values
- Filtering nullable collections
- Literal-value checks
- UI values that may be strings or other renderable values
This is what production usage looks like in practice.
not one giant schema,
but many small decisions at normal control-flow points.
๐ The Practical Advantages
After using it in the application, the advantages became clearer.
1. Incremental adoption
We did not need to redesign the data layer.
A check like
typeof value === "string";
can become
isString(value);
And later, if reuse becomes useful.
values.filter(isString);
2. Less assertion casting
The old error guards repeatedly used
error as HttpClientError;
The composed version narrows once, then accesses the narrowed value normally
isHttpClientError(value) && value.response?.status === status;
3. Shared runtime semantics
Questions like these now have explicit answers:
- Does โnumberโ include
NaN? - Does this object check accept class instances?
- Is this field optional, nullable, or both?
- Are two values compared with
===orObject.issemantics?
The benefit is not shorter syntax alone.
It is fewer slightly-different answers across the codebase.
4. Normal TypeScript control flow
The result is still a function.
if (isValidationError(error)) {
error.response?.data;
}
No parse result is required.
No schema object has to travel through the application.
That makes the guards easy to use in:
iffilter- event handlers
- error boundaries
- utility functions
5. Small dependency surface
is-kit has no runtime dependencies.
That does not mean it has zero bundle cost.
It means introducing it does not bring a tree of transitive runtime packages with it.
๐ฎ It Became a Team Rule
One sign of real adoption is that the library moved beyond individual preference.
The production repository now has a contributor rule:
When combining
is-kitguards,
preferor,and,andAll,nullish, and related combinators
instead of rebuilding the same composition with native operators.
For example,
const isTextOrNumber = or(isString, isNumberPrimitive);
instead of,
const isTextOrNumber = (value: unknown) =>
isString(value) || isNumberPrimitive(value);
Both can return the same boolean.
But the first version is a named, reusable guard that can be passed around and composed again.
This rule is also used by coding agents working in the repository.
That matters because a tool is not truly adopted if every contributor, human or AI, invents a different style.
โ๏ธ What We Cannot Claim
I want to be careful here.
We did not run a controlled study showing that is-kit:
- Improved runtime performance
- Reduced production incidents
- Made every validation task easier
So I will not claim those things.
The effects we can actually see are:
- Repeated guards were consolidated
- Assertion heavy checks became composable predicates
- Primitive semantics became centralized
- Application code gained reusable narrowing functions
- The pattern became part of the repository guidelines
This is primarily a maintainability and type-safety improvement. ๐๏ธโโ๏ธ
๐ Why Not Use a Schema Library?
For these call sites, we did not need:
- Rich validation error trees
- Data transformations
- A schema-first model
We needed
โCan this
unknownvalue safely enter this branch?โ
That is exactly where a type guard fits.
For forms, API contracts, or detailed validation errors, a schema library such as Zod may still be the better tool.
They solve different problems.
๐ฏ What 50 Stars Means to Me
50 stars is small compared with the largest TypeScript libraries.
But OSS does not become meaningful only after thousands of stars.
For me, this milestone means:
- People outside the project understand the idea
- The API is useful beyond a toy example
- The library is solving a real maintenance problem
- There is still a lot to improve
And the production application gives the milestone some weight.
is-kit is not only being starred.
It is currently helping real application code answer:
โWhat is this value, and can TypeScript trust it?โ
Thank you to everyone who starred, tested, reported an issue, or simply looked at the repository.
If small composable type guards fit your TypeScript style, give it a try ๐
nyaomaru
/
is-kit
Build small guards. Compose them. Lightweight, zero-dependency TypeScript type guards for runtime validation and natural narrowing. Runtime-safe ๐ก๏ธ, composable ๐งฉ, and ergonomic โจ.
is-kit
Build small guards. Compose them.
is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.
It helps you write small isFoo functions, compose them into richer runtime checks, and keep TypeScript narrowing natural inside regular control flow.
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 - Validate object shapes and collections
-
Parse or assert
unknownvalues without a large schema framework
Best for app-internal narrowing, filtering, and reusable guards.
๐ค Why use is-kit?
Tired of rewriting the same isFoo checks again and again?
is-kit is a good fit when you want to:
-
write reusable
isXfunctions instead of one-off inline checks - keep runtime validation lightweight and dependency-free
-
narrow values directly in
if,filter, and other TypeScriptโฆ


Top comments (0)