Skip to content
Package README

Package README

TypeScript core utilities library providing value-based error handling types inspired by Go-style error handling. Published as a dual ESM/CJS package.

Since v0.7.0 the primary API is flow — free functions over a plain ResultTuple<T> ([value, null] or [null, Err]). The Outcome<T> class is deprecated and frozen from v0.7.0 onward; v0.7.0 itself changed it, so see the changelog when upgrading and Migrating from Outcome when moving off it.

Install

npm install @pencroff-lab/kore
# or
bun add @pencroff-lab/kore

Requires TypeScript 5.9+ as a peer dependency.

Documentation

The package ships its own documentation. After installing, start at docs/README.md ; agents should read llms.txt , the machine-readable index of the same set. Both resolve inside the installed package, so they always describe the version you have.

Detailed API documentation for each module:

  • Err – Immutable, value-based error type with wrapping, aggregation, and serialization
  • flow – Free functions over result tuples: the primary API
  • test helpers – expectOk / expectErr, from @pencroff-lab/kore/test
  • Outcome<T> – Deprecated monadic container, frozen in v0.7.0
  • dtStamp – Filesystem/log-safe date formatting utility
  • Logger – Structured logging with transport DI and Err integration

Guides

Examples

Generated from executable tests: all examples .

API

Err

Immutable, value-based error type. Supports wrapping (cause chains), aggregation (multiple errors), hierarchical error codes, JSON serialization, and conversion to native Error.

import { Err } from "@pencroff-lab/kore";

// Create an error with a code
const err = Err.from("User not found", "NOT_FOUND");

// Wrap with context (returns a new instance)
const wrapped = err.wrap("Failed to load profile");

// Aggregate multiple errors
let errors = Err.from("Validation failed");
errors = errors.add("Name is required");
errors = errors.add(Err.from("Invalid email", "INVALID_EMAIL"));

if (errors.isAggregate) {
  console.log(errors.toString());
}

Creating errors

// From string with code
Err.from("Timeout", "TIMEOUT");

// From string with options
Err.from("Connection failed", {
  code: "DB_ERROR",
  metadata: { host: "localhost" },
});

// From native Error (preserves stack and cause chain)
try {
  riskyOperation();
} catch (e) {
  Err.from(e as Error, { code: "OPERATION_FAILED" });
}

// Wrap native errors with context
try {
  await db.query(sql);
} catch (e) {
  Err.from(e as Error).wrap("Database query failed");
}

Error inspection

const err = Err.from("DB error", "DB:CONNECTION:TIMEOUT")
  .wrap("Repository failed")
  .wrap("Service unavailable");

err.hasCode("DB:CONNECTION:TIMEOUT"); // true - exact match in chain
err.hasCodePrefix("DB"); // true - hierarchical prefix match
err.root.message; // "DB error" - original error
err.chain(); // array from root to current
err.find((e) => e.code === "DB:CONNECTION:TIMEOUT"); // find in tree

Serialization

// Serialize for API responses
const json = err.toJSON({ stack: false }); // omit stack for public APIs

// Deserialize
const restored = Err.fromJSON(json);

// Convert to native Error
throw err.toError();

flow

Free functions over ResultTuple<T> — readonly [T, null] on success, readonly [null, Err] on failure. There is no container to construct or unwrap: a result destructures at any point.

import { ok, fail, pipe, onOk, onErr, defaultTo, type ResultTuple } from "@pencroff-lab/kore";

const parsePort = (raw: string): ResultTuple<number> => {
  const port = Number(raw);
  return Number.isInteger(port) ? ok(port) : fail(`invalid port: ${raw}`, "PORT");
};

const [port, err] = parsePort("8080");
if (err) return err;

The callback protocol

Every fallible callback returns a ResultTuple. The error slot is the only failure signal — no operator inspects a value’s runtime shape:

flatMap(ok(2), (n) => ok(n * 2));        // success
flatMap(ok(2), () => fail("nope"));       // failure
flatMap(ok(1), readConfig);               // a tuple-returning callee composes directly

ok(...) is the one way to carry error- or tuple-shaped data as a success:

ok(Err.from("this is data"));   // ResultTuple<Err>
ok(ok(1));                      // ResultTuple<ResultTuple<number>> — nesting is explicit

map is the single exception: its return is never inspected, so an Err or a tuple returned from map is data. A callback that returns a bare value, a bare Err, or nothing at all does not compile.

Boundaries and transformations

ok(42);                       // [42, null]
ok();                         // [undefined, null] — ResultTuple<void>
fail("Not found", "NOT_FOUND");
fail(Err.from(caught, { code: "READ_FAILED", metadata: { path } }));

attempt(() => ok(JSON.parse(raw)));            // converts a throw to a failure
await attemptAsync(async () => ok(await fetchUser(id)));

