DEV Community

Cover image for is-kit Reached 100 Stars β­πŸŽ‰ Here’s What Changed Since 50
nyaomaru
nyaomaru

Posted on AI-assisted

is-kit Reached 100 Stars β­πŸŽ‰ Here’s What Changed Since 50

Covers new APIs, Effect edge cases, and OSS usage

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:

  • What changed between 50 and 100 stars
  • Some of the most important APIs added since then
  • How I am actually using is-kit outside the original production application
  • A real bug report involving Effect.catchIf
  • What 100 stars means to me now

Let's dive in! πŸ”§

is-kit 100 stars


⭐ First, What Did 50 Stars Mean?

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;
Enter fullscreen mode Exit fullscreen mode

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. πŸ‘€


πŸ”‘ Property Refinement Became a First-Class Use Case

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;
Enter fullscreen mode Exit fullscreen mode

Maybe I already know how to validate a string

import { isString } from "is-kit";
Enter fullscreen mode Exit fullscreen mode

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.

For example

import { isString, refineKey } from "is-kit";

const hasStringName = refineKey("name", isString);
Enter fullscreen mode Exit fullscreen mode

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.


πŸ” Then 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 };
Enter fullscreen mode Exit fullscreen mode

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,
  }),
});
Enter fullscreen mode Exit fullscreen mode

The runtime behavior is useful.

But the more interesting part is the compile-time contract.

The builder checks things like:

  • Missing discriminant branches
  • Extra branches
  • Incompatible branch guards
  • Incomplete discriminant tuples

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.


πŸ’ͺ The Library Also Became Stricter

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 narrowing
  • native Error brand checks
  • array guards with overridden iterators
  • exact struct validation
  • symbol-keyed struct schemas
  • cross-realm struct schemas

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.


πŸ€– I Started Using is-kit in Other OSS Too

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),
);
Enter fullscreen mode Exit fullscreen mode

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.


πŸš€ Then an is-kit Guard Ran Into an Effect Type Bug

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 => ...
Enter fullscreen mode Exit fullscreen mode

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 πŸš€

fix(effect): Made catchIf NoInfer Refinement's first arg with generic Err param #9252

Type

  • [ ] Refactor
  • [ ] Feature
  • [x] Bug Fix
  • [ ] Optimization
  • [ ] Documentation Update

Description

When passing (arg: unknown) => arg is SomeError to some catchIfs, 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>;
Enter fullscreen mode Exit fullscreen mode

and the fix changed it to

refinement: Predicate.Refinement<NoInfer<E>, EB>;
Enter fullscreen mode Exit fullscreen mode

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"),
);
Enter fullscreen mode Exit fullscreen mode

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.


πŸ‘ This Is the Kind of Feedback I Wanted

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

  • Using the library
  • Asking questions
  • Finding edge cases
  • Reporting bugs
  • Comparing it with other validation approaches
  • Using its guards with other TypeScript libraries
  • Making me reconsider APIs I thought were already finished

That is much more valuable than the number itself. πŸ’–


πŸ’¬ The Project Is Also Becoming Easier to Explain

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:

  • Rich validation errors
  • Transformations
  • Form validation
  • A contract shared across systems

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);
Enter fullscreen mode Exit fullscreen mode

Small runtime decisions.
Normal functions.
Normal TypeScript.


🐱 What 100 Stars Means to Me

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:

  • New APIs were added
  • Existing APIs became stricter
  • Runtime edge cases were fixed
  • Package compatibility testing improved
  • I started using is-kit naturally in other OSS
  • External usage produced feedback I could not generate alone
  • An 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!

GitHub logo nyaomaru / is-kit

Build small guards. Compose them. A lightweight, zero-dependency toolkit for building reusable TypeScript type guards that compose, refine, and preserve natural narrowing. Runtime-safe πŸ›‘οΈ, composable 🧩, and ergonomic ✨.

is-kit

is-kit logo

npm version JSR npm downloads License

Build small guards. Compose them.

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.

  • Build and reuse typed guards
  • Compose guards with and, or, not, oneOf
  • Use refineKey to narrow a child property while preserving its parent type
  • Validate object shapes and collections when that is useful
  • Parse or assert unknown values without a large schema framework
Documentation Site

Best…

Thank you for reading! 🀲

Top comments (31)

Collapse
 
francistrdev profile image
FrancisTRᴅᴇᴠ •

100 stars is crazy. Well done and love the gif article cover lol. XD

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you!! 😹⭐
Honestly, making the GIF probably took me the longest out of the whole article 😹

Collapse
 
amintacccp profile image
tamina •

The Effect catchIf story is the part I'll remember. A refinement quietly widening the error channel to unknown is exactly the failure mode guards are supposed to prevent β€” and it only surfaced when two libraries met in real code, not in either one's own test suite. The NoInfer fix is elegant, too: the refinement's input shouldn't get a vote on a type that's already been decided upstream.

