bakutils-catcher
v6.2.0
Published
Utility library for handling errors elegantly with Decorators and Rust-like algebraic types.
Maintainers
Readme
bakutils-catcher
bakutils-catcher is a lightweight, zero-dependency TypeScript library for robust error handling and functional programming patterns inspired by Rust. It ships as an ESM module and works in Node, the browser, and Dynamics 365 / Power Apps (with first-class support for Xrm promise-likes).
- Algebraic Data Types —
Result,Option, andOneOffor expressive, exception-free control flow. - Decorators & function wrappers —
@Catcher,@DefaultCatcher,@AnyErrorCatcher, and their functional counterparts to intercept thrown errors. ResultTry— turn any throwing (sync or async) function into aResult.- Thenable-aware — transparently handles native Promises, generic thenables, and Dynamics
Xrm.Async.PromiseLike.
The source types contain rich JSDoc with inline examples — hover them in your editor for more.
Table of Contents
- Installation
- Quick Start
- Result
- Option
- Converting between Result and Option
- ResultTry
- Decorators and function wrappers
- OneOf
- Full example
- Exported types
- License
Installation
# npm
npm install bakutils-catcher
# yarn
yarn add bakutils-catcher
# pnpm
pnpm add bakutils-catcherDecorators require the following in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}If you use decorators without providing a handler and rely on metadata, install
reflect-metadata. It is intentionally not bundled, so the library stays dependency-free.
Quick Start
import { Ok, Err, Option, Some, None, Result, catcher } from 'bakutils-catcher';
// 1. Result — model success/failure without throwing
function divide(a: number, b: number): Result<number, string> {
return b === 0 ? Err('cannot divide by zero') : Ok(a / b);
}
divide(10, 2).match({
Ok: (value) => console.log('Result:', value), // Result: 5
Err: (msg) => console.error('Failed:', msg),
});
// 2. Option — null-safe values. Option() turns null/undefined into None.
const apiKey: Option<string> = Option(process.env.API_KEY);
console.log(apiKey.unwrapOr('default-key'));
// 3. catcher — wrap a throwing function so it never throws
class ParseError extends Error {}
const safeParse = catcher(
(raw: string) => {
const n = Number(raw);
if (Number.isNaN(n)) throw new ParseError(`not a number: ${raw}`);
return n;
},
ParseError,
(err) => 0, // fallback when ParseError is thrown
);
safeParse('42'); // 42
safeParse('abc'); // 0Result
Result<T, E> is either Ok<T> (success) or Err<E> (failure). It is the union Right<T, E> | Left<T, E>.
import { Ok, Err, Result, isResult } from 'bakutils-catcher';
const ok = Ok(42); // Result<number, never>
const err = Err('boom'); // Result<never, string>
const empty = Ok(); // Ok with an undefined value is valid
isResult(ok); // true
isResult({}); // falseMethods
| Method | Ok behavior | Err behavior |
| --- | --- | --- |
| unwrap() | returns the value | throws the error |
| unwrapOr(def) | returns the value | returns def |
| unwrapOrElse(fn) | returns the value | returns fn(error) |
| isOk() / isErr() | true / false | false / true (type guards) |
| map(fn) | returns Ok(fn(value)), or Err if fn throws | returns itself unchanged |
| flatMap(fn) | returns fn(value) (an inner Result) | returns itself unchanged |
| flatMapAsync(fn) | awaits fn(value) → Promise<Result> | returns itself unchanged |
| toOption() | Some(value) | None |
| match({ Ok, Err }) | calls Ok(value) | calls Err(error) |
Note:
Ok.mapis exception-safe — if the mapping function throws, you get anErrinstead of a thrown exception.
Chaining
flatMap lets you sequence operations that each return a Result, short-circuiting on the first Err:
class OrderError extends Error {}
class UserError extends Error {}
const orderService = (id: string): Result<string, OrderError> =>
id === 'valid' ? Ok('Order details') : Err(new OrderError('invalid order'));
const userService = (id: string): Result<string, UserError> =>
id === 'valid' ? Ok('User details') : Err(new UserError('invalid user'));
const processOrder = (orderId: string, userId: string) =>
orderService(orderId).flatMap((order) =>
userService(userId).map((user) => `Processed: ${order}, ${user}`),
);
processOrder('valid', 'valid').unwrap(); // "Processed: Order details, User details"
processOrder('valid', 'bad').isErr(); // true (UserError)For asynchronous pipelines, use flatMapAsync:
const processOrderAsync = async (orderId: string, userId: string) =>
orderService(orderId).flatMapAsync(async (order) =>
userService(userId).map((user) => `Processed: ${order}, ${user}`),
);
await processOrderAsync('valid', 'valid');Option
Option<T> is either Some<T> (has a value) or None (empty). It is the union SomeType<T> | NoneType<T>.
There are three ways to build one:
import { Some, None, Option } from 'bakutils-catcher';
Some(42); // SomeType<number>
None; // the shared NoneType singleton (frozen)
// Some() THROWS if given null or undefined:
Some(null); // ❌ Error: Some() cannot be called with null or undefinedThe Option() wrapper (recommended)
Option(value) is the safe constructor: it returns None for null/undefined and Some otherwise. It also accepts a lazy callback — if the callback returns null/undefined or throws, you get None.
Option(process.env.API_KEY); // Some(value) or None — no manual ternary needed
Option(null); // None
Option(() => JSON.parse(raw)); // None if JSON.parse throws, else Some(parsed)
Option(() => obj?.maybe?.deep); // None if the chain is undefined
// Namespace helpers are also available:
Option.Some(1);
Option.None;Methods
| Method | Description |
| --- | --- |
| unwrap() | Some: the value. None: throws. |
| unwrapOrU() | the value, or undefined for None. |
| unwrapOr(def) | the value, or def for None. def may be a value or a () => T callback. |
| isSome(value?) | type guard. With no arg: true if it has a value. With an arg: true only if the stored value equals it — no unwrap needed. |
| isNone() | type guard for the empty case. |
| map(fn) | applies fn and re-wraps via Option() — so a result of null/undefined (or a throw) becomes None. |
| mapOr(fn, def) | like map, but falls back to Option(def) when the mapped value is empty. def may be a value or callback. |
| flatMap(fn) | applies fn that itself returns an Option, flattening one level. |
| flatMapAsync(fn) | async variant returning Promise<Option>. |
| flatten() | collapses a nested Option<Option<T>> by one level. Some throws if its value is not an Option. |
| okOr(err) | converts to Result: Some → Ok, None → Err(err). err may be a value or callback. |
| toSome(value) | (None only) produces a fresh Some from value. |
| clone() | deep structuredClone of a Some; None returns itself. |
| toString() | string representation. |
| match({ Some, None }) | pattern match. |
| toJSON() | serialization hook — Some serializes to its value, None serializes to null. |
Examples
// Compare without unwrapping
const opt = Some(5);
opt.isSome(5); // true — avoids `opt.isSome() && opt.unwrap() === 5`
opt.isSome(9); // false
// map short-circuits to None on missing nested data
Some({ value: { nested: undefined } })
.map((x) => x.value.nested) // None (re-wrapped)
.isNone(); // true
// mapOr supplies a default when the mapped value is empty
Some({ value: { nested: undefined as unknown as string } })
.mapOr((x) => x.value.nested, 'default')
.unwrap(); // "default"
// unwrapOr / okOr accept a value or a lazy callback
Option<number>(undefined).unwrapOr(() => 4); // 4
// JSON serialization treats None as null
JSON.stringify({ nested: None }); // '{"nested":null}'
JSON.stringify({ nested: Option(1) }); // '{"nested":1}'import { isOption } from 'bakutils-catcher';
isOption(Some(1)); // true
isOption(42); // falseConverting between Result and Option
Some(2).okOr('error'); // Ok(2)
None.okOr('error'); // Err('error')
Ok(2).toOption(); // Some(2)
Err('e').toOption(); // NoneResultTry
ResultTry(fn, args?, errorCase?) runs fn and always resolves to a Promise<Result<T, E>> — Ok on success, Err on a thrown error or rejected promise. It infers args from fn's parameters and awaits async results automatically.
import { ResultTry } from 'bakutils-catcher';
function add(a: number, b: number) { return a + b; }
const r1 = await ResultTry(add, [10, 20]); // Ok(30)
async function fetchUser(id: number): Promise<User> { /* ... */ }
const r2 = await ResultTry(fetchUser, [42]); // Ok(User) or Err(Error)errorCase controls the produced error. It can be a fixed error instance or a mapper (originalError, ...args) => E:
class MyError extends Error {}
function subtract(a: number, b: number) {
if (a < b) throw new Error('a < b');
return a - b;
}
// Fixed error
await ResultTry(subtract, [5, 10], new MyError('overridden'));
// Mapper that has access to the original error and the call arguments
await ResultTry(
subtract,
[5, 10],
(orig, a, b) => new MyError(`failed a=${a}, b=${b}: ${(orig as Error).message}`),
);ResultTry also accepts plain thenables and Xrm promise-likes — see below.
Decorators and function wrappers
Every catcher comes in two flavors: a method decorator (PascalCase, e.g. @Catcher) and a function wrapper (camelCase, e.g. catcher) for when you can't use decorators or prefer a functional style.
The handler always receives the same arguments:
type Handler = (err, fnName: string, ctx, ...args) => Return;
// ↑ thrown error
// ↑ name of the wrapped method/function
// ↑ `this` context at call time
// ↑ the original call argumentsThe wrapper preserves the original return shape: if the wrapped function returns a Promise, the wrapper returns a Promise; otherwise it stays synchronous.
Renamed in v5:
Catch→Catcher,DefaultCatch→DefaultCatcher.
@Catcher / catcher
Catches only a specific error subclass; anything else is re-thrown.
import { Catcher, catcher } from 'bakutils-catcher';
class DBError extends Error {}
// Decorator
class Repo {
@Catcher(DBError, (err, fnName) => {
console.warn(`${fnName} failed:`, err.message);
return null; // fallback value returned to the caller
})
query(): Row[] | null {
throw new DBError('timeout');
}
}
// Function wrapper
const safeQuery = catcher(
(sql: string) => { throw new DBError('timeout'); },
DBError,
(err) => null,
);The handler also receives the context and arguments, which is useful for building a recovery Result:
class FooBar {
@Catcher<Result<number, MyErr>, DBError, [number]>(
DBError,
(err, fnName, ctx, value) => Err({ err, value }),
)
load(value: number): Result<number, MyErr> {
throw new DBError('nope');
}
}@DefaultCatcher / defaultCatcher
Catches all Error instances (it is @Catcher(Error, handler)).
import { DefaultCatcher, defaultCatcher } from 'bakutils-catcher';
class DataService {
@DefaultCatcher((err) => {
console.error('An error occurred:', err);
return undefined; // fallback
})
async fetchData(url: string): Promise<Data | undefined> {
throw new Error('network down');
}
}
const safeJSON = defaultCatcher(
(txt: string) => JSON.parse(txt),
() => ({}), // fallback on any error
);
safeJSON('{ bad'); // {}@AnyErrorCatcher
Catches literally anything thrown — including non-Error values like strings or numbers — with no instanceof check.
import { AnyErrorCatcher } from 'bakutils-catcher';
class Whatever {
@AnyErrorCatcher(() => 'fallback')
run(): string {
throw 'string-error'; // not an Error instance
}
}
new Whatever().run(); // "fallback"OneOf
OneOf<LabelMap> is a type-safe tagged union: a value that is exactly one of several labeled variants. Create variants with createOneOf and handle them exhaustively with match.
import { createOneOf, OneOf } from 'bakutils-catcher';
type Shape = {
Circle: { radius: number };
Square: { side: number };
Rectangle: { width: number; height: number };
};
const circle: OneOf<Shape> = createOneOf('Circle', { radius: 5 });
function area(shape: OneOf<Shape>): number {
return shape.match({
Circle: ({ radius }) => Math.PI * radius ** 2,
Square: ({ side }) => side ** 2,
Rectangle: ({ width, height }) => width * height,
});
}
area(circle); // 78.539...Methods
| Method | Description |
| --- | --- |
| match(handlers) | exhaustive match — every label must be handled. |
| matchPartial(handlers, default) | match a subset; unmatched labels fall through to default(value). |
| is(label) | type guard that narrows value to the variant's type. |
| equals(other) | true if both variants share the same type and value. |
| map(fn) | transforms the contained value, keeping the same label. |
| toJSON() | serializes to { type, value }. |
| OneOfVariant.fromJSON(json) | static — rebuilds a variant from { type, value }. |
// Narrowing with `is`
if (circle.is('Circle')) {
circle.value.radius; // typed as number
}
// Partial matching
circle.matchPartial(
{ Circle: ({ radius }) => radius },
() => 0, // default for Square / Rectangle
);
// Serialization round-trip
const json = circle.toJSON(); // { type: 'Circle', value: { radius: 5 } }
const back = OneOfVariant.fromJSON<Shape, 'Circle'>(json);Full example
For an end-to-end pipeline that combines Option, Result (flatMap / flatMapAsync), okOr, ResultTry, a @Catcher-decorated service, and a OneOf state machine, see EXAMPLES.md.
Exported types
For consumers who need to annotate explicitly:
- Result:
Result<T, E>,Right<T, E>(Ok),Left<T, E>(Err) - Option:
Option<T>,SomeType<T>,NoneType<T>,RemoveOption<T> - OneOf:
OneOf<LabelMap>,OneOfVariant - Decorators:
Handler,Wrap,ErrCtor - ResultTry:
ErrorCase - Utilities:
ValueOrFn<T>, and theBAKUtilsIs*type guards (BAKUtilsIsPromise,BAKUtilsIsThenable,BAKUtilsIsXrmPromiseLike,BAKUtilsIsFunction,BAKUtilsIsXrmError) - Type guards:
isResult,isOption
