Open source

Open source

result-kit

A TypeScript library that puts failure in the return type. This page is the bill for it: one decision I argued with four months later, and one I still have not measured.

What throwing costs

A function that throws tells you nothing in its type. Nothing in the signature says the call can fail, so nobody has to decide what to do about it. The caller finds out when it happens — in a test if I am lucky, in production if I am not.

Quiet failures are the expensive ones. A mail automation in this portfolio has the same shape one layer up. Tokens expire, webhooks lapse, and a bad day looks like a quiet day until somebody asks why a lead went cold.

The answer is old and it is not mine. Put the failure in the return type, and the compiler makes the caller deal with it. I had already paid part of that bill by hand. Another codebase here has a private version of the same idea, and I measured it: an abstract class of thirty-nine static methods over six hundred and fifty-six lines, of which six were ever called from outside. Every method that composed two results was used zero times.

So the question for a library is which half of the pattern the compiler holds. The other half is a suggestion about style, and nobody has to take it.

Plain data, and the bill for it

A Result is one of two objects: ok true with a value, or ok false with an error. No class. No methods. No hidden brand and no tag. Anything of that shape is one, whoever built it.

I chose that, and it buys one guarantee. If the value and the error are JSON-safe, the result survives a round trip through JSON.stringify and comes back usable with no re-wrapping. A result can be an HTTP response body, a queue message or a postMessage payload. A brand would have made the returned object stop being a valid result, which is the same hazard the class version has, moved up to the type level.

The typed error follows the same rule. It is a plain object with four fields, never a class and never anything that extends Error, because an error that crosses a boundary has to survive the crossing.

The bill came straight away. A union with no methods cannot be chained, and chaining is what people want to write. Refusing methods on the value is what forced a second surface to exist.

Two surfaces, one implementation

So there are two. The core is free functions: you call map(result, fn), one signature, no curried twin. The wrapper sits behind its own entry point, where you write ok(x).map(f). The wrapper delegates to the core, and you leave it before you serialize anything.

The core is the half that matters, because it stands on its own. Import three functions and ship three functions. A library built on methods cannot offer that: the methods and the data are the same object, so importing the constructor drags the whole surface in. That is the whole argument for paying for two surfaces. Most people should use the wrapper and never think about it.

The obvious third surface is pipe. I re-opened it later and settled it with a spike, then turned it down, along with flow and the arity-detecting dual that would have made it worth having. The numbers are why. Across three call sites, pipe was never the shortest spelling and never the flattest one. The longest chain came to 336 characters and eight lambdas under pipe, against 255 and none under the wrapper. pipe reads well elsewhere because of dual, which gives every operation a data-last twin so each step drops in as a bare name. Without dual every step is a lambda, and those ergonomics were the whole reason to want pipe.

I turned dual down on its own account too. It works by reading arguments.length at runtime to guess which way round the caller meant it.

The field I refused, and the day it came back

The typed error has four fields and no path. A path is validation's concern, and putting it in the shared shape would have made every error in the system carry a field that means nothing to most of them.

The first bill arrived from a distance. Zod's error formatters are all path-derived, so none of them ports to a shape with no path. I wrote that down as a finding, and the formatters here are the library's own.

The second bill arrived when a schema adapter had to exist anyway, and the path had to go somewhere. It went into the typed payload as an array of strings and numbers, normalized from whatever the validator handed over. I wrote that normalization from the Standard Schema types, which say a path segment is a property key. So a symbol looked like the exotic case, and I handled the symbol.

It was not the exotic case. Valibot's path items include a Map key, typed as anything at all, and a Set key, which is null. A map keyed by big integers produced a failure whose payload contained a BigInt, and JSON.stringify on the whole result threw. That is the one guarantee the package sells, and the field I had refused is what broke it. An object key was worse and quieter. Held by identity, it carried the caller's own graph into the payload, cycles and personal data included.

