Looks like TS 7.1 breaks Effect in multiple ways: effect#10306 - cc @typescript
59 likes · 3 reposts · 3,956 views
Nothing quite like the high of opening an issue for Effect and getting notified of the merged PR.
effect#10094
16 likes · 1 repost · 554 views
is-kit が 100 Stars に到達したで ⭐🎉 50 Stars から何が変わったんか見てこか〜
みなさん、こんにちは!
エビ 🦐 と Type Guard のことばっかり考えているフロントエンドエンジニアの @nyaomaru です!😸
僕が開発している is-kit が、ついに GitHub 100 Stars ⭐ に到達しました〜〜〜!!!🎉🎉🎉
50 Stars に到達したときは、
「実際 Production でどう使っとるん?」
という話を、10万人以上の User が利用する TypeScript Application の実例を使って紹介しました。
dev.to
今回も、
Production ではこう使っとるで!
って同じ話をしてもええんやけど、それはさすがにサボりすぎやな 😹
ってなわけで、50 → 100 Stars の間だけでも、is-kit にはかなり色々ありました。
新しい API が増えた
既存 API がより厳密になった
自分の他の OSS でも普通に使うようになった
実際の利用から Edge Case が見つかるようになった
そして Effect の Type Inference Bug まで発見された
などなど。
なので今回は、
50 Stars の頃から is-kit は何が変わったん?
を中心に見ていこうと思います!
ほんなら、一緒に見てこか〜!
⭐ そもそも 50 Stars の頃は何を紹介してたん?
まず軽く振り返っとこか。
50 Stars のときに一番伝えたかったのは、
is-kit は README の中だけで動いてる Toy Project ちゃうで〜
ということやった。
すでに実際の Production Application で使われていて、そこには例えばこんな Runtime Check が大量にあった。
typeof value === "string";
typeof value === "number";
value === null || value === undefined;
他にも、
HTTP Error
Literal Union
Array
API Response
Plain Object
みたいな値に対する User-defined Type Guard が色んな場所に存在してた。
別にこの Code 自体が悪いわけちゃうねん。
ただ、同じような Check が色んな場所に散らばってくると、
「これ毎回書くん?」
ってなる。
そこで is-kit を使って、
小さい Guard を作る → 再利用する → Compose する
という形に整理していったわけやな。
ここは今でも is-kit の根っこの考え方として変わってへん。
🔑 Property Refinement をちゃんと扱えるようになった
開発していて何回も出会ったのが、こんなケースや。
「Guard 自体はもうあるねん。これを Object の Property に使いたいだけやねん!」
例えば、
declare const input: unknown;
この値に対して、String の Guard はすでにある。
import { isString } from "is-kit";
でも欲しいのは、
「この Object は name を持っていて、その name が String やで」
という Check やったりする。
一見めっちゃ単純そうなんやけど、よく考えると Property にも色々ある。
Required Property と Optional Property は同じちゃう。
Array Index と Object Property も同じちゃう。
さらに Optional Property でも、
「存在するなら String」
と
「ここまで Narrowing した結果、もう必ず存在する String」
では Type として意味が違う。
めんどくさ!😹
でも、Type System 的にはここ雑にしたらアカンとこやな。
そこで is-kit に追加したのが、
refineKey
refineDefinedKey
refineIndex
の3つや。
考え方はめちゃシンプル。
すでに持っている Type Guard を、Property や Array Element に持ち上げる
だけ。
例えば、
import { isString, refineKey } from "is-kit";
const hasStringName = refineKey("name", isString);
これで isString を、別の Custom Predicate の中でまた手書きせずに再利用できる。
小さい API やけど、個人的にはかなり好きや。
というのも、is-kit の基本思想とめっちゃ合ってるから。
巨大な Validator を一個作るんやなくて、
小さい Check を作って、普通の TypeScript Code の中で使い回す。
これやな。
🔍 discriminatedUnion がだいぶ本気になった
50 Stars 以降で、一番大きい追加機能は多分これ。
v1.15.0 で追加した Exhaustive Discriminated Union Guard や。
例えば TypeScript でよくある、
type Result = { ok: true; value: string } | { ok: false; error: string };
こういう Type。
Runtime で Validate するだけなら、そんなに難しくない。
ok 見て、
true なら value
false なら error
を Check したらええ。
でも、ここで一個問題があry。
その Validator 自体が、本当に Union 全部を Cover しとるん?
という話。
例えば後から、
| { ok: "pending"; retryAfter: number }
みたいなのが増えたのに、Runtime Guard 側を直すの忘れたらどうするん?
Type は進化した。
Guard は昔のまま。
こういうのが、いわゆる Type と Runtime Guard の Drift やな。
そこで discriminatedUnion。
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,
}),
});
Runtime Validation ができるのはもちろんなんやけど、個人的に重要なのは Compile Time 側。
例えば、
Branch が足りない
余計な Branch がある
Branch Guard の Type が合ってない
Discriminant Tuple が不完全
みたいなのを Compiler が検知してくれます。
つまり、
Type だけ更新して Guard 更新すんの忘れてたわ〜 🤪
が起きにくくなる。
Hand-written Type Guard で一番怖いところって、
書いた瞬間に間違ってることより、半年後に Type だけ変わって Guard が古くなること
やと思ってます。
なのでここは、is-kit として結構大きい進化でした。
💪 新機能だけやなくて、地味にめっちゃ厳しくなった
50 → 100 Stars の間、別に Feature ばっかり作ってたわけではないです。
むしろ、
「この JavaScript の変なケースどうすんねん」
みたいなのを潰してる時間も結構長かった。😹
最近だけでも例えば、
recordOf の Literal-key Narrowing
Native Error Brand Check
Iterator が Override された Array
Exact struct
Symbol-keyed Struct Schema
Cross-realm Struct Schema
あたりを修正してる。
あと Package Level の Test も増やしました。
今は Published Package を、
ESM
CommonJS
Deno
JSR
あたりから実際に Consume して Smoke Test しています。
これ、記事映えはせえへんねん。
「Deno の Smoke Test 増やしました!!!」って言われても、
お、おう・・・
ってなるやろ?😹
でも Library を作ってると、こういうところがめっちゃ大事になってくる。
API を増やすのは比較的簡単や。
でも、
変な JavaScript Environment でもちゃんと動くん?
を確認し始めたところから、OSS Library っぽさが一気に増す気がする。
🤖 自分の他の OSS でも普通に使うようになった
50 Stars の記事では、一番大きな Real-world Example として Production Application を紹介した。
でも最近は、自分が作ってる別の OSS でも普通に is-kit を使ってるな。
例えば changelog-bot。
Changelog の Section を Check するときも、
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),
);
みたいな感じで、小さい Predicate を作って使ってるねんな。
で、ここで個人的に面白い変化があった。
前までは、
「自分の Library やし、ここで is-kit 使ってみよかな」
って考えてたんよ。
でも最近はそうじゃなくて、
「ここ Guard 欲しいな」
↓
is-kit import
みたいになってる。
自分の Library を使ってる意識が薄れてきた。
これ、結構大きい変化やと思ってます。
Library が自分自身の Demo に見えなくなって、
普通の道具になってきた
ってことやからな。
🚀 そして is-kit の Guard が Effect の Bug を踏んだ
最近あった中で、一番「おもろ!」ってなったのがこれや!!
ある is-kit User が、Effect.catchIf に Type Guard を渡して使ってた。
概念的にはこんな感じ。
(error: unknown): error is SomeError => ...
これ、is-kit 的にはめちゃくちゃ普通や。
というか、
まだ信用してない値なんやから Input は unknown でええやん
というのが is-kit の考え方やな。
ところが Effect.catchIf にこれを渡したとき、Generic Inference にちょっと面白いことが起きた。
Error Channel が unknown に Widen してしまうケースがあった。
そして、それがこの PR につながった。
effect#9252
関連部分は元々ざっくり、
refinement: Predicate.Refinement<E, EB>;
だったものが、
refinement: Predicate.Refinement<NoInfer<E>, EB>;
に変わってる。
何が起きてたかというと、Refinement 側の Input Type が、
unknown;
やから、この unknown が Generic Inference に参加してしまい、
「ほな Error Type は unknown なんかな?」
みたいな方向に Type が引っ張られる可能性があった。
いや、お前はそこで自己主張せんでええねん 😹
元々 Effect 側ですでに Error Type は分かってるんやから、それを Source of Truth にしたい。
そこで NoInfer<E> を使って、
「ここから E 推論せんでええで〜」
としたわけやな。
PR にはこんな Type Test も追加されてる。
const result = Effect.catchIf(
mixedEffect,
(e: unknown): e is AiError => e instanceof AiError,
() => Effect.succeed("ok"),
);
同じ Pattern は Channel と Stream にも修正が入った。
この件について Bluesky でも Post してくれた。
bsky.app
これが個人的にめっちゃ面白かった。
なぜなら、
is-kit 単体を一生 Test してても、多分自分では見つけられへん Bug
やったから。
もちろん、
is-kit が Effect を壊した!
って話やないで。
Effect 側がヤバい Code 書いてた!
って話でもない。
Generic API と、意図的に広い Input を持つ Refinement が出会ったことで発生した、かなり繊細な TypeScript Inference の Edge Case やった。
でもこういうのって、
Library が実際の Ecosystem の中で使われ始めたからこそ出てくる問題
なんよな。
そこがめっちゃおもろかったな。
👍 こういう Feedback が欲しかったんよな
50 Stars の記事では、
「誰かがこの Idea を便利だと思ってくれた」
ということ自体が嬉しいと書いた。
今もそれは変わってへん。
100 Stars になったからって、突然 is-kit が巨大 OSS になったわけでもない。
Star は Benchmark でもない。
Correctness の証明でもない。
すべての TypeScript Project が is-kit を使うべき!
なんてことも当然ない。
でも一つ変わったのは、
Star を付けてもらうだけじゃなくなった
ということや。
実際に、
使ってもらう
質問してもらう
Edge Case を見つけてもらう
Bug Report が来る
他の Validation Library と比較される
他 Library と組み合わせて使われる
自分が完成したと思ってた API を考え直す
みたいなことが起きるようになった。
こっちの方が、Star の数字そのものより何倍も嬉しいな! 💖
💬 is-kit が何じゃないのかも分かってきた
もう一つ、50 → 100 Stars の間でかなり整理できたことがある。
それは、
is-kit は何を目指してへんの?
というところ。
is-kit は、
すべての Schema Library を置き換えたいわけではありません。
すべての Runtime Value を Schema-first にしたいわけでもない。
TypeScript の普通の Control Flow を隠したいわけでもない。
結局やりたいことは、めちゃくちゃ小さい。
「この unknown、信用してええんか?」
これだけ。
例えば、
Rich な Validation Error が欲しい
Transformation したい
Form Validation したい
System 間で Schema を共有したい
みたいな場合は、
Zod、Valibot、ArkType
とかの方が向いてることも普通にある。
一方で、
「いや、普通の TypeScript Code はもうあんねん。ここで Narrowing したいだけやねん」
ってこともある。
そこが is-kit の場所やな。
if (isUser(value)) {
value.id;
}
items.filter(isString);
result.catch(isHttpClientError);
const isMaybeNumber = nullish(isNumber);
Small runtime decisions.
Normal functions.
Normal TypeScript.
そこを大事にしたい。
🐱 100 Stars が自分にとって意味すること
100 Stars。
巨大な TypeScript OSS と比べたら、全然小さい数字や。
でも、ええやん。😸
自分にとって大事なのは、Star Counter 以外のところで Project がどんどん Real になってることや。
50 Stars 以降だけでも、
新しい API が増えた
既存 API が厳密になった
Runtime Edge Case を修正した
Package Compatibility Test が増えた
自分の他の OSS でも普通に使うようになった
自分だけでは見つけられない Feedback が来るようになった
unknown Refinement から Effect の Inference Edge Case まで見つかった
という変化があった。
ほんで最後のやつは、一番お気に入りや。
そもそもワイが作りたかったのは、
README の Code Example だけで綺麗に動く Library
やない!!!
実際の TypeScript Code の中で、Runtime Narrowing を普通に再利用できる Library
を作りたかった。
そして最近、それが自分の Repository の外でも少しずつ起き始めています。
Star してくれた人、
使ってくれた人、
Issue を作ってくれた人、
Bug を教えてくれた人、
質問してくれた人、
Code をちらっと見てくれただけの人も、
みんな、本当にありがとうございます!🙏
まだまだ改善したいところは山ほどあります。
でも・・・
100 Stars は普通に嬉しいわ!!!😸⭐
小さい Composable Type Guard が自分の TypeScript Style に合いそうやな〜と思ったら、ぜひ一回触ってみてな!
nyaomaru/is-kit
Thank you for reading!🤲
is-kit Reached 100 Stars ⭐🎉 Here’s What Changed Since 50
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. 👇
{% embed dev.to %}
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;
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;
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.
For example
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.
---
🔍 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 };
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:
- 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),
);
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 => ...
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 🚀
{% embed effect#9252 %}
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
{% embed bsky.app %}
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);
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!
{% embed nyaomaru/is-kit %}
Thank you for reading! 🤲
88 reactions · 23 comments
added Clairic to Effect - pls hmu if you have opinions about API design effect#9625
9 likes · 732 views
Type a pipe() That Checks Every Step: Variadic Tuples, infer and One Recursive Type
- Book: The TypeScript Type System
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
Open Effect's Function.ts and count the signatures of pipe. There are 20: one for a bare value, then one more for every extra function, up to 19. RxJS 7's standalone pipe types nine functions and then falls back to a catch-all signature that returns UnaryFunction<T, unknown>. Hand it a tenth function and the output type is gone.
Those libraries know exactly what they are doing. When TypeScript 4.0 shipped variadic tuple types, the release notes called this pattern "death by a thousand overloads", and overloads are still how the big libraries type pipe. The reason is that a single generic signature that checks every step is harder to write than it looks, and it gives some things up.
You're going to write that single signature anyway: about 40 lines of types, checked by tsc --noEmit, and then the four places it breaks so you know when overloads win.
Step 1: read the ends with infer
Start with four small helpers. In and Out pull the parameter and return type out of a one-argument function. First and Last pull the first and last element out of a tuple.
// pipe.ts
export type Fn = (arg: any) => any;
type In<F> = F extends (arg: infer A) => any ? A : never;
type Out<F> = F extends (arg: any) => infer R ? R : never;
type First<T extends unknown[]> =
T extends [infer F, ...unknown[]] ? F : never;
type Last<T extends unknown[]> =
T extends [...unknown[], infer L] ? L : never;
Last matches with a leading rest element, [...unknown[], infer L], which TypeScript has accepted since 4.2. With these you can write the obvious pipe type: the input of the first function, the output of the last.
type NaivePipe<Fns extends Fn[]> =
(input: In<First<Fns>>) => Out<Last<Fns>>;
It reads the ends and never looks at the middle. Give it trim (string to string), len (string to number) and shout (string to string) and it reports (input: string) => string, even though shout would receive a number at runtime. A pipe type that only reads the ends is an as cast with better marketing.
!A pipeline of joined segments where only the first and last joints are inspected and a mismatched joint in the middle goes unnoticed
Reading only the first input and the last output leaves every joint in the middle unchecked.
Step 2: walk the list with one recursive type
The real check has to visit every adjacent pair. Recursive conditional types (TypeScript 4.1) let a type refer to itself in its own branches, so you peel one function off the front, compare its input with the value flowing in, and recurse on the rest.
type PipeError<Step, Got, Want> = {
readonly error: "pipe(): a step got the wrong input";
readonly step: Step;
readonly got: Got;
readonly want: Want;
};
type Check<
Fns extends Fn[],
Input,
Cur,
Done extends unknown[] = []
> = Fns extends [infer F, ...infer Rest extends Fn[]]
? [Cur] extends [In<F>]
? Check<Rest, Input, Out<F>, [...Done, F]>
: PipeError<[...Done, F]["length"], Cur, In<F>>
: (input: Input) => Cur;
export type Pipe<Fns extends [Fn, ...Fn[]]> = Check<
Fns,
In<First<Fns>>,
In<First<Fns>>
>;
Four details carry the weight here.
infer Rest extends Fn[] puts a constraint on the inferred rest, a feature from TypeScript 4.7. Without it, Rest can't be passed back into Check, whose first parameter demands Fn[].
[Cur] extends [In<F>] wraps both sides in brackets on purpose. A bare Cur extends ... on a type parameter is distributive: if a step returns string | number, the check would split into one branch per member and hand you a union of a function and an error. The brackets compare the whole union at once.
Done collects the functions already visited. Its only job is to number the failing step: [...Done, F]["length"] is the 1-based position of F as a number literal type.
The recursive call is the entire branch result. Nothing wraps it, and that makes Check tail-recursive, which matters when chains get long.
The runtime part is short:
export function pipe<Fns extends [Fn, ...Fn[]]>(
...fns: Fns
): Pipe<Fns> {
const run = (input: unknown) =>
fns.reduce((acc: unknown, fn) => fn(acc), input);
return run as unknown as Pipe<Fns>;
}
Fns extends [Fn, ...Fn[]] on a rest parameter makes TypeScript infer the arguments as a tuple, one element per function, which is what Check walks. The double cast is the honest part: the body can't prove a deferred conditional type, so you assert once at the boundary and callers get the checked type. The overloaded libraries pay the same toll in their own way; Effect's implementation signature is pipe(a: unknown, ...args: Array<any>): unknown.
Step 3: prove it with @ts-expect-error
A type you can't test is a type you will break by accident. // @ts-expect-error (TypeScript 3.9) suppresses the error on the next line, and if that line has no error, tsc reports Unused '@ts-expect-error' directive. That turns every expected failure into an assertion.
// pipe.types.ts
import { pipe } from "./pipe";
type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends
(<T>() => T extends B ? 1 : 2)
? true
: false;
type Expect<T extends true> = T;
const trim = (s: string) => s.trim();
const len = (s: string) => s.length;
const isLong = (n: number) => n > 10;
const shout = (s: string) => s.toUpperCase();
const ok = pipe(trim, len, isLong);
export type OkIsTyped = Expect<
Equal<typeof ok, (input: string) => boolean>
>;
// @ts-expect-error the input must be a string
ok(42);
const broken = pipe(trim, len, shout);
// @ts-expect-error step 3 wants a string, gets a number
broken(" hello ");
// @ts-expect-error a broken pipe is not a function
const f: (s: string) => string = broken;
// @ts-expect-error pipe() needs at least one step
pipe();
Equal compares two types strictly, so a result that drifts to any or to a wider type fails it; Expect fails to compile when handed false. A minimal tsconfig.json next to the two files:
{
"compilerOptions": {
"strict": true,
"noEmit": true,
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler"
},
"include": ["pipe.ts", "pipe.types.ts"]
}
Install the compiler with npm i -D typescript, then run npx tsc. The config needs TypeScript 5.0 or newer for "moduleResolution": "bundler"; the types themselves need 4.7. If any expectation stops holding, either an Expect line or an unused directive fails the build, locally or in CI.
Where the recursive version breaks
Look again at the broken test. The pipe(trim, len, shout) line compiles. The type of broken is PipeError<3, number, string>, and the error only appears where you call it or assign it to a function type. Hovering broken tells you the step, what it got and what it wanted, which beats a silent unknown, but the red squiggle sits on the wrong line. Overloads don't have this problem: each step is its own parameter, so a mismatch is an argument error on the exact function that's wrong. There is a variant that rewrites the rest parameter to the expected tuple so the error moves onto the bad argument; ecyrbe's write-up shows one. I haven't run it, and a commenter there reports a gap in its first-to-second step check, so test it the same way before you trust it.
Generic steps lose their type parameter. Take const wrap = <T>(x: T): T[] => [x]. When In and Out match a generic signature against infer, the type parameter doesn't survive; TypeScript issue #38964 shows ReturnType of <T = string>() => T coming out as unknown. So pipe(trim, wrap, join), with join taking string[], fails with got: unknown[]. The fix is an instantiation expression (4.7): pass wrap<string> as the step and the chain checks. Overloads get this for free, because each step is matched against (a: A) => B with A already known, and since TypeScript 3.4 a composer can even propagate a generic step's type parameters into the result.
Inline lambdas aren't typed from the previous step. In Effect, pipe(" hi ", (s) => s.trim(), (s) => s.length) types each s from the step before it, because every overload names the intermediate types as separate type parameters. In ...fns: Fns, nothing ties the second slot's parameter to the first slot's return, so an unannotated (s) => s.length has no way to learn that s is a string. The best it gets is the any from the Fn constraint, and an any step checks nothing. Annotate every inline step, or write the steps as named functions.
Long chains hit the depth limit, depending on how you wrote the recursion. The TypeScript PR that added tail-recursion elimination lets tail-recursive conditional types loop 1,000 times before giving up, and lowered the general limit for the rest to 100. Check is tail-recursive, so a 30-step pipe is nowhere near the ceiling. Rewrite it so the recursive call sits inside something else, a tuple spread or a union, and you are back under the general limit, where the failure is the familiar Type instantiation is excessively deep and possibly infinite from the 4.5 release notes. Even well under the limit, the 4.1 notes warn that recursive types can increase type-checking time, so keep the step types themselves simple.
When overloads are still the better choice
!A tall stack of near-identical cards on one side and a single looping arrow passing through a row of function blocks on the other
Overloads write one signature per chain length. The recursive type writes one signature that walks the list.
The rule that falls out of the four breaks: choose by who writes the steps.
If callers write steps inline, as lambdas, or pass generic helpers like map and filter, use overloads. That is the RxJS and Effect situation, where most steps are operators with type parameters and most callbacks are unannotated. Contextual typing per step and per-argument errors are worth more than an unlimited chain length, and very few real pipelines need more than the 19 functions Effect covers.
If callers pass named, already-typed, non-generic functions, use the recursive type. Validation chains, middleware lists and data transforms assembled from a registry all fit: the steps already carry their own types, chains can be longer than any overload stack, and the PipeError type names the failing step. Ship it with the @ts-expect-error file so the next refactor can't quietly break the check.
---
If this was useful
The recursive Check type is a small version of a pattern that keeps coming back: walk a tuple with infer, carry state in an accumulator, keep the recursion in tail position. The TypeScript Type System goes deep on that toolkit: generics, mapped and conditional types, infer, template literals, branded types. If you already write TypeScript daily, that's the one to read next; if you're still getting comfortable with narrowing and modules, start with TypeScript Essentials.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
Coming from Kotlin, Java or PHP? The two bridge books replace the first two for you. Shipping TypeScript at work, with build tooling and published packages? TypeScript in Production covers that layer.
All five books ship in ebook, paperback, and hardcover.

