Hoi hoi!
YAY! is-kit just reached 100 GitHub stars⭐ 🎉🎉🎉
When it reached 50 stars, I wrote about how we use it in a production TypeScript application serving more than 100,000 users. 👇
I could write the same story again. But that would be boring. 😹
A lot has happened since then.
The library gained new APIs, some existing APIs became stricter, I started using it in more of my own OSS projects, and one real-world use case even exposed a type inference edge case in Effect.
So this time, instead of explaining the production adoption again, I want to look at:
is-kit outside the original production applicationEffect.catchIf
Let's dive in! 🔧
At 50 stars, the biggest thing I wanted to show was that is-kit was not only a toy project.
It was already being used in a real production application. 🚀
The application had repeated runtime checks like
typeof value === "string";
typeof value === "number";
value === null || value === undefined;
and several larger user-defined type guards for things like HTTP errors, literal unions, arrays, API values, and plain objects.
The interesting part was not that is-kit made those checks shorter.
The interesting part was that it gave them one reusable shape.
Small guards could be composed.
Application-owned helpers could sit between the library and feature code. And the result could still participate in normal TypeScript control flow.
That part has not changed.
But since 50 stars, the library itself has moved quite a bit. 👀
One thing I kept running into was this.
I already have a good type guard.
I just want to apply it to one property.
For example
declare const input: unknown;
Maybe I already know how to validate a string
import { isString } from "is-kit";
But now I want to express
"This object has a name property, and that property is a string."
That sounds simple, but there are actually a few different cases.
A required property is not the same as an optional property.
An array index is not the same as an object property.
And an optional property that becomes known to be defined should narrow differently from one that is still optional.
So is-kit added three property refinement helpers:
refineKey`` refineDefinedKey``refineIndex
The idea is simple
Take a type guard you already trust and lift it onto a property or array element.
import { isString, refineKey } from "is-kit";
const hasStringName = refineKey("name", isString);
Now the string guard is reusable at a deeper level instead of being rewritten inside another custom predicate.
This is a small API, but I like it because it follows the same design goal as the rest of is-kit.
Do not build one giant validator.
Build small checks that can be reused in normal TypeScript code.
discriminatedUnion Got Much More Serious
The biggest addition since the 50-star article is probably the exhaustive discriminated union API introduced in v1.15.0.
Consider a common TypeScript type
type Result = { ok: true; value: string } | { ok: false; error: string };
At runtime, validating this is not difficult.
You can check ok, then validate the corresponding fields.
But there is another question
Can TypeScript verify that the validator itself covers the whole union?
That is where discriminatedUnion comes in.
import { discriminatedUnion, isString, oneOfValues, typedStruct } from "is-kit";
type Result = { ok: true; value: string } | { ok: false; error: string };
const resultUnion = discriminatedUnion<Result>();
export const isResult = resultUnion("ok", [true, false], {
true: typedStruct<Extract<Result, { ok: true }>>()({
ok: oneOfValues(true),
value: isString,
}),
false: typedStruct<Extract<Result, { ok: false }>>()({
ok: oneOfValues(false),
error: isString,
}),
});
The runtime behavior is useful.
But the more interesting part is the compile-time contract.
The builder checks things like:
So if the union changes, the guard cannot silently stay incomplete.
That matters because one of the easiest ways for a hand-written type guard to become dangerous is drift.
The TypeScript type evolves.
The runtime guard does not.
And the compiler cannot normally tell you that the two are no longer aligned.
discriminatedUnion is one attempt to make that class of mistake harder.
Not every improvement between 50 and 100 stars was a new feature.
A lot of work was about making existing behavior more precise.
For example, recent releases included fixes around:
recordOf literal-key narrowingError brand checksstruct validation
There was also more package-level testing.
is-kit now checks published-package behavior from both ESM and CommonJS TypeScript consumers, and also has Deno / JSR smoke coverage.
This part is less exciting to show in a code snippet.
But for a small library, I think it is important.
Making sure they behave correctly in weird JavaScript and TypeScript environments is where things become real.
Since reaching 50 stars, I've also started using is-kit more naturally in other OSS projects I maintain.
For example, in my changelog tooling of changelog-bot, I use define to keep small predicates reusable and composable instead of turning every runtime check into an isolated helper.
A simplified example looks like this 👇
import { define, oneOfValues } from "is-kit";
const CHANGELOG_SECTIONS = ["Added", "Changed", "Fixed", "Removed"] as const;
const isChangelogSection = define<(typeof CHANGELOG_SECTIONS)[number]>(
oneOfValues(CHANGELOG_SECTIONS),
);
The exact code is not the important part.
What changed for me is that I no longer think
"I should use my library here."
I just reach for the guard I need.
A library starts becoming useful to its maintainer when it stops feeling like a demo of itself.
This was one of the most interesting things that happened recently.
An is-kit user was using a type guard with Effect's catchIf.
Conceptually, the refinement looked like this 👇
(error: unknown): error is SomeError => ...
That is completely normal for is-kit.
In fact, unknown is exactly where the library is designed to start.
But with Effect.catchIf, that refinement could affect generic inference in an unexpected way.
The error channel could become widened to unknown.
The reported case resulted in this Effect PR 👏 And merged 🚀
When passing (arg: unknown) => arg is SomeError to some catchIf s, it turned the error channel into unknown. This PR makes only self's Error slot influence the types, regardless of the type taken by refinement.
Validation: the Channel, Effect, and Stream type-test files pass on TypeScript 5.9.3 and 6.0.3, and pnpm check passes. Temporarily reverting the implementation reproduces unknown error-channel widening in Channel data-last usage and in unrelated-refinement cases across all three APIs. The matching data-first refinement cases also pass without the fix. No tests were changed during maintainer validation.
Closes EFF-2233
The relevant type originally looked roughly like this
refinement: Predicate.Refinement<E, EB>;
and the fix changed it to
refinement: Predicate.Refinement<NoInfer<E>, EB>;
The important idea is that the refinement's input type should not be allowed to redefine the error type already established by the Effect.
The PR also adds type tests for cases like
const result = Effect.catchIf(
mixedEffect,
(e: unknown): e is AiError => e instanceof AiError,
() => Effect.succeed("ok"),
);
Without the fix, the refinement input could participate in inference and widen the error channel.
With NoInfer, the original error type remains the source of truth.
The same pattern was fixed for Channel and Stream too.
There was also a Bluesky post about the issue
Not like the lib itself caught it, but when I created a reusable operator: export const isNotFoundError = and( isObject, refineDefinedKey('reason', equalsKey('_tag', 'NotFound')), ) I noticed my streams having unknown in the error channel github.com/Effect-TS/ef...
—Evangeline Rome 🌵🦎🌱🐢🐍🐸 🥦 (@evadeva.bsky.social) 2026-10-15T16:38:16.787Z
I really liked this bug because it came from an interaction I would probably never have discovered by testing is-kit alone.
is-kit did not "break Effect". And Effect was not doing something absurd either.
It was a subtle TypeScript inference interaction between a generic API and a refinement whose input was intentionally broad.
That is exactly the kind of edge case that appears when libraries start meeting each other in real code.
When is-kit reached 50 stars, I wrote
"Someone found this idea useful."
That was already enough to make me happy.
100 stars still does not mean the library is huge.
A star is still not a benchmark.
It is still not proof of correctness.
And it definitely does not mean every TypeScript project should install is-kit.
But something has changed.
People are not only starring the repository anymore.
They are
That is much more valuable than the number itself. 💖
Another thing changed between 50 and 100 stars.
I became much clearer about what is-kit is not.
It is not trying to replace every schema library.
It is not trying to turn every runtime value into a schema-first workflow.
And it is not trying to hide normal TypeScript control flow.
The core question is still very small
"Can this unknown value safely enter this branch?"
Sometimes a schema library is the right answer.
For example, if I need:
I may want Zod, Valibot, ArkType, or something else.
But sometimes I already have normal TypeScript code and I only need a reusable narrowing function.
That is where I want is-kit to be useful.
if (isUser(value)) {
value.id;
}
if (isHttpClientError(error)) {
// Handle HTTP client errors
}
items.filter(isString);
result.catch(isHttpClientError);
const isMaybeNumber = nullish(isNumber);
Small runtime decisions.
Normal functions.
Normal TypeScript.
100 stars is still small compared with the largest TypeScript OSS projects.
No problem.
What matters to me is that the project is becoming more real in ways that have nothing to do with the star counter.
Since the 50-star milestone:
unknown-based refinement even helped expose an inference edge case in Effect
That last part is probably my favorite.
Because the goal was never to build a library that only looks nice in its own README!!!
The goal is to make reusable runtime narrowing work naturally inside real TypeScript code.
And now I am starting to see that happen outside my own repository too.
Thank you to everyone who starred the project, tried it, reported something, asked a question, opened an issue, or simply read the code. 🙏
There is still a lot I want to improve.
But 100 feels pretty nice. 😸⭐
If small composable type guards fit your TypeScript style, give it a try!
is-kit is a lightweight, zero-dependency toolkit for building reusable TypeScript type guards.
is-kit is not just a collection of isX helpers. Its main focus is composing
small runtime checks into reusable guards while preserving useful TypeScript
narrowing.
It helps you write small isFoo functions, compose them into richer runtime
checks, refine properties on values you already know about, and keep
TypeScript narrowing natural inside regular control flow. Use it at runtime
boundaries when needed, without requiring a schema-first workflow.
Runtime-safe 🛡️, composable 🧩, and ergonomic ✨ without asking you to adopt a heavy schema workflow.
and, or, not, oneOf
Best…
Thank you for reading! 🤲