map(result, (n) => n * 2);                     // total callback, return-preserving
flatMap(result, readConfig);                   // fallible callback
mapErr(result, (e) => fail(e.wrap("Failed to load project").withCode("LOAD")));
effect(result, ([v, e]) => log(v ?? e));       // side effect on either channel
ensure(result, isPositive, (n) => Err.from(`not positive: ${n}`, "RANGE"));

Error enrichment has no flow helpers of its own — chain the Err methods inside mapErr / onErr.

Pipelines

const loaded = pipe(
  ok(raw),
  onOk(parse),
  onOk(validate),
  onErr((e) => fail(e.wrap("Failed to load config", { code: "CONFIG" }))),
);

const fetched = await pipeAsync(
  Promise.resolve(ok(userId)),          // a promised source needs no extra await
  onOkAsync(fetchUser),
  onOk(normalize),
  onErr((e) => ok(anonymousUser(e))),   // recovery after a failed or throwing stage
);

pipe and pipeAsync copy the source once on entry and catch each stage separately, so a later onErr stage can recover from a throwing custom stage. Both are typed through ten stages; past that the result widens to ResultTuple<unknown>. A reusable pipeline is an ordinary typed function:

const transform = (source: ResultTuple<Input>) =>
  pipe(source, onOk(step1), onOk(step2), onErr(recover));

Terminal operations, collections, serialization

defaultTo(result, () => 0);              // handler-only — a constant fallback is a thunk
defaultTo(result, (e) => recover(e));
either(result, (v) => render(v), (e) => renderError(e));

all([readA(), readB()]);                 // ordered, non-short-circuiting, aggregates every error
all([ok(1), ok("two")] as const);        // heterogeneous: ResultTuple<[number, string]>
any([primary(), replica()]);             // first success by reference; any([]) is EMPTY_INPUT

JSON.stringify(ok({ id: 1 }));           // [{"id":1},null] — no wrapper needed
fromJSON(payload);                       // validates the envelope; never throws

fromJSON validates only the tuple envelope and the error slot — T is a caller assertion. An invalid payload returns an INVALID_JSON failure carrying the payload as originalValue metadata.

Input protection

flow protects the tuple container, not the payload. A pipeline copies its source once on entry, effect hands its callback the output copy, and onTuple hands it an input copy — so callback code cannot mutate the tuple you passed in. Success values, Err instances, and metadata are never cloned. Use the exported copy when a custom operator needs an isolated tuple.

Test helpers

expectOk and expectErr ship from a separate subpath, so they never reach production code. They import no test framework:

import { expectOk, expectErr } from "@pencroff-lab/kore/test";

const port = expectOk(parsePort("8080"));
const error = expectErr(parsePort("nope"));

Outcome<T>

Deprecated since v0.7.0. The class stays exported and its shape is frozen from v0.7.0 onward — but v0.7.0 is itself a breaking release for it. Upgrading from v0.6.x, see the changelog; writing new code, see Migrating from Outcome .

Monadic container wrapping ResultTuple<T> ([T, null] | [null, Err]). Supports map/flatMap/mapErr/pipe/pipeAsync chains, combinators (all, any), side effects (effect), and terminal operations (toTuple, defaultTo, either).

Callbacks follow the value protocol: a returned Err is the failure, a returned Outcome passes through, and anything else is the success value. fromTuple/fromTupleAsync are the only entry points that read a [value, error] tuple as control flow.

import { Outcome, Err } from "@pencroff-lab/kore";

// Create from callback
const outcome = Outcome.from(() => {
  if (!isValid(input)) return Err.from("Invalid input", "VALIDATION");
  return processedValue;
});

// Extract with tuple destructuring
const [value, err] = outcome.toTuple();
if (err) {
  console.error(err.message);
  return;
}
console.log(value);

Construction

Outcome.ok(42); // success
Outcome.err("Failed", "ERROR_CODE"); // error (Outcome<never>)
Outcome.err<number>("Failed"); // error that still chains as Outcome<number>
Outcome.ok(); // void success (Outcome<void>, value undefined)
Outcome.ok(null); // explicit null success (Outcome<null>)
Outcome.ok(someErr); // an Err carried as the success value

// From sync callback (catches throws)
Outcome.from(() => JSON.parse(input)); // may throw

// From async callback
await Outcome.fromAsync(async () => {
  const res = await fetch("/api/data");
  if (!res.ok) return Err.from("Request failed", "HTTP_ERROR");
  return await res.json();
});

// From a Go-style function returning [value, error]
Outcome.fromTuple(() => readConfig());
await Outcome.fromTupleAsync(() => loadConfig());

Transformations

const result = Outcome.ok(5)
  .map((n) => n * 2) // transform success, cannot fail
  .flatMap((n) => (n > 5 ? Outcome.err("Too big") : Outcome.ok(n))) // step that can fail
  .mapErr((err) => err.wrap("Added context")) // transform error
  .toTuple();