5 reactions · 1 comment
Show HN: Endo – A Higher-Order Harness
Endo is a Higher-Order Harness (HOH).
From the easiest concept to understand to the more complex ones, Endo gives you:
* A single, simple UI to use any agent provider with your existing subscriptions, and hot-swap agents right in the middle of a session.
* An orchestration layer, which I call HOH, allowing arbitrary composition of agents to achieve any workflow. A fun case I tried was asking Fable and Astra to mock a presidential debate.
* An agent protocol that abstracts any agent. The key insight here is that every LLM is an agent—instead of treating the LLM as just one node inside an external ReAct loop.
Endo was a concept formed while I was building OpenChart (longsurf-ai/openchart). To power trading workflows, I needed to support scenarios where strict determinism is required, while still keeping the flexibility of an agent's inference-time decision-making capability. A new form factor was needed between rigid n8n-style workflows and open-ended ReAct loop agents.
Thus the creation of Endo. Endo is short for Endomorphism, a term rooted in category theory and functional programming. An endomorphism is a function that transforms an object into another object of the same type. Similarly, Endo itself is built purely on functional programming principles, relying heavily on Effect (Effect-TS/effect).
..And if you have patience to speak technical jargons, Endo is:
* An agent orchestration layer—Higher-Order Harness (HOH)—where an entire agent is treated the same as a single LLM call, enabling arbitrary composition of extremely complex workflows.
* A way to abstract any harness into a standardized protocol, where raw models and agents behave the exact same way, in a KV-cache-friendly manner.
* A workflow engine that enables arbitrary composition of agents with a single workflow language.
It's completely open source, and if you think this idea is cool, let's build together. Just for the fun of it.
6 points
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
effect#9252
1 like · 1 repost
The `effect/net` family is growing. We've now got Datagram sockets (UDP) and a DNS resolver in core with accompanying data types and schemas for dns records & host / domain name handling: effect#8880 and effect#8409
23 likes · 2 reposts · 841 views
optic面白いな。オブジェクトや配列の特定のキーに対するgetter/setterを作れる。subscriptionRefと組み合わせればフロントエンド側で必要な部分だけを購読できるので再レンダリングが必要最小限になって最高。
github.com/Effect-TS/effect/blob/main/packages/ef…
Mentions
Looks like TS 7.1 breaks Effect in multiple ways: effect#10306 - cc @typescript
Nothing quite like the high of opening an issue for Effect and getting notified of the merged PR. effect#10094
is-kit が 100 Stars に到達したで ⭐🎉 50 Stars から何が変わったんか見てこか〜 みなさん、こんにちは! エビ 🦐 と Type Guard のことばっかり考えているフロントエンドエンジニアの @nyaomaru です!😸 僕が開発している is-kit が、ついに GitHub 100 Stars ⭐ に到達しました〜〜〜!!!🎉🎉🎉 50 Stars に到達したときは、 「実際 Production でどう使っとるん?」 という話を、10万人以上の User が利用する TypeScript Application の実例を使って紹介しました。 dev.to 今回も、 Production ではこう使っとるで! って同じ話をしてもええんやけど、それはさすがにサボりすぎやな 😹 ってなわけで、50 → 100 Stars の間だけでも、is-kit にはかなり色々ありました。 新しい API が増えた 既存 API がより厳密になった 自分の他の OSS でも普通に使うようになった 実際の利用から Edge Case が見つかるようになった そして Effect の Type Inference Bug まで発見された などなど。 なので今回は、 50 Stars の頃から is-kit は何が変わったん? を中心に見ていこうと思います! ほんなら、一緒に見てこか〜! ⭐ そもそも 50 Stars の頃は何を紹介してたん? まず軽く振り返っとこか。 50 Stars のときに一番伝えたかったのは、 is-kit は README の中だけで動いてる Toy Project ちゃうで〜 ということやった。 すでに実際の Production Application で使われていて、そこには例えばこんな Runtime Check が大量にあった。 typeof value === "string"; typeof value === "number"; value === null || value === undefined; 他にも、 HTTP Error Literal Union Array API Response Plain Object みたいな値に対する User-defined Type Guard が色んな場所に存在してた。 別にこの Code 自体が悪いわけちゃうねん。 ただ、同じような Check が色んな場所に散らばってくると、 「これ毎回書くん?」 ってなる。 そこで is-kit を使って、 小さい Guard を作る → 再利用する → Compose する という形に整理していったわけやな。 ここは今でも is-kit の根っこの考え方として変わってへん。 🔑 Property Refinement をちゃんと扱えるようになった 開発していて何回も出会ったのが、こんなケースや。 「Guard 自体はもうあるねん。これを Object の Property に使いたいだけやねん!」 例えば、 declare const input: unknown; この値に対して、String の Guard はすでにある。 import { isString } from "is-kit"; でも欲しいのは、 「この Object は name を持っていて、その name が String やで」 という Check やったりする。 一見めっちゃ単純そうなんやけど、よく考えると Property にも色々ある。 Required Property と Optional Property は同じちゃう。 Array Index と Object Property も同じちゃう。 さらに Optional Property でも、 「存在するなら String」 と 「ここまで Narrowing した結果、もう必ず存在する String」 では Type として意味が違う。 めんどくさ!😹 でも、Type System 的にはここ雑にしたらアカンとこやな。 そこで is-kit に追加したのが、 refineKey refineDefinedKey refineIndex の3つや。 考え方はめちゃシンプル。 すでに持っている Type Guard を、Property や Array Element に持ち上げる だけ。 例えば、 import { isString, refineKey } from "is-kit"; const hasStringName = refineKey("name", isString); これで isString を、別の Custom Predicate の中でまた手書きせずに再利用できる。 小さい API やけど、個人的にはかなり好きや。 というのも、is-kit の基本思想とめっちゃ合ってるから。 巨大な Validator を一個作るんやなくて、 小さい Check を作って、普通の TypeScript Code の中で使い回す。 これやな。 🔍 discriminatedUnion がだいぶ本気になった 50 Stars 以降で、一番大きい追加機能は多分これ。 v1.15.0 で追加した Exhaustive Discriminated Union Guard や。 例えば TypeScript でよくある、 type Result = { ok: true; value: string } | { ok: false; error: string }; こういう Type。 Runtime で Validate するだけなら、そんなに難しくない。 ok 見て、 true なら value false なら error を Check したらええ。 でも、ここで一個問題があry。 その Validator 自体が、本当に Union 全部を Cover しとるん? という話。 例えば後から、 | { ok: "pending"; retryAfter: number } みたいなのが増えたのに、Runtime Guard 側を直すの忘れたらどうするん? Type は進化した。 Guard は昔のまま。 こういうのが、いわゆる Type と Runtime Guard の Drift やな。 そこで discriminatedUnion。 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, }), }); Runtime Validation ができるのはもちろんなんやけど、個人的に重要なのは Compile Time 側。 例えば、 Branch が足りない 余計な Branch がある Branch Guard の Type が合ってない Discriminant Tuple が不完全 みたいなのを Compiler が検知してくれます。 つまり、 Type だけ更新して Guard 更新すんの忘れてたわ〜 🤪 が起きにくくなる。 Hand-written Type Guard で一番怖いところって、 書いた瞬間に間違ってることより、半年後に Type だけ変わって Guard が古くなること やと思ってます。 なのでここは、is-kit として結構大きい進化でした。 💪 新機能だけやなくて、地味にめっちゃ厳しくなった 50 → 100 Stars の間、別に Feature ばっかり作ってたわけではないです。 むしろ、 「この JavaScript の変なケースどうすんねん」 みたいなのを潰してる時間も結構長かった。😹 最近だけでも例えば、 recordOf の Literal-key Narrowing Native Error Brand Check Iterator が Override された Array Exact struct Symbol-keyed Struct Schema Cross-realm Struct Schema あたりを修正してる。 あと Package Level の Test も増やしました。 今は Published Package を、 ESM CommonJS Deno JSR あたりから実際に Consume して Smoke Test しています。 これ、記事映えはせえへんねん。 「Deno の Smoke Test 増やしました!!!」って言われても、 お、おう・・・ ってなるやろ?😹 でも Library を作ってると、こういうところがめっちゃ大事になってくる。 API を増やすのは比較的簡単や。 でも、 変な JavaScript Environment でもちゃんと動くん? を確認し始めたところから、OSS Library っぽさが一気に増す気がする。 🤖 自分の他の OSS でも普通に使うようになった 50 Stars の記事では、一番大きな Real-world Example として Production Application を紹介した。 でも最近は、自分が作ってる別の OSS でも普通に is-kit を使ってるな。 例えば changelog-bot。 Changelog の Section を Check するときも、 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), ); みたいな感じで、小さい Predicate を作って使ってるねんな。 で、ここで個人的に面白い変化があった。 前までは、 「自分の Library やし、ここで is-kit 使ってみよかな」 って考えてたんよ。 でも最近はそうじゃなくて、 「ここ Guard 欲しいな」 ↓ is-kit import みたいになってる。 自分の Library を使ってる意識が薄れてきた。 これ、結構大きい変化やと思ってます。 Library が自分自身の Demo に見えなくなって、 普通の道具になってきた ってことやからな。 🚀 そして is-kit の Guard が Effect の Bug を踏んだ 最近あった中で、一番「おもろ!」ってなったのがこれや!! ある is-kit User が、Effect.catchIf に Type Guard を渡して使ってた。 概念的にはこんな感じ。 (error: unknown): error is SomeError => ... これ、is-kit 的にはめちゃくちゃ普通や。 というか、 まだ信用してない値なんやから Input は unknown でええやん というのが is-kit の考え方やな。 ところが Effect.catchIf にこれを渡したとき、Generic Inference にちょっと面白いことが起きた。 Error Channel が unknown に Widen してしまうケースがあった。 そして、それがこの PR につながった。 effect#9252 関連部分は元々ざっくり、 refinement: Predicate.Refinement<E, EB>; だったものが、 refinement: Predicate.Refinement<NoInfer<E>, EB>; に変わってる。 何が起きてたかというと、Refinement 側の Input Type が、 unknown; やから、この unknown が Generic Inference に参加してしまい、 「ほな Error Type は unknown なんかな?」 みたいな方向に Type が引っ張られる可能性があった。 いや、お前はそこで自己主張せんでええねん 😹 元々 Effect 側ですでに Error Type は分かってるんやから、それを Source of Truth にしたい。 そこで NoInfer<E> を使って、 「ここから E 推論せんでええで〜」 としたわけやな。 PR にはこんな Type Test も追加されてる。 const result = Effect.catchIf( mixedEffect, (e: unknown): e is AiError => e instanceof AiError, () => Effect.succeed("ok"), ); 同じ Pattern は Channel と Stream にも修正が入った。 この件について Bluesky でも Post してくれた。 bsky.app これが個人的にめっちゃ面白かった。 なぜなら、 is-kit 単体を一生 Test してても、多分自分では見つけられへん Bug やったから。 もちろん、 is-kit が Effect を壊した! って話やないで。 Effect 側がヤバい Code 書いてた! って話でもない。 Generic API と、意図的に広い Input を持つ Refinement が出会ったことで発生した、かなり繊細な TypeScript Inference の Edge Case やった。 でもこういうのって、 Library が実際の Ecosystem の中で使われ始めたからこそ出てくる問題 なんよな。 そこがめっちゃおもろかったな。 👍 こういう Feedback が欲しかったんよな 50 Stars の記事では、 「誰かがこの Idea を便利だと思ってくれた」 ということ自体が嬉しいと書いた。 今もそれは変わってへん。 100 Stars になったからって、突然 is-kit が巨大 OSS になったわけでもない。 Star は Benchmark でもない。 Correctness の証明でもない。 すべての TypeScript Project が is-kit を使うべき! なんてことも当然ない。 でも一つ変わったのは、 Star を付けてもらうだけじゃなくなった ということや。 実際に、 使ってもらう 質問してもらう Edge Case を見つけてもらう Bug Report が来る 他の Validation Library と比較される 他 Library と組み合わせて使われる 自分が完成したと思ってた API を考え直す みたいなことが起きるようになった。 こっちの方が、Star の数字そのものより何倍も嬉しいな! 💖 💬 is-kit が何じゃないのかも分かってきた もう一つ、50 → 100 Stars の間でかなり整理できたことがある。 それは、 is-kit は何を目指してへんの? というところ。 is-kit は、 すべての Schema Library を置き換えたいわけではありません。 すべての Runtime Value を Schema-first にしたいわけでもない。 TypeScript の普通の Control Flow を隠したいわけでもない。 結局やりたいことは、めちゃくちゃ小さい。 「この unknown、信用してええんか?」 これだけ。 例えば、 Rich な Validation Error が欲しい Transformation したい Form Validation したい System 間で Schema を共有したい みたいな場合は、 Zod、Valibot、ArkType とかの方が向いてることも普通にある。 一方で、 「いや、普通の TypeScript Code はもうあんねん。ここで Narrowing したいだけやねん」 ってこともある。 そこが is-kit の場所やな。 if (isUser(value)) { value.id; } items.filter(isString); result.catch(isHttpClientError); const isMaybeNumber = nullish(isNumber); Small runtime decisions. Normal functions. Normal TypeScript. そこを大事にしたい。 🐱 100 Stars が自分にとって意味すること 100 Stars。 巨大な TypeScript OSS と比べたら、全然小さい数字や。 でも、ええやん。😸 自分にとって大事なのは、Star Counter 以外のところで Project がどんどん Real になってることや。 50 Stars 以降だけでも、 新しい API が増えた 既存 API が厳密になった Runtime Edge Case を修正した Package Compatibility Test が増えた 自分の他の OSS でも普通に使うようになった 自分だけでは見つけられない Feedback が来るようになった unknown Refinement から Effect の Inference Edge Case まで見つかった という変化があった。 ほんで最後のやつは、一番お気に入りや。 そもそもワイが作りたかったのは、 README の Code Example だけで綺麗に動く Library やない!!! 実際の TypeScript Code の中で、Runtime Narrowing を普通に再利用できる Library を作りたかった。 そして最近、それが自分の Repository の外でも少しずつ起き始めています。 Star してくれた人、 使ってくれた人、 Issue を作ってくれた人、 Bug を教えてくれた人、 質問してくれた人、 Code をちらっと見てくれただけの人も、 みんな、本当にありがとうございます!🙏 まだまだ改善したいところは山ほどあります。 でも・・・ 100 Stars は普通に嬉しいわ!!!😸⭐ 小さい Composable Type Guard が自分の TypeScript Style に合いそうやな〜と思ったら、ぜひ一回触ってみてな! nyaomaru/is-kit Thank you for reading!🤲
is-kit Reached 100 Stars ⭐🎉 Here’s What Changed Since 50 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. 👇 {% embed dev.to %} 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; 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; 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. For example 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. --- 🔍 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 }; 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: - 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), ); 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 => ... 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 🚀 {% embed effect#9252 %} 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 {% embed bsky.app %} 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); 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! {% embed nyaomaru/is-kit %} Thank you for reading! 🤲
added Clairic to Effect - pls hmu if you have opinions about API design effect#9625
Type a pipe() That Checks Every Step: Variadic Tuples, infer and One Recursive Type - Book: The TypeScript Type System - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- Open Effect's Function.ts and count the signatures of pipe. There are 20: one for a bare value, then one more for every extra function, up to 19. RxJS 7's standalone pipe types nine functions and then falls back to a catch-all signature that returns UnaryFunction<T, unknown>. Hand it a tenth function and the output type is gone. Those libraries know exactly what they are doing. When TypeScript 4.0 shipped variadic tuple types, the release notes called this pattern "death by a thousand overloads", and overloads are still how the big libraries type pipe. The reason is that a single generic signature that checks every step is harder to write than it looks, and it gives some things up. You're going to write that single signature anyway: about 40 lines of types, checked by tsc --noEmit, and then the four places it breaks so you know when overloads win. Step 1: read the ends with infer Start with four small helpers. In and Out pull the parameter and return type out of a one-argument function. First and Last pull the first and last element out of a tuple. // pipe.ts export type Fn = (arg: any) => any; type In<F> = F extends (arg: infer A) => any ? A : never; type Out<F> = F extends (arg: any) => infer R ? R : never; type First<T extends unknown[]> = T extends [infer F, ...unknown[]] ? F : never; type Last<T extends unknown[]> = T extends [...unknown[], infer L] ? L : never; Last matches with a leading rest element, [...unknown[], infer L], which TypeScript has accepted since 4.2. With these you can write the obvious pipe type: the input of the first function, the output of the last. type NaivePipe<Fns extends Fn[]> = (input: In<First<Fns>>) => Out<Last<Fns>>; It reads the ends and never looks at the middle. Give it trim (string to string), len (string to number) and shout (string to string) and it reports (input: string) => string, even though shout would receive a number at runtime. A pipe type that only reads the ends is an as cast with better marketing. !A pipeline of joined segments where only the first and last joints are inspected and a mismatched joint in the middle goes unnoticed Reading only the first input and the last output leaves every joint in the middle unchecked. Step 2: walk the list with one recursive type The real check has to visit every adjacent pair. Recursive conditional types (TypeScript 4.1) let a type refer to itself in its own branches, so you peel one function off the front, compare its input with the value flowing in, and recurse on the rest. type PipeError<Step, Got, Want> = { readonly error: "pipe(): a step got the wrong input"; readonly step: Step; readonly got: Got; readonly want: Want; }; type Check< Fns extends Fn[], Input, Cur, Done extends unknown[] = [] > = Fns extends [infer F, ...infer Rest extends Fn[]] ? [Cur] extends [In<F>] ? Check<Rest, Input, Out<F>, [...Done, F]> : PipeError<[...Done, F]["length"], Cur, In<F>> : (input: Input) => Cur; export type Pipe<Fns extends [Fn, ...Fn[]]> = Check< Fns, In<First<Fns>>, In<First<Fns>> >; Four details carry the weight here. infer Rest extends Fn[] puts a constraint on the inferred rest, a feature from TypeScript 4.7. Without it, Rest can't be passed back into Check, whose first parameter demands Fn[]. [Cur] extends [In<F>] wraps both sides in brackets on purpose. A bare Cur extends ... on a type parameter is distributive: if a step returns string | number, the check would split into one branch per member and hand you a union of a function and an error. The brackets compare the whole union at once. Done collects the functions already visited. Its only job is to number the failing step: [...Done, F]["length"] is the 1-based position of F as a number literal type. The recursive call is the entire branch result. Nothing wraps it, and that makes Check tail-recursive, which matters when chains get long. The runtime part is short: export function pipe<Fns extends [Fn, ...Fn[]]>( ...fns: Fns ): Pipe<Fns> { const run = (input: unknown) => fns.reduce((acc: unknown, fn) => fn(acc), input); return run as unknown as Pipe<Fns>; } Fns extends [Fn, ...Fn[]] on a rest parameter makes TypeScript infer the arguments as a tuple, one element per function, which is what Check walks. The double cast is the honest part: the body can't prove a deferred conditional type, so you assert once at the boundary and callers get the checked type. The overloaded libraries pay the same toll in their own way; Effect's implementation signature is pipe(a: unknown, ...args: Array<any>): unknown. Step 3: prove it with @ts-expect-error A type you can't test is a type you will break by accident. // @ts-expect-error (TypeScript 3.9) suppresses the error on the next line, and if that line has no error, tsc reports Unused '@ts-expect-error' directive. That turns every expected failure into an assertion. // pipe.types.ts import { pipe } from "./pipe"; type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; type Expect<T extends true> = T; const trim = (s: string) => s.trim(); const len = (s: string) => s.length; const isLong = (n: number) => n > 10; const shout = (s: string) => s.toUpperCase(); const ok = pipe(trim, len, isLong); export type OkIsTyped = Expect< Equal<typeof ok, (input: string) => boolean> >; // @ts-expect-error the input must be a string ok(42); const broken = pipe(trim, len, shout); // @ts-expect-error step 3 wants a string, gets a number broken(" hello "); // @ts-expect-error a broken pipe is not a function const f: (s: string) => string = broken; // @ts-expect-error pipe() needs at least one step pipe(); Equal compares two types strictly, so a result that drifts to any or to a wider type fails it; Expect fails to compile when handed false. A minimal tsconfig.json next to the two files: { "compilerOptions": { "strict": true, "noEmit": true, "target": "es2022", "module": "esnext", "moduleResolution": "bundler" }, "include": ["pipe.ts", "pipe.types.ts"] } Install the compiler with npm i -D typescript, then run npx tsc. The config needs TypeScript 5.0 or newer for "moduleResolution": "bundler"; the types themselves need 4.7. If any expectation stops holding, either an Expect line or an unused directive fails the build, locally or in CI. Where the recursive version breaks Look again at the broken test. The pipe(trim, len, shout) line compiles. The type of broken is PipeError<3, number, string>, and the error only appears where you call it or assign it to a function type. Hovering broken tells you the step, what it got and what it wanted, which beats a silent unknown, but the red squiggle sits on the wrong line. Overloads don't have this problem: each step is its own parameter, so a mismatch is an argument error on the exact function that's wrong. There is a variant that rewrites the rest parameter to the expected tuple so the error moves onto the bad argument; ecyrbe's write-up shows one. I haven't run it, and a commenter there reports a gap in its first-to-second step check, so test it the same way before you trust it. Generic steps lose their type parameter. Take const wrap = <T>(x: T): T[] => [x]. When In and Out match a generic signature against infer, the type parameter doesn't survive; TypeScript issue #38964 shows ReturnType of <T = string>() => T coming out as unknown. So pipe(trim, wrap, join), with join taking string[], fails with got: unknown[]. The fix is an instantiation expression (4.7): pass wrap<string> as the step and the chain checks. Overloads get this for free, because each step is matched against (a: A) => B with A already known, and since TypeScript 3.4 a composer can even propagate a generic step's type parameters into the result. Inline lambdas aren't typed from the previous step. In Effect, pipe(" hi ", (s) => s.trim(), (s) => s.length) types each s from the step before it, because every overload names the intermediate types as separate type parameters. In ...fns: Fns, nothing ties the second slot's parameter to the first slot's return, so an unannotated (s) => s.length has no way to learn that s is a string. The best it gets is the any from the Fn constraint, and an any step checks nothing. Annotate every inline step, or write the steps as named functions. Long chains hit the depth limit, depending on how you wrote the recursion. The TypeScript PR that added tail-recursion elimination lets tail-recursive conditional types loop 1,000 times before giving up, and lowered the general limit for the rest to 100. Check is tail-recursive, so a 30-step pipe is nowhere near the ceiling. Rewrite it so the recursive call sits inside something else, a tuple spread or a union, and you are back under the general limit, where the failure is the familiar Type instantiation is excessively deep and possibly infinite from the 4.5 release notes. Even well under the limit, the 4.1 notes warn that recursive types can increase type-checking time, so keep the step types themselves simple. When overloads are still the better choice !A tall stack of near-identical cards on one side and a single looping arrow passing through a row of function blocks on the other Overloads write one signature per chain length. The recursive type writes one signature that walks the list. The rule that falls out of the four breaks: choose by who writes the steps. If callers write steps inline, as lambdas, or pass generic helpers like map and filter, use overloads. That is the RxJS and Effect situation, where most steps are operators with type parameters and most callbacks are unannotated. Contextual typing per step and per-argument errors are worth more than an unlimited chain length, and very few real pipelines need more than the 19 functions Effect covers. If callers pass named, already-typed, non-generic functions, use the recursive type. Validation chains, middleware lists and data transforms assembled from a registry all fit: the steps already carry their own types, chains can be longer than any overload stack, and the PipeError type names the failing step. Ship it with the @ts-expect-error file so the next refactor can't quietly break the check. --- If this was useful The recursive Check type is a small version of a pattern that keeps coming back: walk a tuple with infer, carry state in an accumulator, keep the recursion in tail position. The TypeScript Type System goes deep on that toolkit: generics, mapped and conditional types, infer, template literals, branded types. If you already write TypeScript daily, that's the one to read next; if you're still getting comfortable with narrowing and modules, start with TypeScript Essentials. - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR Coming from Kotlin, Java or PHP? The two bridge books replace the first two for you. Shipping TypeScript at work, with build tooling and published packages? TypeScript in Production covers that layer. All five books ship in ebook, paperback, and hardcover. 
Show HN: Endo – A Higher-Order Harness Endo is a Higher-Order Harness (HOH). From the easiest concept to understand to the more complex ones, Endo gives you: * A single, simple UI to use any agent provider with your existing subscriptions, and hot-swap agents right in the middle of a session. * An orchestration layer, which I call HOH, allowing arbitrary composition of agents to achieve any workflow. A fun case I tried was asking Fable and Astra to mock a presidential debate. * An agent protocol that abstracts any agent. The key insight here is that every LLM is an agent—instead of treating the LLM as just one node inside an external ReAct loop. Endo was a concept formed while I was building OpenChart (longsurf-ai/openchart). To power trading workflows, I needed to support scenarios where strict determinism is required, while still keeping the flexibility of an agent's inference-time decision-making capability. A new form factor was needed between rigid n8n-style workflows and open-ended ReAct loop agents. Thus the creation of Endo. Endo is short for Endomorphism, a term rooted in category theory and functional programming. An endomorphism is a function that transforms an object into another object of the same type. Similarly, Endo itself is built purely on functional programming principles, relying heavily on Effect (Effect-TS/effect). ..And if you have patience to speak technical jargons, Endo is: * An agent orchestration layer—Higher-Order Harness (HOH)—where an entire agent is treated the same as a single LLM call, enabling arbitrary composition of extremely complex workflows. * A way to abstract any harness into a standardized protocol, where raw models and agents behave the exact same way, in a KV-cache-friendly manner. * A workflow engine that enables arbitrary composition of agents with a single workflow language. It's completely open source, and if you think this idea is cool, let's build together. Just for the fun of it.
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 effect#9252
The `effect/net` family is growing. We've now got Datagram sockets (UDP) and a DNS resolver in core with accompanying data types and schemas for dns records & host / domain name handling: effect#8880 and effect#8409
optic面白いな。オブジェクトや配列の特定のキーに対するgetter/setterを作れる。subscriptionRefと組み合わせればフロントエンド側で必要な部分だけを購読できるので再レンダリングが必要最小限になって最高。 github.com/Effect-TS/effect/blob/main/packages/ef…