name: better-result description: Use when migrating library code from thrown or rejected error flows to typed Result-based flows with better-result. Follow official better-result patterns for tagged errors, Result.try, generator composition, and matching.
better-result
Follow the official better-result guidance closely and adapt it to the local codebase only where naming or module boundaries require it.
Core Principles
- Model expected failures in the type signature with
Result<T, E>orPromise<Result<T, E>>. - Keep domain errors, infrastructure errors, and programmer defects separate.
- Use tagged errors for expected failures you want to handle.
- Let defects throw instead of forcing every problem into
Result. - Migrate incrementally from boundaries inward.
Recommended Migration Order
- Wrap throwing or rejecting boundaries.
- Introduce tagged errors for the boundary you are changing.
- Return
Resultfrom the changed functions. - Replace manual early-return plumbing with generator composition where it helps.
- Leave compatibility wrappers at the edge if the public API still throws or rejects.
Tagged Errors
Prefer TaggedError for expected failures you want to preserve and match on later.
Do:
class ValidationError extends TaggedError("ValidationError")<{
field: string;
message: string;
}>() {}
class NotFoundError extends TaggedError("NotFoundError")<{
resource: string;
id: string;
message: string;
}>() {}
Do not:
class AppError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
}
}
That loses the narrow tagged structure that better-result is designed to support.
Result.try and Result.tryPromise
Use these only at real exception or rejection boundaries.
Do:
function parseJsonResult(input: string): Result<unknown, ValidationError> {
return Result.try({
try: () => JSON.parse(input),
catch: () =>
new ValidationError({
field: "payload",
message: "Invalid JSON",
}),
});
}
Do:
async function fetchUserResult(id: string): Promise<Result<User, NetworkError>> {
return Result.tryPromise({
try: () => apiClient.fetchUser(id),
catch: (cause) =>
new NetworkError({
endpoint: "/users",
message: "Request failed",
cause,
}),
});
}
Do not:
function requirePositiveResult(value: number): Result<number, ValidationError> {
return Result.try({
try: () => {
if (value <= 0) {
return Result.err(new ValidationError({ field: "value", message: "Must be positive" }));
}
return Result.ok(value);
},
catch: () => new ValidationError({ field: "value", message: "Bad value" }),
});
}
That function is pure. Use direct Result.err(...) or Result.ok(...) instead.
Direct Domain Checks
Do:
function requirePositiveResult(value: number): Result<number, ValidationError> {
if (value <= 0) {
return Result.err(
new ValidationError({
field: "value",
message: "Must be positive",
}),
);
}
return Result.ok(value);
}
Do not hide straightforward validation behind try or gen.
Generator Composition
Use Result.gen(...) for multi-step flows where each step can fail.
Do:
function loadConfigResult(raw: string): Result<Config, ValidationError | NotFoundError> {
return Result.gen(function* () {
const json = yield* parseJsonResult(raw);
const env = yield* readEnvironmentResult(json);
const config = yield* validateConfigResult(env);
return Result.ok(config);
});
}
Do not:
function loadConfigResult(raw: string): Result<Config, ValidationError | NotFoundError> {
const json = parseJsonResult(raw);
if (Result.isError(json)) {
return json;
}
const env = readEnvironmentResult(json.value);
if (Result.isError(env)) {
return env;
}
return validateConfigResult(env.value);
}
Use Result.await(...) for async result-returning steps:
const user = yield* Result.await(fetchUserResult(userId));
Do not use Result.gen(...) for trivial one-step functions:
function parsePortResult(value: number): Result<number, ValidationError> {
if (value < 1 || value > 65535) {
return Result.err(new ValidationError({ field: "port", message: "Out of range" }));
}
return Result.ok(value);
}
Matching
Use matching when behavior genuinely depends on the error tag.
Do:
return matchError(error, {
ValidationError: (err) => ({ status: 400, body: err.message }),
NotFoundError: (err) => ({ status: 404, body: `${err.resource} not found` }),
});
Do not match when you only need a pass-through check:
if (ValidationError.is(error) || NotFoundError.is(error)) {
return error;
}
That branch is simpler than a match and should stay simple.
Error Checks
Prefer class guards from TaggedError.
Do:
if (ValidationError.is(error) || NotFoundError.is(error)) {
return error;
}
Avoid instanceof in migrated code when the generated .is(...) guard is available.
Avoid one-off helper abstractions unless reused enough to reduce duplication.
Preserve Narrow Error Shape
Do:
return Result.err(
new NotFoundError({
resource: "User",
id,
message: `User ${id} was not found`,
}),
);
Do not:
return Result.err(
new Error(`Operation failed: user ${id} missing`),
);
Do not collapse a specific, actionable error into a generic fallback unless the boundary genuinely lacks enough structure to do better.
Defects vs Expected Errors
Expected failure:
function findUserResult(id: string): Result<User, NotFoundError> {
const user = users.get(id);
if (!user) {
return Result.err(new NotFoundError({ resource: "User", id, message: "User not found" }));
}
return Result.ok(user);
}
Defect:
function renderUserCard(user: User): string {
if (!user.profile) {
throw new Error("Invariant violated: profile must be present before rendering");
}
return user.profile.displayName;
}
Do not convert internal invariant failures into routine tagged errors just because Result is available.
Compatibility Edges
If the public API still throws or rejects, keep that behavior as a thin adapter over result-native internals.
Do:
async function getUser(id: string): Promise<User> {
return Result.unwrap(await getUserResult(id));
}
Do not:
async function getUserResult(id: string): Promise<User> {
const user = Result.unwrap(await fetchUserResult(id));
return user;
}
The inner function should stay result-native. The outer compatibility edge unwraps.
Review Checklist
- Does the function signature now show expected failures explicitly?
- Is
Result.tryorResult.tryPromiseused only at throw or reject boundaries? - Is
Result.gen(...)simplifying a real multi-step flow rather than adding ceremony? - Are tagged errors narrow and meaningful?
- Are defects still allowed to throw?
- Are there direct tests for the result-returning API?
- If a compatibility API remains, do tests still cover its throw or reject behavior?
Avoid
- Wrapping already-result-native functions in
try. - Re-throwing inside
Resultfunctions instead of returningResult.err(...). - Using matching where a simple guard is clearer.
- Exporting
better-resultimplementation details from public APIs unless that API is intentionally Result-native.
References
- Official best practices:
https://better-result.dev/advanced/best-practices - AI agent guidance:
https://better-result.dev/guides/ai-agents