Interoperate with other libraries
How-to. Thin
to*/from*bridges betweenResult/AsyncResultand the neighbours in the errors-as-values space. Nothing to learn beyond "which direction am I going."
| Package | Peer dependency | Bridges |
|---|---|---|
@unthrown/effect | effect | Exit, Either, Effect |
@unthrown/neverthrow | neverthrow | Result, ResultAsync |
@unthrown/boxed | @bloodyowl/boxed | Result, Future<Result> |
For Standard Schema validators (Zod, Valibot, ArkType), see the dedicated Validate with Standard Schema guide.
The one rule: does the neighbour have a defect channel?
unthrown has three channels — Ok, Err, and the out-of-band Defect. Most libraries have only two. That single difference decides every signature.
- Coming in (
from*), a two-channel result is only ever anOkor anErr— the bridge never produces aDefect. - Going out (
to*) to a two-channel type, aDefecthas nowhere to live. Rather than silently fold it into your domain error, the bridge forces you to triage it with a mandatoryonDefect: (cause) => E. There is no one-arg form.
import { Ok } from "unthrown";
import { toNeverthrow } from "@unthrown/neverthrow";
// onDefect is required — the compiler will not let you drop a defect.
toNeverthrow(Ok(1), (cause) => ({ _tag: "Bug", cause }));Effect — a genuine bijection
Effect is the exception: it does have a defect channel (Cause.die), so Result ↔ Exit round-trips losslessly.
import { Ok, Err, P, TaggedError } from "unthrown";
import { toExit, fromEffect } from "@unthrown/effect";
import { Effect } from "effect";
class NotFound extends TaggedError("NotFound") {}
class Timeout extends TaggedError("Timeout") {}
type User = { name: string };
toExit(Ok(1)); // Exit.succeed(1)
toExit(Err("e")); // Exit.fail("e") — a modeled Cause.fail
// a Defect would become Exit.die(cause)
// Run an Effect and collect its outcome; a die/interrupt becomes a Defect.
// Effect's error channel arrives as E, so name each of its cases:
declare const loadUser: Effect.Effect<User, NotFound | Timeout>;
await fromEffect(loadUser).match({
ok: (user) => user.name,
errCases: (matcher) =>
matcher.with(P.tag("NotFound"), () => "missing").with(P.tag("Timeout"), () => "timed out"),
defect: String,
});toEffect also accepts an AsyncResult, and toEither — since Either has no defect channel — takes the same mandatory onDefect.
Async pairs
Every package mirrors its sync pair for the asynchronous types:
@unthrown/effect—fromEffectreturns anAsyncResult;toEffectaccepts one.@unthrown/neverthrow—toNeverthrowAsync/fromNeverthrowAsyncbridgeAsyncResult ↔ ResultAsync.@unthrown/boxed—toBoxedFuture/fromBoxedFuturebridgeAsyncResult ↔ Future<Result>.
On the way in, an unexpected rejection inside the neighbour's async type becomes a Defect — never a silently-swallowed error. (Boxed's Future has no failure channel and never rejects, so for fromBoxedFuture this is a defensive guarantee rather than a path you can actually hit.)
Where to go next
- Why the
to*triage is mandatory: Qualification. - Validators as results: Validate with Standard Schema.