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/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.
Agents can start with the core subset and safety rules .
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 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 and the explicit value adapters carry callback returns as data, including
an Err or a tuple. A fallible callback that returns a bare value, a bare
Err, or nothing at all does not compile on the typed path.
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 } }));
attemptValue(() => JSON.parse(raw)); // unknown data; validate before use
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 accept at most ten stages; compose longer flows in typed chunks.
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.
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 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
Release process
This section is the maintainer checklist for publishing the library and its
versioned documentation. The documentation implementation and recovery model
are described in more detail in site/README.md
.
1. Finish the code and documentation
Keep package.json#version at the currently published version while ordinary
feature work is in progress. Before merging a feature, make its commit messages
final: git-cliff derives the next version from Conventional Commits. Use !
and a BREAKING CHANGE: footer for a real breaking change, for example:
feat(flow)!: add tuple-based error boundaries
BREAKING CHANGE: flow replaces Outcome as the primary result API.Regenerate documentation whenever exported APIs, JSDoc, examples or navigation change, and commit generated files with their source change:
bun install --frozen-lockfile
bun run docs:generate
bun run docs:checkValidate code and documentation before preparing the release:
bun run lint:ci
bun run check:boundaries
bun test --coverage --path-ignore-patterns='scripts/docs/build.integration.test.ts'
bun test scripts/docs/build.integration.test.ts
bun run build
bun run docs:build
bun run docs:check -- --site build/docs-site/publicThe documentation integration tests and site build require the exact Bun, Go,
Hugo Extended and Hextra versions listed in
site/toolchain.json
.
2. Prepare the release
Create a release-preparation branch from the latest main:
VERSION=0.8.0
git switch main
git pull --ff-only origin main
git switch -c "release/prepare-${VERSION}"Check the version inferred from the commit history, generate the changelog, and
set package.json#version to the same version without the leading v:
bunx git-cliff --bumped-version
bun run bump_versionReview CHANGELOG.md; do not accept the inferred version blindly. If it is
wrong, fix the Conventional Commit messages before they reach main, then
regenerate the changelog. Once the version is final, regenerate documentation
so its source links use v<version> and create the immutable snapshot:
bun run docs:generate
bun run check:release
bun run docs:check
bun run docs:snapshot "$VERSION"
bun run docs:release-stateThe final command must report mode=release, snapshot_present=true and
publication_recorded=false. Review and commit every generated release file:
git status
git diff --stat
git add -A
git commit -m "chore(release): prepare ${VERSION}"
git push -u origin "release/prepare-${VERSION}"Open a pull request. Pull-request CI validates the package and complete site but
does not publish. Merging the prepared commit to main publishes npm, verifies
the exact registry version, creates v<version>, and uploads the verified
publication-receipt-<version> artifact.
After upload, npm may keep a version in post-publish validation for several
minutes. CI polls every 20 seconds for up to about 30 minutes, waiting for both
the exact version and latest to point to it before checking the tag and
recording the receipt. A timeout reports the last registry observation.
3. Automatic publication receipt and deployment
After verifying npm and the release tag, the publish job commits
site/published.json directly to main using REPO_TAG_TOKEN. That commit
triggers the documentation-only workflow: it validates the receipt and deploys
GitHub Pages without publishing npm or moving the tag again. No receipt download
or second pull request is normally needed. Follow the receipt commit’s CI run
to confirm deployment completed.
An identical receipt on main makes a rerun a no-op. The update compares the
receipt’s blob SHA with the release commit, so it cannot overwrite a receipt
changed by another release. If the automatic step fails, publication remains
successful and the job summary points to the verified
publication-receipt-<version> artifact. Use the manual receipt recovery
procedure
only for that fallback.
The site build also refreshes development metadata automatically. Once the
package version is recorded as published, /next/ becomes Next (unreleased)
in the menu, banner, edition metadata and llms.txt. Its rendered source links
follow the checked-out commit. When a new unrecorded package version is
prepared, its version label appears automatically. No catalog label edit or
extra version bump is needed after publication.
Troubleshooting releases
git-cliff suggests the wrong version
Inspect the commits since the latest tag. A breaking release needs ! after
the type or scope, or a BREAKING CHANGE: footer. Rewrite incorrect messages
before merging the feature branch. If the commits are already on main, avoid
rewriting shared history merely to change the changelog: select the intended
version explicitly, review CHANGELOG.md, and continue with a release-prep
branch.
Release state is unprepared
package.json names a version that is neither recorded as published nor
present under site/versions/. Confirm that CHANGELOG.md contains the exact
version, then run:
VERSION=0.8.0
bun run check:release
bun run docs:generate
bun run docs:snapshot "$VERSION"
bun run docs:release-stateNever add a site/versions.yaml entry manually; docs:snapshot creates the
directory, manifest, hashes and catalog entry as one validated operation.
npm publish failed before the version appeared on npm
Fix the reported authentication, permission, network or package-validation error and rerun the release. The prepared snapshot is still only a candidate, and no receipt should be committed until the exact version exists on npm.
npm published, but receipt generation failed
Rerun the failed release job. The publish step skips an exact version that
already exists, then waits for registry availability and latest before
continuing through the tag and receipt checks. A rerun trusts the existing
registry artifact: its integrity is recorded, but no comparison with a local
build is made. Investigate an unexpected artifact rather than treating the
skip as proof of its origin. Do not bump the version just because npm’s
post-publish validation delayed the verification step.
Reruns use the workflow at the original release commit. For older runs without
the skip/wait steps, or a recorder that needs a tooling fix, use the recovery
procedure below. This includes the old Executable not found in $PATH: "npm"
failure; the current recorder uses bun info.
The recorder requires HEAD to equal the commit referenced by v<version>.
Create a recovery branch at that tag, apply any verifier fix without committing
it yet, verify the operation, and then write the receipt:
VERSION=0.8.0
git fetch origin --tags
git switch -c "recovery/record-${VERSION}" "v${VERSION}"
# Apply the required tooling fix here, but keep HEAD at "v${VERSION}".
bun run docs:record-publication -- "$VERSION" --dry-run
bun run docs:record-publication -- "$VERSION"
bun run docs:release-stateCommit only site/published.json first, then commit any tooling fix separately.
The resulting branch reports mode=docs-only; merging it skips npm publication
and resumes the documentation deployment.
The version already exists on npm
First determine whether it is the package produced by the current release. If
it is the expected release and v<version> points at its release commit, rerun
the failed release job; it skips publication and resumes verification. For an
older workflow, use the receipt-recovery procedure above. If it belongs to
another build, stop and investigate; npm versions are immutable, so the intended
release needs a new version rather than an overwritten artifact.
The release tag is missing or points at another commit
Compare the tag and release commit:
VERSION=0.8.0
git rev-parse "v${VERSION}"
git rev-parse HEADDo not move an existing published tag automatically. Confirm which commit produced the npm artifact first. A missing tag may be created at that verified commit; a mismatched tag requires manual investigation.
The receipt exists but was not committed
The publish job normally commits it automatically. If that step fails, inspect
its error and download publication-receipt-<version> from the successful
publication run. Start a recovery branch from current main:
VERSION=0.8.0
RUN_ID=123456789
git fetch origin main
git switch -c "docs/record-${VERSION}" origin/main
gh run download "$RUN_ID" \
--name "publication-receipt-${VERSION}" \
--dir build/receipt
cp build/receipt/published.json site/published.json
git diff -- site/published.json
bun run docs:release-state
git add site/published.json
git commit -m "docs: record ${VERSION} publication"A 409 error means the receipt changed after the release commit. Review the
diff before committing: retain newer release entries and latestStable rather
than replacing them with an older artifact. The classifier must report
mode=docs-only. Merge the recovery PR to trigger the documentation-only
workflow. A release-job rerun with the updated workflow also skips the existing
npm version. If the artifact expired, check out
v<version> and recreate it with docs:record-publication as described in
receipt-generation recovery
.
Documentation validation cannot find Hugo
Install the Extended edition and match site/toolchain.json
.
Library tests intentionally exclude scripts/docs/build.integration.test.ts;
run that suite only in an environment where Hugo is available.
GitHub Actions warns that Node.js 20 is deprecated
The warning refers to the JavaScript runtime used internally by an action, not
to Bun or this package. Upgrade the named action to a Node.js 24-based major;
for example, replace actions/checkout@v4 with actions/checkout@v5.
npm succeeded but GitHub Pages deployment failed
Confirm the automatic receipt commit succeeded; use manual recovery if it
is still pending. Then rerun the failed deployment or manually dispatch the CI
workflow with deploy_docs_only=true.
That path rebuilds and deploys the committed documentation state and cannot
republish npm or move the release tag.