You call JSON.parse. It looks safe. Nothing in the signature tells you otherwise.
Then someone passes garbage in production and your server crashes.
That's not a bug. That's try/catch being invisible.
The problem
function parseConfig(text: string): Config {
return JSON.parse(text); // throws SyntaxError. you'd never know.
}
This function lies. It says it returns a Config. It doesn't say it might explode. TypeScript doesn't warn you. Your tests probably don't catch it. And the person calling this function has no idea they need to wrap it.
The failure is real. It's just hidden.
What if failure was part of the type?
function parseConfig(text: string): Result<Config, SyntaxError> {
return wrap(() => JSON.parse(text));
}
Now the signature is honest. Two possible outcomes. Both visible. TypeScript knows about both. The person calling this function knows about both.
That's result.js.
The API
Two constructors:
const success = ok({ id: 1, name: "Alice" });
const failure = err({ code: 404, message: "Not found" });
Handle both outcomes with match — both arms required, TypeScript won't compile if you skip one:
result.match({
ok: user => `Welcome, ${user.name}`,
err: error => `Failed: ${error.message}`,
});
Chain fallible operations without nesting with flatMap:
const dashboard = getUser(id)
.flatMap(user => getPermissions(user))
.flatMap(perms => buildDashboard(perms))
.unwrapOr(emptyDashboard);
If any step fails, the rest are skipped. The error falls through to unwrapOr. No if (result.isErr()) checks stacking up.
Adopt existing throwing code at the boundary:
const parsed = wrap(
() => JSON.parse(text) as Config,
e => ({ code: "PARSE_ERROR", detail: String(e) })
);
Async works too
ResultAsync wraps a Promise<Result<T, E>> and gives you the same chaining API. Write the whole pipeline, await once at the end.
const name = await ResultAsync
.from(fetchUser(id), e => new ApiError(e))
.flatMap(user => ResultAsync.from(fetchProfile(user.profileId), e => new ApiError(e)))
.map(profile => profile.displayName)
.unwrapOr("Anonymous");
No nested awaits. No growing stack of error checks. One await at the end.
When to use it
Use Result when the failure is an expected outcome — parsing input, querying a database, calling an API.
Stick with try/catch when the failure is a bug — out of memory, programmer errors, things that should never happen.
You don't have to pick one everywhere. wrap converts throwing code at the boundary so you can work with Result from there.
Install
npm install @bugfreedev/result.js
Zero dependencies. Full TypeScript. Ships with type declarations — no @types package needed.
import { ok, err, wrap, ResultAsync } from "@bugfreedev/result.js";
import type { Result } from "@bugfreedev/result.js";
The idea is simple: functions that can fail should say so. Not in a comment. Not in the docs. In the type signature, where TypeScript can see it and so can anyone reading the code.
That's the whole thing.
Links
- GitHub: https://github.com/bug-free-dev/result.js
- npm: https://www.npmjs.com/package/@bugfreedev/result.js
If you try it, I'd love feedback on the API design, naming, and overall developer experience. The goal is to keep it small, obvious, and pleasant to use without turning it into a mini framework.
Top comments (1)
Thanks for reading.
This library is still early, so now is the perfect time to challenge the API before it becomes harder to change.
If you have experience with Result types (Rust, neverthrow, Effect, fp-ts, etc.), I'd love to hear:
ResultAsyncexist?GitHub: github.com/bug-free-dev/result.js
npm: npmjs.com/package/@bugfreedev/resu...
Brutally honest feedback is welcome. The goal isn't to build the most powerful Result library. It's to build one that's easy to understand, easy to teach, and pleasant to use.