is-kit Passes 100 Stars With New APIs, Stricter Guards, and a Merged Effect Fix
Hoi hoi!
Yay! is-kit just reached 100 GitHub Stars⭐ 🎉🎉🎉
When it reached 50 Stars, I wrote an article about how we use it in production-grade TypeScript applications serving more than 100,000 users. 👇
https://dev.to/nyaomaru/is-kit-reached-50-stars-heres-how-we-use-it-in-production-2i5b
I could have told the same story again, but that would be boring. 😹
A lot has happened since then.
The library added new APIs, some existing APIs became stricter, I started using it in my other OSS projects, and there was even a real usage that exposed a TypeScript type inference edge case in Effect.
So this time, instead of repeating the production use case, I want to talk about:
- What changed from 50 to 100 Stars
- A few important APIs added along the way
- How I use
is-kitoutside the original production app - A real bug report involving
Effect.catchIf - What 100 Stars means to me now
Let's get started! 🔧
⭐ First, what did 50 Stars mean?
When it reached 50 Stars, the main thing I wanted to say was that is-kit is not a toy project for demos.
It was already being used in real production environments. 🚀
There are many repetitive runtime checks in applications, like:
typeof value === "string";
typeof value === "number";
value === null || value === undefined;
There are also more complex custom type guards for HTTP errors, literal union types, arrays, API data, plain objects, and so on.
The interesting part is not that is-kit makes these checks shorter.
It's that it gives them a reusable form.
Small guards can be composed.
The app's own helpers can sit between the library and business code, and the final result still works with normal TypeScript control flow.
That hasn't changed.
But from 50 Stars to now, the library itself has moved forward quite a bit. 👀
🔑 Property refinement becomes an officially supported use case
I often run into a need like this:
I already have a decent type guard. I just want to apply it to a 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 say:
"This object has a
nameproperty, and its value is a string."
Sounds simple, but there are actually several different cases.
A required property is not the same as an optional property. An array index is not the same as an object property.
Also, an optional property that has been confirmed to have a value should produce a different type narrowing result from a property that is still optional.
So is-kit added three property refinement helpers:
refineKeyrefineDefinedKeyrefineIndex
The idea is simple:
Take a type guard that is already trusted and apply it to an object property or array element.
For example:
import { isString, refineKey } from "is-kit";
const hasStringName = refineKey("name", isString);
This way an existing string guard can be reused at a deeper level, without reimplementing it in another custom predicate.
This API is tiny, but I like it because it continues is-kit's design goal.
Not building a giant validator.
Instead, build small checks that can be reused in ordinary TypeScript code.
🔍 Then, discriminatedUnion became more powerful
Since the 50 Stars article, the biggest addition 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 };
Validating it at runtime is actually not hard.
Check ok, then validate the corresponding field.
But there is another problem:
Can TypeScript verify that the validator itself covers the entire union?
That's 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,
}),
});
Its runtime behavior is practical.
But the compile-time contract is more interesting.
This builder checks:
- Missing discriminant branches
- Extra branches
- Guards incompatible with a branch
- Incomplete discriminant tuples
So if the union changes, the guard cannot quietly stay incomplete.
This is important because one of the easiest ways for hand-written type guards to go wrong is drift (the type and the check logic gradually fall out of sync).
TypeScript types evolve. The runtime guard doesn't keep up. And usually the compiler doesn't know the two are no longer consistent.
discriminatedUnion is meant to make that kind of mistake harder.
💪 The library itself also became stricter
The improvements between 50 and 100 Stars were not all new features.
A lot of work went into making existing behavior more precise.
For example, recent versions fixed:
recordOf's type narrowing for literal keys- Brand checks for native
Error - Array guards whose iterator has been overridden
- Exact validation for
struct - Struct schemas using symbol keys
- Cross-realm struct schemas
Package-level tests were also added.
Now is-kit checks the published package from the perspective of ESM and CommonJS TypeScript consumers, and it also covers Deno / JSR smoke tests.
These things may not be as eye-catching in code snippets.
But I think they are very important for a small library.
The real test is whether they work correctly in all kinds of strange JavaScript and TypeScript environments.
🤖 I started using is-kit in other OSS projects
After reaching 50 Stars, I also started using is-kit more naturally in other OSS projects I maintain.
For example, in changelog-bot's changelog utility code, I use define to keep small predicates reusable and composable, instead of writing every runtime check as an isolated helper.
A simplified example is below 👇
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 specific code isn't the point.
The real change for me is that I no longer think:
"I should use my own library here."
I just naturally reach for the guard I need.
A library only starts to become genuinely useful when the maintainer no longer feels like they are just demoing it, but can directly solve problems with it.
🚀 An is-kit guard accidentally ran into a type issue with Effect
This is the most interesting thing that has happened recently.
An is-kit user used a type guard together with Effect's catchIf.
Conceptually, the refinement looks roughly like this 👇
(error: unknown): error is SomeError => ...
For is-kit, this is completely normal.
In fact, unknown is exactly the starting point this library was designed around.
But in Effect.catchIf, this refinement can unexpectedly affect generic type inference.
The error channel can be widened to unknown.
This report eventually led to the following Effect PR 👏 and it has been merged! 🚀
https://github.com/Effect-TS/effect/pull/9252
The relevant type was originally roughly:
refinement: Predicate.Refinement<E, EB>;
After the fix it became:
refinement: Predicate.Refinement<NoInfer<E>, EB>;
The key point: the refinement's input type should not redefine the error type already determined by Effect.
The PR also added type tests for cases like this:
const result = Effect.catchIf(
mixedEffect,
(e: unknown): e is AiError => e instanceof AiError,
() => Effect.succeed("ok"),
);
Before the fix, the refinement's input could participate in type inference and widen the error channel.
With NoInfer, the original error type remains the only trusted source.
The same pattern was also fixed in Channel and Stream.
There is also a Bluesky post about this issue:
https://bsky.app/profile/evadeva.bsky.social/post/3mxwiqfeo3s2j
I particularly like this bug because if I only tested is-kit itself, I would probably never have found this kind of interaction problem.
It's not that is-kit "broke Effect", nor that Effect did something absurd.
It's a subtle TypeScript type inference interaction: on one side a generic API, on the other a refinement whose input type is deliberately written very broadly.
This is exactly the kind of edge case that only appears when different libraries start meeting in real code.
👍 This is exactly the feedback I want
When is-kit reached 50 Stars, I wrote:
"Someone finds this idea useful."
That alone already made me very happy.
100 Stars still doesn't mean this library is huge.
Stars are not a benchmark.
Nor are they proof of correctness.
Nor do they mean every TypeScript project should install is-kit.
But some things have definitely changed.
People are no longer just starring the repository.
They are starting to:
- Actually use the library
- Ask questions
- Find edge cases
- Report bugs
- Compare it with other validation approaches
- Use the guards in other TypeScript libraries
- Make me rethink APIs I thought were finished
These are far more valuable than the numbers themselves. 💖
💬 The project has also become easier to explain
From 50 to 100 Stars, there is another change.
I am getting clearer about what is-kit is not.
It is not trying to replace all schema libraries. It is not trying to turn every runtime value into a schema-first workflow. It is not trying to hide TypeScript's normal control flow.
The core question is still very simple:
"Can this
unknownvalue safely enter this branch?"
Sometimes a schema library is the right choice.
For example, when I need:
- Rich validation error messages
- Transformations
- Form validation
- Contracts shared across systems
I might choose Zod, Valibot, ArkType, or other tools.
But sometimes I am already writing ordinary TypeScript code and just need a reusable narrowing function.
That is where I want is-kit to come in.
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. Ordinary functions. Ordinary TypeScript.
🐱 What 100 Stars means to me
Compared with the biggest TypeScript OSS projects, 100 Stars is still not many.
That's fine.
What matters to me is that the project is becoming more real in ways completely unrelated to the star count.
Since reaching 50 Stars:
- New APIs were added
- Existing APIs became stricter
- Runtime edge cases were fixed
- Package compatibility tests were improved
- I started naturally using
is-kitin other OSS projects - Real external usage brought feedback I could not create on my own
- An
unknown-based refinement even helped expose an inference edge case in Effect
That last one is probably my favorite.
Because my goal has never been to write a library that only looks nice in its own README!!!
The real goal is: let reusable runtime narrowing naturally fit into real TypeScript code.
And now I am starting to see it happen outside its own repository.
Thanks to everyone who starred, tried it, gave feedback, asked questions, filed issues, or just read the code. 🙏
There is still a lot I want to improve.
Still, 100 Stars feels great. 😸⭐
If small, composable type guards fit your TypeScript style, feel free to try it!
https://github.com/nyaomaru/is-kit
Thanks for reading! 🤲
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Congratulations! The type-narrowing part was explained quite clearly.