The discriminatedUnion compile-time checks hit the same nerve from the other side. The type evolves, the hand-written guard doesn't, and the compiler normally says nothing. That drift has bitten me before, so a builder that rejects missing or extra branches feels like the right kind of strict.

Congrats on 100 β€” the stage where people start filing real edge cases is the fun one.

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you so much!! 😸

I really liked that the issue only appeared when the two libraries met in real code, it’s exactly the kind of thing I could never have found by testing is-kit alone. 😹

And yes, the drift problem with hand-written guards is one of the main reasons I wanted discriminatedUnion to be strict at compile time. If the type changes, I really want the compiler to force me to look at the guard again.

Now that people are actually finding these kinds of edge cases, the project is becoming much more interesting for me too. Thank you for reading it so carefully! 😺⭐

Collapse
 
amintacccp profile image
tamina •

Glad the careful reading landed! One more thought on the drift angle: hand-written guards don't just rot when the type changes β€” they rot when the domain vocabulary changes too, like a new variant getting a runtime branch before anyone remembers to update the guard. Compile-time strictness turns that into a to-do list, which is a much friendlier failure mode. Rooting for 200 ⭐

Thread Thread
 
nyaomaru profile image
nyaomaru •

Exactly 😸 I really like the way you put it as β€œdomain vocabulary” changing.

A new runtime variant can appear before the TypeScript model catches up, or the other way around.

Making that mismatch a compile-time error turns it into something you have to consciously deal with instead of something that quietly drifts.

200 still feels far away, but I’ll keep going. 😹⭐

Thread Thread
 
amintacccp profile image
tamina •

Yes β€” that two-way drift is exactly why the compile-time error matters so much. A runtime variant sneaking in ahead of the model is just as easy to miss as the model racing ahead, and in both cases a quiet mismatch is worse than a loud one. Congrats again on 100 β€” if the edge cases people keep finding are what push it toward 200, that strictness is doing its job.

Thread Thread
 
nyaomaru profile image
nyaomaru •

Exactly 😸 A quiet mismatch is the scary part.

What I want from discriminatedUnion is basically to turn that drift into a loud failure while you’re still writing code, instead of discovering it later at runtime.

And thank you again! If the edge cases people find are what take is-kit toward 200, I’ll be very happy with that path. ⭐

Collapse
 
elanatframework profile image
Elanat Framework •

Unfortunately, I can only give one star. 🌟
I hope you reach 1,000 stars soon.

Collapse
 
nyaomaru profile image
nyaomaru •

Haha, one star is already enough! 😸🌟 Thank you so much!
1,000 still feels very far away, but I’ll keep going. πŸš€

Collapse
 
csm18 profile image
csm •

Congratulations πŸŽ‰

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸

Collapse
 
emilywoodsnyc profile image
Emily Woods •

The union changing while its guard stays incomplete is the example I'd keep in the docs. I'd show one added variant and the resulting compiler error right beside it. I'd also pair a coding exercise with that kind of change request: finish the first version, then change the input contract and see what the types actually protect.

Collapse
 
nyaomaru profile image
nyaomaru •

Yeah, I think that’s a great docs example 😸

Adding one new variant and showing the compiler error right next to it would make the drift problem very concrete. πŸ‘

I also like the exercise idea, changing the contract after the first implementation is probably the easiest way to see what the types actually protect.

Collapse
 
shouvik12 profile image
Shouvik Palit •

onwards and upwards !!!! Congratulations

Collapse
 
shouvik12 profile image
Shouvik Palit •

And you are becoming a celebrity on Devtv !!!

Collapse
 
nyaomaru profile image
nyaomaru •

Haha, I'm happy to be celebrity 😹⭐

Collapse
 
nyaomaru profile image
nyaomaru •

Thank you!! 😸⭐ Onwards and upwards! πŸš€

Collapse
 
akhourianmolkumar profile image
Akhouri Anmol Kumar •

congrats GitHub master @nyaomaru ....

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸

Collapse
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Congratulations πŸŽ‰πŸ”₯πŸ”₯❀️❀️ My Friend!!!!!

Collapse
 
nyaomaru profile image
nyaomaru •

Thx, my friend!!!!! 😸❀️πŸ”₯πŸŽ‰

Collapse
 
technogamerz profile image
π“π‘πž π‹πšπ³π² 𝐆𝐒𝐫π₯ •

Next target: 200 stars! Nyaomaru, good luck! 😼

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗡𝗻 •

Congratulations @nyaomaru ! πŸ˜ƒπŸŽ‰

Collapse
 
nyaomaru profile image
nyaomaru •

Thx! 😸🀝

Collapse
 
hemapriya_kanagala profile image
Hemapriya Kanagala •

Congratulations πŸ˜ƒπŸŽ‰

Collapse
 
nyaomaru profile image
nyaomaru •

Thx!! 😸

Some comments may only be visible to logged-in visitors. Sign in to view all comments.