Guard Clause + Result
This is the first structure to apply in functions that mix validation, authorization, and dependency failures. Close off failure conditions early, and keep the function body focused on the success path. Failures are returned as Result data rather than thrown as exceptions.
Practice memo · 8 min read · Medium
This is the structure you can apply first in a function that mixes validation, authorization, and dependency failures. Close off failure conditions early, and leave the function body for the happy path. Return failures as Result data, not as throws.
The goal of this pattern is not to make the code shorter. It is to elevate failure paths into the function's contract. Callers can see directly from the types and return values whether a function can fail. By contrast, throw is hard to spot just from the signature — it's easy to miss in code review.
Guard clauses block failures at the top. Result turns those blocked failures into data. Using both together leaves the function body with only one concern: what to do when everything succeeds.
The reason to separate guard clauses is not simply to tidy up if statements. Each guard clause isolates a distinct failure cause and serves as a classification point for later connecting that failure to an ErrorPolicy. That is why guard clauses are commonly used together with ErrorPolicy.
For example, a missing value can be classified as required, a non-integer as not_integer, and an out-of-range value as out_of_range. These distinctions allow the call site or a higher-level policy layer to decide which failures should respond with 400 Bad Request, which should only be logged, and which should be translated into a user-facing message.
In short, guard clauses close off failures early, Result turns those failures into data, and ErrorPolicy decides how to handle that failure data.
Personal note: return predictable domain failures as Result, and let unexpected system failures propagate via exceptions or an upper-level policy handler.
The point is to expose the possibility of failure in the function's contract so callers cannot overlook their responsibility to handle it.
Expression
type Result<T> =
// The success path carries a real value.
| { ok: true; value: T }
// The failure path is not an exception but data the caller can handle.
| { ok: false; error: string };
function parseLimit(raw: string | null): Result<number> {
// Guard clause 1: if no value is present, close immediately.
// This ensures the normal flow below does not have to keep worrying about null, empty strings, or blank strings.
if (raw === null || raw.trim() === '') {
return { ok: false, error: 'limit is required' };
}
const limit = Number(raw);
// Guard clause 2: close values that cannot be interpreted as a number.
if (!Number.isFinite(limit)) {
return { ok: false, error: 'limit must be a number' };
}
// Guard clause 3: close values that are numeric but not permitted by policy.
if (!Number.isInteger(limit)) {
return { ok: false, error: 'limit must be an integer' };
}
// Guard clause 4: close values that fall outside the domain policy range.
if (limit < 1 || limit > 100) {
return { ok: false, error: 'limit must be between 1 and 100' };
}
// By the time execution reaches this point, only the normal flow remains.
return { ok: true, value: limit };
}
Call site
const result = parseLimit(request.query.limit);
// Failure is part of the function's return value.
// Handle domain failures with `if` statements, not `try/catch`.
if (!result.ok) {
return badRequest(result.error);
}
// Below this point, TypeScript narrows `result` to the success case.
// `result.value` can be safely used as a `number`.
return loadPage({ limit: result.value });
Reading order
Is the input empty?
Can it be converted to a value?
Does it satisfy the domain policy?
Can it be passed as a success value?
When the validation order is scrambled, error messages become scrambled too. For example, if you call Number(raw) without first blocking null, the messages "value is missing" and "cannot convert to a number" get mixed together.
The boundary between exceptions and Result
Use Result for predictable failures.
The user did not provide a required value
A number is outside the range allowed by policy
Insufficient authorization
An external API responded normally but the response does not meet a business condition
Reserve exceptions for unexpected failures.
The database connection is lost
File system permissions are broken
A code invariant is violated
A library throws an unrecoverable error
If you start throwing user input failures as exceptions, normal domain flow hides inside error-handling flow. Conversely, if you wrap every system failure in a Result, genuine failures look like ordinary branches. Neither is good.
When to use it
Request parameter validation
Authorization check
External API response validation
Pre-save policy validation
It can be overkill for small UI decisions. It pays off at boundaries where you need to trace failure causes, such as authentication, persistence, and external calls.
Bad example
function parseLimit(raw: string | null) {
const limit = Number(raw);
// If you collapse both failures into a single `bad limit`, the caller has no way to know the cause.
// A user providing no value and a user providing a non-integer value are two distinct failures.
if (!raw || !Number.isInteger(limit)) {
throw new Error('bad limit');
}
// With throw-based control flow, failure possibilities are not clearly visible from the types alone.
return limit;
}
In this code, failure reasons collapse into one place, and if the caller forgets try/catch, the control flow becomes invisible.
The call site is also a problem. Just from the name parseLimit(), it is hard to tell whether the function throws, what errors it throws, or which failures the caller needs to recover from. By contrast, Result<number> forces the caller to handle both success and failure.
Further considerations
In production code, it is better to separate error codes rather than using error: string. Strings are convenient for display, but they are weak for policy branching. With error codes, you can independently handle HTTP status, translation, logging, user messages, and retry decisions.
1. Separate failure types as codes.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type ParseLimitError =
| { code: 'required'; message: string }
| { code: 'not_number'; message: string }
| { code: 'not_integer'; message: string }
| { code: 'out_of_range'; min: number; max: number; message: string };
2. Each guard clause returns a unique failure code.
function parseLimit(raw: string | null): Result<number, ParseLimitError> {
if (raw === null || raw.trim() === '') {
return {
ok: false,
error: { code: 'required', message: 'limit is required' },
};
}
const limit = Number(raw);
if (!Number.isFinite(limit)) {
return {
ok: false,
error: { code: 'not_number', message: 'limit must be a number' },
};
}
if (!Number.isInteger(limit)) {
return {
ok: false,
error: { code: 'not_integer', message: 'limit must be an integer' },
};
}
if (limit < 1 || limit > 100) {
return {
ok: false,
error: {
code: 'out_of_range',
min: 1,
max: 100,
message: 'limit must be between 1 and 100',
},
};
}
return { ok: true, value: limit };
}
3. ErrorPolicy maps failure codes to actual handling behavior.
function handleParseLimitError(error: ParseLimitError) {
switch (error.code) {
case 'required':
case 'not_number':
case 'not_integer':
case 'out_of_range': {
return badRequest({
code: error.code,
message: error.message,
});
}
}
}
The call site does not interpret strings directly. The meaning of a failure belongs to ParseLimitError, and how to handle it is decided by handleParseLimitError().
const result = parseLimit(request.query.limit);
if (!result.ok) {
return handleParseLimitError(result.error);
}
return loadPage({ limit: result.value });
Dividing responsibilities this way makes each role clear. Guard clause = isolating the failure cause. Result<T, E> = expressing failure as a return value. ErrorCode = reliably identifying the kind of failure. ErrorPolicy = deciding how to handle the failure.
Strings are convenient for display but weak for branching. With error codes, you can handle HTTP status, translation, logging, and user messages separately.
Summary
If a failure is part of the domain flow, it should be returned as data rather than thrown as an exception.
Guard clause = isolating the failure cause
Result<T, E> = expressing failure as a return value
ErrorCode = reliably identifying the kind of failure
ErrorPolicy = deciding how to handle the failure