The rule is now total. A string passes through, a finite number passes through, and everything else becomes its own string form — lossy on purpose, because the alternative is a value that disappears with no diagnostic, which is the failure the library exists to refuse.

An adversarial review pass found it. The tests I wrote beside the implementation exercised objects and arrays, so they never reached a map key.

Where the inference broke

ok(1) returns the narrow success half, not a full result with never on the error side. I wrote down two reasons: it is strictly more precise, and it still assigns into any result annotation.

Both are true of the value in your hand. Neither survives one transform. The narrow half has no error member, so it gives the error type nowhere to be inferred from. A transform over a bare ok(1) had nothing to work with and fell back to unknown, and a result whose error is unknown assigns into no annotation at all. My note described the constructor and read as though it described the value.

The narrow return stayed. I defaulted the error type to never wherever it surfaces to a user, which is honest enough, since a value built by ok(1) really has no error channel.

Four months later, in drizzle-tx, I made the same call the other way, and I had a good reason there too. That page argues it out.

The second inference story ends stranger. Inside a generator-based do-block, returning a tagged object widens its tag, because a generator's return type is inferred from the bottom up and the surrounding type never reaches the constructor call. I tried five fixes against the compiler. The one that works everywhere makes every array payload read-only and breaks any consumer expecting a normal array. So I changed nothing and documented the caller-side pattern, and the decision record ships no code.

A convention nobody enforces

A pattern the compiler does not hold decays. Nothing stops you calling a function that returns a result and then ignoring the value. Rust makes that an error. TypeScript does not.

So there is a second repo, result-kit-lint, and a rule called must-use-result. It ships to two hosts. The ESLint one is type-aware and follows a result through wrappers, aliases and unions. The Oxlint one cannot see types at all, so I had to decide whether a partial rule beats none.

I measured it before it shipped. Against a consumer corpus, the syntax-level matchers caught nine of the eleven dropped results. The two misses are the ones no syntax-level rule can reach: a call whose result return is inferred with no annotation, and one whose return type hides behind another name. Both are named in the plugin's own README, because a linter that overstates its reach is one you stop reading.

The rule finds a result by its shape, never by importing the library, which is why the two repos stay apart and the core keeps its zero dependencies.

One more thing is worth a sentence. The repo ships a hand-written brief for coding agents, and a test pins every root export into it. A human reading a stale README can watch the compiler disagree with it. A model is handed the brief as ground truth.

What I have not measured

Here is the strongest objection to the whole idea, and I do not yet have an answer to it.

A failure that becomes a value stops throwing. Sentry hooks unhandled rejections and window errors. A thrown exception is what marks an OpenTelemetry span as failed. If those tools only see failures that throw, a codebase that handles every result correctly may report no failures at all. A team would find that out during an incident.

The work is open and the method is simple. Run the same failure path twice, once thrown and once returned, point both at a collector and a Sentry-compatible sink, and write down what arrives. A negative result would take out the largest thing on my roadmap for the price of an afternoon, and I would have to report it as loudly as a positive one.

Until then the honest version is this. The type story holds up. I have not proved the operations story.

Try it

The source and the decision records are at github.com/alifaroo-q/result-kit. Every claim above has a record behind it in docs/adr, including the ones the code later amended.

Two things to know before you reach for it. It is ESM-only — there is no CommonJS build, and a CommonJS consumer loads it through require of an ES module or a dynamic import.

The rework that produced the current shape has one silent break. unwrapOrThrow exists on both sides of it, takes a result first and an optional second argument on both sides, and type-checks cleanly after a find-and-replace. The old one threw an HTTP exception. The new one throws a plain Error, so a handled not-found becomes an unhandled server error, in production, with nothing at compile time to warn you. Every other break in that migration is loud. I put that one at the top of the migration guide.

Sections

  1. What throwing costs
  2. Plain data, and the bill for it
  3. Two surfaces, one implementation
  4. The field I refused, and the day it came back
  5. Where the inference broke
  6. A convention nobody enforces
  7. What I have not measured
  8. Try it