// Pipe for sequential transformations with access to both value and error
const piped = Outcome.ok(rawInput).pipe(
  ([val, err]) => (err ? err : validate(val)),
  ([val, err]) => (err ? err : transform(val)),
);

Combinators

// All must succeed (collects all errors)
const all = Outcome.all([Outcome.ok(1), Outcome.ok(2), Outcome.ok(3)]);
// all.toTuple() === [[1, 2, 3], null]

// First success wins
const any = Outcome.any([
  Outcome.err("Failed"),
  Outcome.ok(42),
  Outcome.ok(100),
]);
// any.toTuple() === [42, null]

Terminal operations

// Default value on error
const count = parseNumber(input).defaultTo(0);

// Computed fallback
const name = fetchUser(id).defaultTo((err) =>
  err.hasCode("NOT_FOUND") ? "Guest" : "Unknown",
);

// Transform both cases
const response = processOrder(orderId).either(
  (order) => ({ status: 200, body: order }),
  (err) => ({ status: err.hasCode("NOT_FOUND") ? 404 : 500, body: { error: err.message } }),
);

Migrating from Outcome

Outcome and flow share one ResultTuple<T> declaration, so toTuple() is the bridge and migration is call-site local.

Outcomeflow
Outcome.ok(value) / Outcome.ok()ok(value) / ok()
Outcome.err(error)fail(error)
Outcome.err<T>(...)fail(...) — ResultTuple<never> threads through later operations
Outcome.from(fn)attempt(() => ok(fn())), or attempt(() => cond ? ok(v) : fail(...))
Outcome.fromAsync(fn)attemptAsync(async () => ok(await fn()))
Outcome.fromTuple(tuple)use the tuple directly
Outcome.fromTuple(callback)attempt(callback)
outcome.toTuple()destructure the result directly
outcome.map(fn)map(tuple, fn)
outcome.flatMap(fn)flatMap(tuple, fn); a value-returning fn becomes (v) => ok(fn(v))
outcome.mapErr(fn)mapErr(tuple, fn); enrichment becomes (e) => fail(e.wrap(...)), recovery (e) => ok(fallback)
outcome.effect(fn)effect(tuple, fn)
outcome.defaultTo(value) / defaultTo(value, true)defaultTo(tuple, () => value)
outcome.defaultTo(handler)defaultTo(tuple, handler)
outcome.either(...)either(tuple, ...)
outcome.pipe(...)pipe(tuple, onOk(...), onErr(...), onTuple(...))
Outcome.all(...) / Outcome.any(...)all(...) / any(...)
Outcome.fromJSON(payload)fromJSON(payload)
Outcome.ok(err) in a callback or as a resultok(err) in both positions

Migration is not textual. Three changes need attention:

  • Every fallible callback now returns a tuple. A callback that returned a bare value or a bare Err gains an explicit ok(...) or fail(...). The compiler locates every site, because neither a bare value nor a missing return satisfies ResultTuple.
  • defaultTo is handler-only. A value-form call no longer type-checks and becomes a thunk: defaultTo(tuple, () => value).
  • Err.isErr is nominal. Code that relied on a plain { kind: "Err" } marker being treated as an error now sees that object as success data. This one is not caught by the compiler — construct a real instance with Err.from(marker).

Utilities

dtStamp

Formats a Date into a compact timestamp string. Useful for filenames, logs, and identifiers.

import { dtStamp } from "@pencroff-lab/kore";

dtStamp(); // "20260218_153045"
dtStamp(new Date(), { parts: "date" }); // "20260218"
dtStamp(new Date(), { parts: "time", ms: true }); // "153045_123"
dtStamp(new Date(), { readable: true }); // "2026-02-18_15:30:45"
dtStamp(new Date(), { tz: "local" }); // uses local timezone

Logger

Structured, callable logger with transport DI, child loggers, and automatic Err formatting.

import { log, createLogger } from "@pencroff-lab/kore";

// Default logger
log("Application started");
log(log.WARN, "Connection slow");
log(log.ERROR, "Failed to save", { userId: "123" });

// Module-specific logger
const dbLog = createLogger("database");
dbLog("Connected to postgres");

// Child loggers with inherited context
const userLog = dbLog.child("users", { version: "1.0" });
userLog("User created");
// Output: [database] [users] User created {"version":"1.0"}

// Err instances rendered automatically
const [data, err] = fetchData();
if (err) {
  log(log.ERROR, "Fetch failed", err);
  // Output includes indented Err.toString() below the log line
}

Development

bun install                # install dependencies
bun test                   # run tests
bun run test:coverage      # run tests with coverage (>= 83%)
bun run lint               # lint + type check (auto-fix)
bun run build              # build ESM + CJS to dist/

License

Apache-2.0