Migration from throwing
Step-by-step guide for converting try/catch code to result tuples with flow.
Every fallible function returns a ResultTuple<T> — readonly [T, null] on
success, readonly [null, Err] on failure — built with ok() and fail().
Step 1: Identify the throwing function
// Before: throws on error
function parseConfig(raw: string): Config {
const json = JSON.parse(raw); // throws SyntaxError
if (!json.port) throw new Error("port is required");
return { port: json.port, host: json.host ?? "localhost" };
}Step 2: Wrap with attempt()
The simplest migration — keep the internals, change the boundary:
import { attempt, ok, type ResultTuple } from "@pencroff-lab/kore";
function parseConfig(raw: string): ResultTuple<Config> {
return attempt(() => {
const json = JSON.parse(raw);
if (!json.port) throw new Error("port is required");
return ok({ port: json.port, host: json.host ?? "localhost" });
});
}attempt() catches any thrown exception and converts it with Err.from(). The
callback still has to return a tuple — ok(...) or fail(...) — so a missing
return is a compile error rather than a silent success.
Step 3: Replace throws with explicit failures
For real error codes and metadata, return fail() instead of throwing:
import { attempt, Err, fail, ok, type ResultTuple } from "@pencroff-lab/kore";
function parseConfig(raw: string): ResultTuple<Config> {
const [json, parseErr] = attempt<unknown>(() => ok(JSON.parse(raw)));
if (parseErr) {
return fail(parseErr.wrap("Invalid JSON", { code: "PARSE_ERROR" }));
}
const obj = json as Record<string, unknown>;
if (!obj.port) return fail("port is required", "MISSING_FIELD");
return ok({
port: obj.port as number,
host: (obj.host as string) ?? "localhost",
});
}Step 4: Update calling code
// Before: try/catch
try {
const config = parseConfig(raw);
startServer(config);
} catch (e) {
console.error("Failed:", e);
process.exit(1);
}
// After: destructure the result — there is nothing to unwrap
const [config, err] = parseConfig(raw);
if (err) {
console.error("Failed:", err.toString());
process.exit(1);
}
startServer(config);Or fold both channels inline with either:
either(
parseConfig(raw),
(config) => startServer(config),
(err) => {
console.error("Failed:", err.toString());
process.exit(1);
},
);Async functions
Use attemptAsync() for async throwing code:
// Before
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
// After
async function fetchUser(id: string): Promise<ResultTuple<User>> {
return attemptAsync(async () => {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
return fail(
Err.from(`HTTP ${res.status}`, {
code: "HTTP_ERROR",
metadata: { status: res.status, url: `/api/users/${id}` },
}),
);
}
return ok((await res.json()) as User);
});
}Common refactoring patterns
Wrapping a throwing library call
const safeJsonParse = (raw: string): ResultTuple<unknown> =>
attempt(() => ok(JSON.parse(raw)));Composing tuple-returning functions
A tuple-returning callee composes directly — no lifting step, no unwrapping:
const config = flatMap(findUser(id), loadConfigFor);
const loaded = pipe(
ok(raw),
onOk(parseConfig),
onOk(validate),
onErr((e) => fail(e.wrap("Startup failed", { code: "STARTUP" }))),
);Gradual migration
Nothing needs converting at once. Err.from() accepts a native Error, so a
legacy try/catch produces a value the rest of the code can carry:
try {
legacyOperation();
} catch (e) {
const err = Err.from(e as Error).withCode("LEGACY_ERROR");
return fail(err);
}Gotchas
attempt()catches every throw — including programming errors likeTypeError. To catch only expected failures, use an explicittry/catchinside the callback and returnfail(...)yourself.- A fallible callback must return a tuple. A bare value, a bare
Err, and a missing return are all compile errors.mapis the one exception: its return is data, never control flow. ok(err)carries an error as data. The tuple’s error slot is the only failure signal, so a value that happens to be anErr— or a tuple — is carried unchanged.ok()isundefined,ok(null)isnull.ok()is typedResultTuple<void>; it serializes to[null, null]and comes back asok(null).defaultTois handler-only. A constant fallback is a thunk:defaultTo(result, () => 0).- Error codes are not inherited — wrapping an
Errdoes not copy the code. Useerr.hasCode()to search the cause chain. Err.isErris nominal. A plain{ kind: "Err" }object is data, not an error; useErr.from(marker)to reconstruct a real instance from wire data.
See also
- flow examples — every pattern above, executable
- Err examples — section 3 “Catching native errors”
- Migrating from
Outcome— the class-tier operation map