Skip to content

Qualify a boundary

How-to. Bring untyped failures — a nullable value, a throwing function, a rejecting promise — into a Result. For why every boundary forces a triage decision, see Qualification.

The edges of your program are where untyped failure enters. Pick the constructor that matches the shape of the edge.

The edge is…useerror channel
a nullable value (T | null)fromNullableyour modeled Err
a throwing function, some throws modeledfromThrowableExclude<qualify, Defect>
a throwing function, every throw a bugfromSafeThrowablenever
a rejecting promise, some rejections modeledfromPromiseExclude<qualify, Defect>
a rejecting promise, every rejection a bugfromSafePromisenever

fromNullable — absence as a modeled error

null / undefined become a modeled Err; anything else (including falsy 0, "", false) becomes Ok:

ts
import { fromNullable } from "unthrown";

const cache = new Map([["a", 1]]);

fromNullable(cache.get("a"), () => "missing"); // Ok(1)
fromNullable(cache.get("z"), () => "missing"); // Err("missing")

This is the sanctioned bridge for nullable third-party APIs — and the reason unthrown ships no Option type (see Design decisions).

fromThrowable — wrap a throwing function

Pass a qualify function that triages the thrown cause into a modeled error E or a defect. Its second argument is a defect helper the boundary injects — call it to mark a cause as unmodeled (you never import it):

ts
import { fromThrowable } from "unthrown";

const parse = fromThrowable(JSON.parse, (cause, defect) =>
  cause instanceof SyntaxError ? ("invalid_json" as const) : defect(cause),
);

parse("{ not json"); // Err("invalid_json")
parse("{}"); // Ok({})

A throw inside qualify is itself treated as a defect.

fromPromise — qualify every rejection

fromPromise wraps a promise (or a thunk returning one) as an AsyncResult. Every rejection must be triaged:

ts
import { fromPromise, TaggedError } from "unthrown";

class NotFound extends TaggedError("NotFound") {} // our modeled domain failure
class NotFoundError extends Error {} // what `fetchUser` rejects with on a 404

const user = fromPromise(fetchUser(id), (cause, defect) =>
  cause instanceof NotFoundError ? new NotFound() : defect(cause),
);
// AsyncResult<User, NotFound>

The error channel is inferred as Exclude<R, Defect> — the Defect arm of qualify's return is subtracted, never inferred into E. A qualify that returns only defect(cause) gives AsyncResult<T, never>. qualify must be synchronous (an async one won't compile).

fromSafePromise / fromSafeThrowable — when any failure is a bug

When a failure would be a bug, not an anticipated outcome, skip qualify. The error channel is never; any failure becomes a defect:

ts
import { fromSafePromise, fromSafeThrowable } from "unthrown";

const config = fromSafePromise(loadTrustedConfig()); // AsyncResult<Config, never>

// The row came from our own schema; a decode throw is a bug, not an outcome.
const decode = fromSafeThrowable((row: Row) => userSchema.parse(row));
decode(row); // Result<User, never>

These are the explicit, named form of the (cause, defect) => defect(cause) you would otherwise spell out — reach for them only when "everything here is a defect" is a decision, and keep fromThrowable / fromPromise wherever some failures are anticipated.

Recipe: bridge a nullable lookup

A cache or Map that returns T | undefined becomes a Result with a modeled NotFound:

ts
import { fromNullable, TaggedError } from "unthrown";

class NotFound extends TaggedError("NotFound")<{ id: string }> {}

const fromCache = (id: string) => fromNullable(cache.get(id), () => new NotFound({ id }));

fromCache("u_1").map((p) => p.name); // Result<string, NotFound>

Recipe: wrap a throwing parser

Third-party code that throws is bridged once, at the boundary. The qualify function triages each throw into a modeled error or a defect:

ts
import { fromThrowable, TaggedError } from "unthrown";

class InvalidProfile extends TaggedError("InvalidProfile")<{ field: string }> {}

const parseProfile = fromThrowable(
  (raw: string) => JSON.parse(raw) as Profile,
  (cause, defect) =>
    cause instanceof SyntaxError ? new InvalidProfile({ field: "json" }) : defect(cause),
);

parseProfile('{"name":"Ada"}'); // Result<Profile, InvalidProfile>

The payoff: one handler at the edge

Because every boundary is qualified and every in-pipeline throw becomes a defect, the edge of your program needs no try/catch — just one exhaustive match:

ts
const status = await user.match({
  ok: () => 200,
  errCases: (matcher) => matcher.with({ _tag: "NotFound" }, () => 404), // your modeled NotFound
  defect: (cause) => {
    logger.error(cause);
    return 500; // everything unexpected
  },
});

Where to go next

Released under the MIT License.