@pvorona/failable
v0.10.1
Published
Typed success/failure results for expected failures in TypeScript.
Maintainers
Readme
@pvorona/failable
Typed success/failure results for expected failures in TypeScript.
Use @pvorona/failable when failure is part of normal control flow: invalid
input, missing config, not found, or a dependency call that can fail. Return a
Failable<T, E> instead of throwing, then handle the result explicitly.
A Failable<T, E> is either Success<T> or Failure<E>.
success()/success(data)/failure()/failure(error)create resultsfailable(...)captures thrown or rejected boundariesrun(...)composes multipleFailablestepsall(...),allSettled(...), andrace(...)combine multiple sourcesresult.map(...)/result.mapError(...)/result.flatMap(...)transform and chain results
Install
npm i @pvorona/failableThis package is ESM-only and requires Node 18+.
Basic Usage
Return success(...) or failure(...), then branch on result.isFailure.
The typed error lets the caller decide what to do for each failure reason.
import { failure, success, type Failable } from '@pvorona/failable';
type ReadPortError = { code: 'missing' } | { code: 'invalid'; raw: string };
function readPort(raw: string | undefined): Failable<number, ReadPortError> {
if (raw === undefined) return failure({ code: 'missing' });
const port = Number(raw);
if (!Number.isInteger(port) || port <= 0) {
return failure({ code: 'invalid', raw });
}
return success(port);
}
const result = readPort(process.env.PORT);
if (result.isFailure) {
switch (result.error.code) {
case 'missing':
console.error('PORT is not set');
break;
case 'invalid':
console.error(`PORT is not a valid number: ${result.error.raw}`);
break;
}
} else {
console.log(`Listening on ${result.data}`);
}Choose The Right API
| Need | Use |
| ------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Return a successful or failed result from your own code | success(...) / failure(...) |
| Read the value or provide a fallback | getOr(...) / getOrElse(...) |
| Recover to Success<T> | or(...) / orElse(...) |
| Map both branches to one output | match(onSuccess, onFailure) |
| Throw an Error from a failure | getOrThrow(toError?) / throwIfFailure(result, toError?) |
| Capture a throwing or rejecting boundary | failable(...) |
| Compose multiple Failable steps | run(...) |
| Combine multiple Failable sources | all(...), allSettled(...), race(...) |
| Transform a successful value only | map(...) |
| Transform a failure value only | mapError(...) |
| Chain another Failable step | flatMap(...) |
| Cross a structured-clone boundary | toFailableLike(...) + failable(...) |
| Validate unknown input | isFailable(...), isSuccess(...), isFailure(...), isFailableLike(...) |
Unwrapping And Recovery
Start with ordinary branching on result.isFailure or result.isSuccess. When
you want something shorter, use the helper that matches the job:
result.getOr(fallback): return the success value or an eager fallbackresult.getOrElse(() => fallback): lazy fallbackresult.getOrElse((error) => fallback): lazy fallback derived from the failureresult.or(fallback): recover toSuccess<T>with an eager fallbackresult.orElse(() => fallback): lazy recovery toSuccess<T>result.orElse((error) => fallback): lazy recovery toSuccess<T>derived from the failureresult.match(onSuccess, onFailure): map both branches to one outputresult.getOrThrow(toError?): return the success value or throw anErrorderived from the failurethrowIfFailure(result, toError?): throw anErrorderived from the failure and narrow the same variable
Both throw helpers preserve existing Error instances unchanged by default.
Other failure values become:
new Error(\Expected value to be Success. Received Failure(${String(reason)}).`, { cause: reason })`- or
new Error('Expected value to be Success. Received Failure(Unstringifiable error value).', { cause: reason })when string coercion itself throws
Pass toError when you need a specific throwable shape at the throw boundary.
You can pass either:
- a callback that returns an
Erroror a string message - or a string message directly
Use the lazy forms when the fallback is expensive or has side effects. Failure
callbacks receive the stored error, so () => ... can ignore it and
(error) => ... can use it:
const port = result.getOrElse((error) => {
return error.code === 'missing' ? 3000 : 8080;
});Using readPort from above:
const result = readPort(process.env.PORT);
const port = result.getOr(3000);
const label = result.match(
(port) => `Listening on ${port}`,
(error) => `Using default port (${error.code})`
);throwIfFailure narrows the result to Success in place, so
subsequent code can access .data without branching:
import { throwIfFailure } from '@pvorona/failable';
const result = readPort(process.env.PORT);
throwIfFailure(result);
console.log(result.data * 2);When you want a specific Error shape only at the throw site, pass toError
there:
const port = readPort(process.env.PORT).getOrThrow(
(reason) => new Error(`Invalid port (${reason.code})`)
);Transform And Chain With map(...), mapError(...), And flatMap(...)
Use result.map(fn) when you only need to change the success value. The callback
runs on Success only; on Failure, the same failure is returned unchanged.
Use result.mapError(fn) when you only need to change the failure value. The
callback runs on Failure only; on Success, the same success is returned
unchanged.
Use result.flatMap(fn) when the next step can fail again. The callback must
return another Failable. On Success, that result becomes the outcome; on
Failure, flatMap short-circuits and keeps the original error.
Building on readPort from Basic Usage:
import { failure, success, type Failable } from '@pvorona/failable';
type ReadPortError = { code: 'missing' } | { code: 'invalid'; raw: string };
type ApplicationPortError =
| ReadPortError
| { code: 'not_application_port'; port: number };
function readPort(raw: string | undefined): Failable<number, ReadPortError> {
if (raw === undefined) return failure({ code: 'missing' });
const port = Number(raw);
if (!Number.isInteger(port) || port <= 0) {
return failure({ code: 'invalid', raw });
}
return success(port);
}
function ensureApplicationPort(
port: number
): Failable<number, ApplicationPortError> {
if (port < 3000 || port > 3999) {
return failure({ code: 'not_application_port', port });
}
return success(port);
}
const appPortResult = readPort(process.env.PORT).flatMap((port) =>
ensureApplicationPort(port)
);
const labelResult = appPortResult.map(
(port) => `Application listening on ${port}`
);
const invalidRangeCode = readPort('8080')
.flatMap((port) => ensureApplicationPort(port))
.mapError((error) => error.code);When you pass object literals directly into success(...) or failure(...),
TypeScript often keeps their types as narrow as possible (literal fields where
that makes sense), which helps switch on error.code and similar patterns.
Capture Thrown Or Rejected Failures With failable(...)
Use failable(...) at a boundary you do not control. It turns a thrown or
rejected value into Failure, so the rest of your code can stay in normal
Failable flow.
Use the callback form for synchronous code that can throw:
import { failable } from '@pvorona/failable';
const rawConfig = '{"theme":"dark"}';
const configResult = failable(
() => JSON.parse(rawConfig),
(reason) => ({ code: 'invalid_config', cause: reason })
);
if (configResult.isFailure) {
console.error(configResult.error.code, configResult.error.cause);
} else {
console.log(configResult.data);
}If you do not pass a second argument, the thrown or rejected value stays in the
failure channel unchanged as unknown.
Pass a promise directly when you want rejection capture:
import { failable } from '@pvorona/failable';
import { readFile } from 'node:fs/promises';
const fileResult = await failable(readFile('config.json', 'utf8'), {
code: 'config_read_failed',
});
const config = fileResult.getOr('{}');failable(...) can:
- preserve an existing
Failable - rehydrate a
FailableLike - capture sync throws from a callback
- capture promise rejections from a promise passed directly
- map captured reasons with
toReason - store a constant reason with a non-function second argument
By default, the thrown or rejected value becomes .error unchanged.
Pass the promise itself when you want rejection capture. If you call
failable(() => promise), only synchronous throws from the callback itself are
captured. The returned promise stays in the success channel.
Compose Existing Failable Steps With run(...)
Use run(...) when each step already returns Failable and you want to write
the success path once. If any yielded step fails, that failure becomes the
default unwind result. Cleanup still runs first, and an explicit return
reached in finally overrides it. Yielded cleanup Failure values keep the
current unwind result unless a later cleanup return overrides it.
Inside a run(...) builder, there are two valid delegation forms:
yield* resultwhenresultis already a hydratedFailableyield* await promisedResultin async builders when you have aPromise<Failable<...>>
Hydrated Failable values expose sync and async iterators so run(...) can
intercept yield* result in both sync and async builders. Outside run(...),
treat them as result objects rather than as a general-purpose collection API.
Without run(...), composing steps means checking each result before
continuing:
import { failure, success, type Failable } from '@pvorona/failable';
type ConfigError =
| { code: 'missing'; key: string }
| { code: 'invalid'; key: string; raw: string };
function readEnv(
key: string,
env: Record<string, string | undefined>
): Failable<string, ConfigError> {
const raw = env[key];
if (raw === undefined) return failure({ code: 'missing', key });
return success(raw);
}
function parsePort(raw: string): Failable<number, ConfigError> {
const port = Number(raw);
if (!Number.isInteger(port) || port <= 0) {
return failure({ code: 'invalid', key: 'PORT', raw });
}
return success(port);
}
function loadConfig(
env: Record<string, string | undefined>
): Failable<{ host: string; port: number }, ConfigError> {
const hostResult = readEnv('HOST', env);
if (hostResult.isFailure) return hostResult;
const rawPortResult = readEnv('PORT', env);
if (rawPortResult.isFailure) return rawPortResult;
const portResult = parsePort(rawPortResult.data);
if (portResult.isFailure) return portResult;
return success({ host: hostResult.data, port: portResult.data });
}With run(...), the same flow stays linear:
import { run, success, type Failable } from '@pvorona/failable';
function loadConfig(
env: Record<string, string | undefined>
): Failable<{ host: string; port: number }, ConfigError> {
return run(function* () {
const host = yield* readEnv('HOST', env);
const rawPort = yield* readEnv('PORT', env);
const port = yield* parsePort(rawPort);
return success({ host, port });
});
}When a helper already returns a hydrated Failable, yield it directly with
yield* helper(). For promised sources in async builders, await them first and
then yield the hydrated result with yield* await promisedHelper().
run(...) does not inject helper arguments. Import the top-level combinators
you need and use them directly inside the builder.
For async flows, switch to run(async function* ...). Sync hydrated helpers
still work with direct yield* helper(), and promised sources compose with
yield* await ...:
import {
all,
failable,
failure,
run,
success,
type Failable,
} from '@pvorona/failable';
type ApiError =
| { code: 'network_error'; cause: unknown }
| { code: 'http_error'; status: number }
| { code: 'json_parse_error'; cause: unknown };
type User = { id: string; email: string };
type Profile = { id: string; pictureUrl: string };
async function readJson<T>(url: string) {
const responseResult = await failable(fetch(url));
if (responseResult.isFailure) {
return failure({ code: 'network_error', cause: responseResult.error });
}
const response = responseResult.data;
if (!response.ok) {
return failure({ code: 'http_error', status: response.status });
}
const jsonResult = await failable(response.json());
if (jsonResult.isFailure) {
return failure({ code: 'json_parse_error', cause: jsonResult.error });
}
return success(jsonResult.data as T);
}
async function getUser(userId: string) {
return readJson<User>(`https://api.example.com/users/${userId}`);
}
async function getUserProfile(userId: string) {
return readJson<Profile>(`https://api.example.com/users/${userId}/profile`);
}
async function loadUserPage(
userId: string
): Promise<Failable<{ user: User; profile: Profile }, ApiError>> {
return await run(async function* () {
const [user, profile] = yield* await all(
getUser(userId),
getUserProfile(userId)
);
return success({ user, profile });
});
}- if a yielded step fails, that failure becomes the default unwind result
- cleanup still runs, and the last explicit
returnreached infinallywins - yielded cleanup
Failurevalues keep the current unwind result unless a later cleanupreturnoverrides it - sync hydrated
Failablehelpers can use directyield* helper()in both sync and async builders - promised sources in async builders use
yield* await promisedHelper() - in async builders, use
yield* await all(...)to run multiple sources in parallel and get a success tuple or the first failure - use
yield* all(...)in sync builders when every source is already a hydratedFailable - use
await allSettled(...)to inspect the settled tuple of sources that resolve toFailable; source promise rejections still reject unchanged - use
yield* race(...)when every raced source is already a hydratedFailable - use
yield* await race(...)when any raced source is promised - direct promised sources still follow normal async
await/try/finallysemantics rather than a helper-managed rejection path run(...)does not capture thrown values or rejected promises intoFailure; wrap throwing boundaries withfailable(...)before they enterrun(...)
Parallel Combinators
Import all(...), allSettled(...), and race(...) from the package root when
you want to combine multiple sources outside run(...) or inside async
builders.
import { all, allSettled, failure, race, success } from '@pvorona/failable';
const syncTuple = all(success(1), success('two'));
const mixedTuple = await all(success(1), Promise.resolve(success('two')));
const settled = await allSettled(
Promise.resolve(success(1)),
Promise.resolve(failure('missing-profile'))
);
const syncWinner = race(success('cached'), success('stale'));
const mixedWinner = await race(
success('cached'),
Promise.resolve(success('network'))
);Key semantics:
all(...)returns the first failure in input orderallSettled(...)returns a plain settled tuple rather than aSuccesswrapperallSettled(...)preservesFailurevalues in the returned settled tupleallSettled(...)only settles sources that resolve toFailablevalues- promised source rejections in
allSettled(...)still reject the combinator - wrap rejecting boundaries with
failable(...)first if you want a rejection converted intoFailure - bare
Promise.reject(...)inputs are rejected at type level as a best-effort guardrail; TypeScript still cannot model arbitrary promise rejection channels precisely race(...)accepts sync or promisedFailablesourcesrace(...)returns syncFailablewhen every source is sync, otherwisePromise<Failable>- when
race(...)mixes already-settled sync and promised sources, winner order follows normalPromise.race(...)input ordering race()with zero sources rejects with a clear error instead of hanging
Transport And Runtime Validation
Failable values are hydrated objects with methods. Keep them inside your
process. If you need a structured-clone-friendly shape, convert to
FailableLike<T, E> before crossing the boundary and rehydrate on the other
side:
import { failure, failable, toFailableLike } from '@pvorona/failable';
const result = failure({ code: 'missing' });
const wire = toFailableLike(result);
const hydrated = failable(wire);Use the runtime guards only when the input did not come from your own local control flow:
import { isFailable } from '@pvorona/failable';
const candidate: unknown = maybeFromAnotherModule();
if (isFailable(candidate) && candidate.isFailure) {
console.error(candidate.error);
}- use
isFailable(...),isSuccess(...), andisFailure(...)forunknownvalues that might already be hydratedFailableresults - use
isFailableLike(...)for plain transport shapes like{ status, data }or{ status, error }
API At A Glance
type Failable<T, E>:Success<T> | Failure<E>type Success<T>/type Failure<E>: hydrated result variantstype FailableLike<T, E>: structured-clone-friendly wire shapesuccess()/success(data)/failure()/failure(error): create hydrated resultsthrowIfFailure(result, toError?)/result.getOrThrow(toError?): throw anError, preserving existingErrorinstances unchanged by defaultfailable(...): preserve, rehydrate, capture raw failures, or map them withtoReason/ a constant reason at a boundaryrun(...): composeFailablesteps without nested branchingresult.map(...): transform success data; failures pass through unchangedresult.flatMap(...): chain anotherFailable; failures short-circuittoFailableLike(...): convert a hydrated result into a wire shapeisFailableLike(...): validate a wire shapeisFailable(...),isSuccess(...),isFailure(...): validate hydrated resultsFailableStatus: runtime success/failure status values
