Interop
unthrown ships thin bridges to the three most common neighbours in the errors-as-values space. Each is a separate, peer-dependency package with a small to* / from* surface — 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> |
@unthrown/standard-schema | (types only) | any Standard Schema |
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— the same boundary-qualification rule unthrown enforces everywhere. 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 } from "unthrown";
import { toExit, fromEffect } from "@unthrown/effect";
import { Effect } from "effect";
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:
await fromEffect(Effect.succeed(1)).match({ ok, err, defect: String });toEffect also accepts an AsyncResult (the AsyncResult → Effect direction), and toEither — since Either has no defect channel — takes the same mandatory onDefect.
Async
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.
Standard Schema — validators as Results
@unthrown/standard-schema is the odd one out: there's no to* direction (you don't turn a Result back into a schema). It bridges any Standard Schema validator — Zod, Valibot, ArkType — into a validator that returns a Result. The schema's validation issues become the modeled error E, because a failed validation is an anticipated outcome, not a defect.
import { fromSchema, fromSchemaAsync } from "@unthrown/standard-schema";
import { z } from "zod";
const parseUser = fromSchema(z.object({ id: z.string() }));
parseUser({ id: "u_1" }).unwrap(); // { id: "u_1" }
parseUser({ id: 1 }).unwrapErr(); // readonly StandardSchemaV1.Issue[]fromSchema(schema)→(input) => Result<Output, Issues>for a synchronous schema (it throws aTypeErrorif the schema is async — use the next one).fromSchemaAsync(schema)→(input) => AsyncResult<Output, Issues>, accepting sync or async schemas. A validator that throws (rather than returning issues) becomes aDefect; theAsyncResultnever rejects.
The only dependency is the tiny, types-only @standard-schema/spec — your validator library provides the runtime.