@pencroff-lab/kore
v0.7.0
Published
Core utilities library
Readme
@pencroff-lab/kore
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/koreRequires 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
- Flow operation flows -- how
flowcomposes operations, end to end - Error handling guide --
Errpractical examples - Logging guide -- Patterns, conventions, and integration strategies
- TS docstrings general guide
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 treeSerialization
// 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 directlyok(...) 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 explicitmap 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 throwsfromJSON 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.
| Outcome | flow |
|---|---|
| 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 result | ok(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
Errgains an explicitok(...)orfail(...). The compiler locates every site, because neither a bare value nor a missing return satisfiesResultTuple. defaultTois handler-only. A value-form call no longer type-checks and becomes a thunk:defaultTo(tuple, () => value).Err.isErris 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 withErr.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 timezoneLogger
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/