Error Handling Patterns: Exceptions vs Result Types (When to Use Which)

Last updated: ⏱ Reading time: ~20 minutes

AI-assisted guide Curated by Norbert Sowinski

Share this guide:

Diagram-style illustration comparing exception-based and Result-type error handling with expected domain failures, unexpected runtime failures, typed errors, propagation, retries, recovery boundaries, logging, validation, and application-layer translation

Discussions about error handling often become:

exceptions are bad

or

Result types create boilerplate

but the more useful question is:

What does this failure mean
to the caller?

Some failures are normal outcomes:

invalid email

product out of stock

username already exists

authentication rejected

Others indicate that ordinary execution cannot continue:

database connection unexpectedly lost

required configuration missing

disk operation failed

invariant violated

Those categories often deserve different handling mechanisms.

Use the mechanism that communicates intent

Result-style values are strong when a caller is expected to inspect and branch on failure. Exceptions are strong when failure needs to escape several layers before a meaningful recovery point exists. A healthy system can use both and translate between them at deliberate boundaries.

1. Start by classifying the failure

Before choosing syntax, classify the event.

Expected business outcome

reserveSeat()

possible outcomes:

Reserved

AlreadyReserved

EventFull

None of these necessarily means the system malfunctioned.

Expected invalid input

parseAge("abc")

→ invalid number

Invalid input is part of the function's normal operating domain.

Recoverable technical failure

remote service unavailable

The caller may:

retry
fall back
show temporary error

Unexpected programming failure

impossible state reached

array invariant broken

required object unexpectedly null

Pretending this is an ordinary business outcome can hide a bug.

Fatal process condition

Some environments have failures for which continuing safely is not realistic.

Those may belong to:

panic
fatal error
process termination

rather than normal Result handling.

2. Understand what exceptions are good at

Exceptions allow failure to cross intermediate functions without every function changing its return type.

Conceptual call chain

handleRequest()
    ↓
createInvoice()
    ↓
saveInvoice()
    ↓
databaseDriver.write()
    ↓
DatabaseUnavailable

If:

createInvoice()

cannot meaningfully recover from a database outage, propagating the exception may be clearer than forcing it to handle:

DatabaseUnavailable

locally.

Stack unwinding

Exceptions can unwind through multiple stack frames until a handler is found.

This is useful when intermediate functions only know:

I cannot complete my operation

and a higher layer knows:

return HTTP 503

Exceptions integrate naturally with cleanup mechanisms

Patterns such as:

try / finally

defer

using / with

RAII

help release resources as execution leaves a scope.

Exceptions preserve diagnostic context well when used correctly

They can carry:

Where exceptions become problematic

Problems appear when exceptions represent ordinary branching:

try:
    findUser()
catch UserNotFound:
    createUser()

if:

not found

is expected and common.

They also become difficult when:

any function may throw
anything
at any time

and API contracts do not communicate likely failure modes.

3. Understand what Result types are good at

Exceptions vs Result types decision tree (diagram)

Decision tree for choosing between exceptions and Result types based on whether failure is expected, whether the immediate caller should branch on it, whether recovery exists locally, whether the error must cross several layers, whether the failure is a domain outcome or unexpected technical problem, and whether the language ecosystem provides idiomatic typed error handling

A Result-style type makes success and failure explicit in the return value.

Result<User, ParseError>

conceptually means:

Success(User)

or

Failure(ParseError)

The caller cannot pretend the failure does not exist

result = parseUser(input)

match result:
    success(user)
    failure(error)

Exact syntax differs by language.

Result types are useful for expected branches

Examples:

parse configuration value

validate command

check inventory

authenticate credentials

reserve resource

decode user input

Typed failures improve APIs

Prefer:

Result<Order, OrderError>

where:

OrderError =
    ProductNotFound
    OutOfStock
    InvalidQuantity
    PaymentDeclined

over:

Result<Order, String>

Why strings are weak error contracts

The caller should not need:

if error.message contains
"out of stock"

to determine behavior.

Result types can compose

Languages and libraries often support:

map
flatMap
andThen
bind
?
match

so successful values continue while failures propagate automatically.

Result types also have costs

If every low-level failure appears in every function signature:

Result<A, DatabaseError>

Result<B, FileError>

Result<C, NetworkError>

application code can become dominated by plumbing.

That is a sign to reconsider:

which failures are actually
part of the caller's contract?

4. Model expected domain failures explicitly

Suppose a transfer operation can legitimately fail because:

account missing

insufficient balance

currency unsupported

daily limit exceeded

Those conditions are part of the business model.

Explicit domain error

TransferResult =
    Success(Transfer)
    AccountNotFound
    InsufficientFunds
    UnsupportedCurrency
    DailyLimitExceeded

lets the caller map each outcome intentionally.

User-facing layer

InsufficientFunds
      ↓
"Your available balance
is too low for this transfer."

API layer

DailyLimitExceeded
      ↓
HTTP 409 or domain-specific response

depending on the API design.

Do not throw because a user made a normal choice

A payment being declined may be important, but it is not necessarily a software defect.

Treating it as:

UnhandledPaymentException

can produce noisy monitoring and confusing logs.

Expected does not mean unimportant

A typed Result can still be:

measured
logged where appropriate
shown to user
used in analytics

without pretending the application crashed.

5. Let unexpected failures propagate appropriately

Imagine:

calculateInvoice()

expects:
tax configuration loaded

actual:
configuration object missing

If that state violates a program invariant, returning:

Result<Invoice, MissingConfig>

from every billing function may hide that the system itself is broken.

Unexpected failures need visibility

They should often reach:

application boundary
monitoring
crash reporting
incident logs

Do not recover blindly

Dangerous:

catch Exception:
    return emptyInvoice()

because:

empty invoice

may now look valid while hiding data corruption.

Failing loudly can be safer than continuing incorrectly

Especially when dealing with:

Distinguish programmer errors from environmental failures

Both may use exceptions in some languages, but operational policy can differ.

database timeout:
possibly recoverable

violated invariant:
likely bug

6. Separate validation from exceptional failure

Validation is often predictable.

Example:

registerUser(input)

may need to report:

email invalid

password too short

name missing

Fail-fast validation

first error
      ↓
return

can be appropriate when later checks depend on earlier ones.

Accumulating validation

Forms often benefit from:

[
  InvalidEmail,
  PasswordTooShort,
  MissingName
]

so the user can fix several problems at once.

Exceptions are usually awkward for multi-error validation

Throwing:

InvalidEmailException

immediately prevents collecting:

password
+
name
+
phone

issues in the same pass.

Separate parsing from validation when useful

raw input
   ↓
parse
   ↓
typed value
   ↓
validate business rules

This lets callers distinguish:

cannot understand input

from:

input is validly structured
but violates a rule

7. Translate errors between architectural layers

A database layer might produce:

UniqueConstraintViolation

but the application layer may care about:

EmailAlreadyRegistered

Those are different abstractions.

Infrastructure layer

SQL unique constraint
"users_email_key"

Repository layer

DuplicateUserRecord

Application layer

EmailAlreadyRegistered

API layer

409 Conflict

The translation keeps database vocabulary out of the client contract.

Preserve original causes

When converting:

DatabaseTimeout

into:

ServiceUnavailable

retain diagnostic cause information internally.

Conceptually:

ServiceUnavailable
caused by DatabaseTimeout

Do not expose internal exceptions directly to users

Avoid responses such as:

NullPointerException
at BillingService.java:148

to an end user.

Translate into:

safe external message
+
internal diagnostic context

8. Recover only at meaningful boundaries

Error propagation across application layers (diagram)

Error propagation diagram showing infrastructure exceptions, repository translation, application Result types, controller or UI recovery boundaries, logging ownership, retry decisions, user-safe messages, preserved causes, and fatal unexpected failures reaching monitoring

A catch block should answer:

Why am I catching this here?

Good answers include:

Weak catch block

try:
    save()
catch:
    log("failed")

and then continue as though:

save succeeded

Boundary ownership

A web request handler might own:

exception
      ↓
log once
      ↓
map to safe HTTP response

A UI screen may own user recovery

Result:
NetworkUnavailable

      ↓

show:
"You're offline"

      ↓

offer:
Retry

Logging every layer creates duplicates

repository logs error

service logs same error

controller logs same error

global handler logs same error

one failure becomes four alerts.

Prefer one ownership point

Lower layers can add structured context to the error.

The owning boundary can decide:

log level
response
retry
alerting

9. Treat retries, timeouts, and cancellation separately

Not every failure should trigger:

retry

Potentially retryable

temporary network failure

service unavailable

rate limit with retry guidance

transaction conflict

Usually not retryable without changing input

invalid email

permission denied

unsupported currency

malformed request

Retry classification belongs in the error model

Better than:

catch Exception:
    retry 5 times

is:

if error is transient:
    retry according to policy

Retries need idempotency

Retrying:

chargeCreditCard()

after an ambiguous timeout can accidentally produce:

duplicate charge

unless the operation supports:

idempotency key
or equivalent deduplication

Timeout and failure are not identical

A timeout may mean:

caller stopped waiting

while the remote operation may still be running.

Cancellation is often a control-flow signal

If a user closes a screen:

Cancelled

may not deserve:

ERROR
production incident

Do not accidentally convert cancellation into generic failure

Preserve the runtime's intended cancellation semantics.

10. Design partial success and batch failures deliberately

Suppose you process:

1,000 imported contacts

and:

997 succeed
3 fail validation

A single exception may not express the useful outcome.

Batch result

ImportResult

successful:
997

failed:
[
  row 14: InvalidEmail,
  row 218: MissingName,
  row 907: InvalidPhone
]

Partial success can be first-class

Useful models include:

all succeeded

some succeeded

none succeeded

operation itself failed

Differentiate item failure from system failure

row 17 invalid

is different from:

database unavailable
before import began

Transactional operations may need all-or-nothing semantics

If:

partial update
would corrupt business state

use:

transaction
rollback
global failure

rather than partial success.

11. Preserve error ownership in async code

Async operations usually represent:

future success
or
future failure

Awaited exception model

try:
    result = await fetchData()
catch NetworkError:
    recover()

Awaited Result model

result = await fetchData()

match result:
    Success(data)
    NetworkUnavailable
    Unauthorized

Both can be coherent.

The same decision rule still applies

Ask whether:

NetworkUnavailable

is an expected caller-visible branch or an exceptional condition that should propagate.

Detached tasks need explicit error ownership

start background task

never await it

never observe result

risks:

lost exception
silent partial failure
unobserved rejection

Structured concurrency helps

Parent scopes can own:

child completion
child errors
child cancellation

Concurrent operations need aggregation semantics

If three tasks produce:

A = success
B = failure
C = success

decide:

fail whole operation?

return partial result?

cancel siblings?

collect every error?

rather than relying accidentally on whichever task finishes first.

12. Use a consistent error-handling workflow

Error classification and recovery workflow (diagram)

Error-handling recovery workflow showing failure detection, expected-versus-unexpected classification, typed Result return, exception propagation, domain translation, retryability checks, recovery boundary, user-safe response, logging, monitoring, cleanup, regression testing, and fatal-error escalation

Step 1: classify the failure

expected domain outcome?

invalid input?

transient infrastructure problem?

unexpected bug?

fatal condition?

Step 2: identify who can act on it

Ask:

Can the immediate caller
make a meaningful decision?

If yes:

typed Result
may be a strong choice

If no:

propagation
may be clearer

Step 3: preserve technical context

Keep:

original cause
operation
safe identifiers
dependency context

Step 4: translate at layer boundaries

SQL error
      ↓
repository error
      ↓
domain error
      ↓
API / UI response

Step 5: recover only when safe

Recovery options:

retry
fallback
ask user to correct input
skip failed batch item
rollback
terminate request

Step 6: log where ownership exists

Avoid:

log
rethrow
log
rethrow
log
rethrow

Step 7: test the failure path

Include:

expected failure

unexpected dependency failure

timeout

cancellation

retry exhaustion

cleanup

Step 8: improve the contract

If callers repeatedly ask:

what kind of error is this?

introduce:

typed variants
or
better exception hierarchy

13. Avoid common error-handling anti-patterns

Catch everything and continue

catch Exception:
    return null

converts:

unknown failure

into:

missing value

and destroys diagnostic information.

Result with string error

Result<User, String>

forces callers to interpret prose instead of types.

Exception for every normal branch

InvalidPromoCodeException

CouponExpiredException

CouponAlreadyUsedException

can make normal business decisions look like crashes.

Result wrapping every imaginable technical error

This:

Result<User,
  NetworkError |
  DatabaseError |
  SerializationError |
  CacheError |
  ConfigError>

is not automatically better if the caller can only:

return failure

Logging and rethrowing at every layer

creates duplicate incidents and noisy logs.

Destroying the original cause

Weak:

catch DatabaseError:
    throw ServiceError("failed")

Better:

ServiceError
caused by DatabaseError

Using exceptions for high-frequency control flow

Besides clarity concerns, some runtimes make exception creation or stack unwinding significantly more expensive than ordinary branching.

Returning null instead of an explicit outcome

A null may ambiguously mean:

not found

error

not loaded

not applicable

Explicit:

Option
Result
NotFound variant

can communicate intent more clearly.

Retrying all errors

Retrying:

InvalidPassword

five times does not improve the password.

Ignoring cleanup during failure

Every design should consider:

locks
transactions
files
connections
temporary state

when control exits unexpectedly.

14. Copy/paste error-handling checklist

Error handling checklist

Failure classification
- Is this expected?
- Is this a normal business outcome?
- Is this invalid user input?
- Is this transient infrastructure failure?
- Is this programmer error?
- Is this invariant violation?
- Is this fatal process condition?
- Is cancellation involved?
- Is timeout involved?

Choosing Result
- Use Result when failure is expected.
- Use Result when immediate caller should branch.
- Use Result for parse failures.
- Use Result for validation.
- Use Result for business-rule rejection.
- Use Result for expected not-found where appropriate.
- Use Result for conflict outcomes.
- Use typed error variants.
- Avoid Result<T, String> for important APIs.
- Keep error variants meaningful.

Choosing exceptions
- Use exceptions where language ecosystem expects them.
- Use exceptions for failures that must cross several layers.
- Use exceptions for unexpected technical failures where appropriate.
- Use exceptions when intermediate callers cannot recover.
- Preserve stack trace.
- Preserve original cause.
- Use cleanup constructs.
- Avoid throwing for ordinary high-frequency branching.

Language idioms
- Follow language conventions.
- Do not force Result style into a language where every library throws without a plan.
- Do not force exception style into an ecosystem centered on typed Results without reason.
- Keep public APIs consistent.
- Use adapters at boundaries where conventions differ.

Expected domain errors
- Model expected outcomes explicitly.
- Use domain vocabulary.
- Keep user-facing decisions separate from infrastructure errors.
- Do not log ordinary user mistakes as production incidents.
- Make callers handle important branches.

Typed errors
- Define error enum / sealed type / hierarchy.
- Use stable machine-readable variants.
- Keep human-readable message separate.
- Add safe metadata where useful.
- Avoid parsing messages.
- Avoid one enormous global error enum.
- Keep errors close to domain boundaries.

Error metadata
- Include operation.
- Include safe resource identifier.
- Include retryability when useful.
- Include cause.
- Include dependency.
- Avoid secrets.
- Avoid passwords.
- Avoid tokens.
- Avoid excessive personal data.

Validation
- Treat user validation as expected.
- Decide fail-fast vs accumulate.
- Return multiple field errors where useful.
- Separate parsing from business validation.
- Keep field identity.
- Provide correction guidance.
- Preserve valid submitted input.

Parsing
- Use Result-like outcome where malformed input is expected.
- Distinguish missing from malformed.
- Include safe location information.
- Avoid exceptions for routine parse probing if API already offers explicit parse result.
- Test edge cases.

Not found
- Decide whether absence is normal.
- Use Option / Maybe when absence alone matters.
- Use Result when absence needs reason or metadata.
- Use exception only when absence violates expected contract.
- Keep repository and domain semantics distinct.

Null
- Do not use null for every failure.
- Distinguish absent from failed.
- Distinguish not loaded from not found.
- Use explicit types where available.
- Avoid turning unknown exceptions into null.

Infrastructure
- Preserve technical failure.
- Translate at application boundary.
- Do not expose database implementation details externally.
- Keep retry information.
- Keep original cause.
- Add dependency context safely.

Database
- Distinguish constraint conflict.
- Distinguish timeout.
- Distinguish unavailable.
- Distinguish deadlock.
- Distinguish malformed query / programmer error.
- Translate expected constraint conflicts into domain outcomes.
- Preserve unknown database errors.

Network
- Distinguish DNS.
- Distinguish connect timeout.
- Distinguish read timeout.
- Distinguish TLS.
- Distinguish authentication.
- Distinguish rate limit.
- Distinguish server failure.
- Distinguish malformed response.
- Do not retry every network failure automatically.

Files
- Distinguish missing file.
- Distinguish permission denied.
- Distinguish invalid format.
- Distinguish disk full.
- Distinguish transient lock where relevant.
- Preserve path safely.
- Avoid exposing sensitive filesystem paths to users.

Layer boundaries
- Infrastructure error should not automatically leak to domain.
- Domain error should not depend on SQL vocabulary.
- UI should receive user-actionable outcome.
- API should receive stable external error contract.
- Preserve cause internally.
- Translate once at meaningful boundary.

Repository layer
- Translate storage-specific expected failures.
- Preserve unexpected infrastructure failures.
- Avoid swallowing database exceptions.
- Keep not-found semantics clear.
- Keep duplicate-key semantics clear.

Application layer
- Express business outcomes.
- Decide recovery.
- Apply retry policy.
- Coordinate transactions.
- Translate infrastructure error where useful.
- Keep domain vocabulary.

Controller / API layer
- Map domain error to response.
- Return safe message.
- Return stable error code.
- Log unexpected failure once.
- Do not expose stack trace.
- Add correlation ID.

UI layer
- Show actionable message.
- Offer retry when appropriate.
- Preserve user input.
- Distinguish validation from server failure.
- Do not show internal exception names.
- Handle cancellation quietly where appropriate.

Exception hierarchy
- Keep hierarchy meaningful.
- Avoid hundreds of tiny exception classes without value.
- Separate recoverable categories where caller needs distinction.
- Preserve cause.
- Avoid catching base Exception unless boundary requires it.
- Re-throw unknown errors when necessary.

Catch blocks
- Catch only when you can act.
- Recover.
- Translate.
- Retry.
- Roll back.
- Add required context.
- Terminate boundary safely.
- Do not catch only to ignore.

Catch-all handlers
- Use at application boundary where necessary.
- Log unexpected error.
- Return safe failure response.
- Keep process policy explicit.
- Do not silently continue corrupted operation.
- Preserve shutdown behavior.

Logging
- Log at ownership boundary.
- Avoid log-and-rethrow at every layer.
- Add correlation context.
- Add safe operation metadata.
- Use severity based on impact.
- Do not log expected validation as server error.
- Avoid duplicate stack traces.

Error messages
- Write for intended audience.
- Internal messages can contain technical context.
- External messages should be safe and actionable.
- Do not expose secrets.
- Do not expose stack traces to users.
- Keep machine-readable error code separate from prose.

Preserving cause
- Keep original exception.
- Use cause / inner exception feature.
- Preserve stack information.
- Avoid replacing technical failure with generic message.
- Add context without destroying cause.

Recovery
- Recover only when state remains valid.
- Prefer explicit fallback.
- Avoid inventing fake success.
- Roll back partial state when required.
- Preserve data integrity.
- Report inability to continue clearly.

Fallbacks
- Use fallback only when semantically valid.
- Cache fallback should indicate possible staleness where relevant.
- Avoid returning empty collection after unknown failure if empty means valid data.
- Monitor fallback usage.
- Test fallback failure.

Retries
- Retry transient failures only.
- Limit attempts.
- Use backoff.
- Add jitter where appropriate.
- Respect overall deadline.
- Respect cancellation.
- Check idempotency.
- Avoid retry storms.
- Record final failure.

Idempotency
- Make repeated writes safe where retries possible.
- Use operation ID.
- Use idempotency key for external writes where supported.
- Handle duplicate message delivery.
- Test ambiguous timeout.
- Avoid duplicate side effects.

Timeouts
- Define operation deadline.
- Distinguish timeout from dependency failure.
- Know whether underlying operation continues.
- Cancel where possible.
- Preserve timeout context.
- Avoid one global timeout for every operation.

Cancellation
- Treat cancellation as special control flow where runtime does.
- Propagate cancellation.
- Do not log normal user cancellation as error.
- Clean up resources.
- Avoid converting cancellation to generic failure.
- Test cancellation timing.

Resource cleanup
- Close files.
- Release connections.
- Release locks.
- Roll back transactions.
- Dispose subscriptions.
- Clean temporary resources.
- Use finally / defer / RAII.
- Test cleanup after exceptions.

Transactions
- Define commit boundary.
- Roll back on failure.
- Do not catch and continue inside corrupt transaction.
- Translate expected constraint conflict.
- Preserve unknown errors.
- Keep transaction scope small.

Batch operations
- Decide all-or-nothing vs partial success.
- Return per-item failures if useful.
- Preserve item identity.
- Separate item validation from system failure.
- Report summary counts.
- Avoid one bad item crashing entire import unless required.

Partial success
- Represent explicitly.
- List successful items.
- List failed items.
- Include typed reasons.
- Avoid calling partial operation simply "Success".
- Define retry behavior.

Async code
- Await owned operations.
- Observe failures.
- Avoid lost background exceptions.
- Define cancellation.
- Define timeout.
- Use structured concurrency where available.
- Define aggregate failure semantics.
- Preserve cause across async boundaries.

Concurrent tasks
- Decide fail-fast vs collect-all.
- Decide whether sibling tasks are cancelled.
- Decide whether partial result is valid.
- Bound concurrency.
- Preserve task identity.
- Aggregate errors intentionally.

Background tasks
- Assign owner.
- Handle failure.
- Log at supervisor.
- Define restart policy.
- Define shutdown.
- Avoid anonymous fire-and-forget critical work.

Assertions
- Use for programmer assumptions where appropriate.
- Do not use assertions for ordinary user input validation.
- Avoid recovering from violated invariants as ordinary business outcome.
- Keep production behavior understood.

Fatal errors
- Define which failures require process termination.
- Avoid attempting unsafe recovery.
- Flush critical telemetry where possible.
- Restart through supervisor when architecture supports it.
- Keep fatal policy rare and explicit.

Result composition
- Use map for success transformation.
- Use flatMap / bind for dependent Results.
- Propagate failures without repetitive branching.
- Avoid deeply nested matches where combinators improve clarity.
- Keep error types manageable.

Result anti-patterns
- Do not return Result when failure cannot be handled meaningfully by caller.
- Avoid huge unions of unrelated technical errors.
- Avoid string-only errors.
- Avoid wrapping every function just for ideological consistency.
- Avoid hiding programmer bugs inside Result variants.

Exception anti-patterns
- Do not catch everything.
- Do not swallow exceptions.
- Do not use exceptions as ordinary loop control.
- Do not destroy stack traces.
- Do not expose internal exceptions to users.
- Do not log same exception at every layer.

API design
- Document expected errors.
- Use stable error codes.
- Keep HTTP status and domain code separate where useful.
- Avoid exposing database errors.
- Keep validation structured.
- Include correlation identifier for unexpected server failures.

Library design
- Follow ecosystem conventions.
- Document thrown exceptions.
- Document Result variants.
- Keep API predictable.
- Avoid mixing styles randomly inside one module.
- Provide non-throwing variant where ecosystem supports it and use case warrants it.

Testing Results
- Test success.
- Test every important error variant.
- Test mapping.
- Test propagation.
- Test recovery.
- Test serialization of public error contracts.

Testing exceptions
- Assert exception type.
- Assert relevant message / metadata.
- Assert cause.
- Assert cleanup.
- Assert rollback.
- Avoid overly broad expected-exception tests.

Testing retries
- Fail first attempt.
- Succeed later.
- Exhaust attempts.
- Verify backoff policy where practical.
- Verify cancellation stops retries.
- Verify non-retryable errors do not retry.
- Verify side effects remain safe.

Testing validation
- Test one invalid field.
- Test multiple invalid fields.
- Test valid input.
- Test boundary values.
- Test malformed input.
- Verify user-actionable messages.

Observability
- Count unexpected failures.
- Count domain rejections separately.
- Track retry rate.
- Track timeout rate.
- Track cancellation separately.
- Track fallback rate.
- Track error code distribution.
- Alert on impact, not every expected failure.

Code review
- Is failure expected?
- Should caller branch on it?
- Is Result typed?
- Is exception appropriate?
- Where is recovery boundary?
- Is original cause preserved?
- Is error logged once?
- Is retry safe?
- Is cancellation preserved?
- Is cleanup guaranteed?
- Could this return fake success?
- Does external message leak internals?

Final review
- Does each failure category have clear meaning?
- Are expected domain failures explicit?
- Are unexpected failures visible?
- Do Result types expose typed variants?
- Are exceptions allowed to propagate until a meaningful boundary?
- Are low-level errors translated into domain vocabulary?
- Is original cause preserved?
- Is logging owned by one boundary?
- Are validation errors separate from infrastructure failures?
- Are retries restricted to transient failures?
- Are repeated writes idempotent where necessary?
- Are timeouts distinct from ordinary errors?
- Is cancellation preserved?
- Are resources released on every exit path?
- Are partial-success operations represented explicitly?
- Are async failures always observed?
- Are fatal and invariant failures kept separate from routine user errors?
- Can callers tell which failures they are expected to handle?

15. FAQ

When should I use a Result type?

Use a Result-style return value when failure is a normal part of the operation and the immediate caller is expected to inspect it and choose a branch. Parsing, validation, expected conflicts, business-rule rejection, and some not-found cases are common examples.

When should I use exceptions?

Exceptions are useful when normal execution cannot continue, when the immediate caller cannot recover meaningfully, or when a technical failure needs to travel through several layers before reaching an ownership boundary.

Should every exception be caught?

No. Catch where you can recover, translate the failure, retry safely, perform required cleanup, or terminate a request or application boundary safely. Otherwise, propagating the exception is often clearer.

Should Result errors be strings?

Usually not for important APIs. Typed variants let callers branch on stable machine-readable concepts such as NotFound, Conflict, ValidationError, PermissionDenied, or Unavailable without parsing text.

Can I use exceptions internally and Results externally?

Yes. This is a common pattern. Infrastructure or libraries can use exceptions, while an application boundary catches known technical failures, preserves their causes, and translates them into Result variants expected by higher-level callers.

Is a timeout an error?

It is a distinct outcome that should usually remain identifiable. A timeout means a deadline was exceeded; it does not always prove that the underlying operation failed or stopped.

Should validation throw exceptions?

Routine user validation is normally better represented as explicit errors because invalid input is expected. Exceptions can still be appropriate if the validation subsystem itself fails unexpectedly.

Key terms (quick glossary)

Exception
A runtime mechanism that transfers control from a failing operation through the call stack until an appropriate handler is reached.
Result type
A return type that explicitly represents either a successful value or a failure value.
Either
A sum type commonly used to represent one of two alternatives, often an error on one side and a successful value on the other.
Typed error
A structured error represented by a distinct type or variant rather than only a human-readable string.
Domain error
A failure or rejection expressed using concepts from the application's business domain, such as insufficient funds or inventory unavailable.
Infrastructure error
A technical failure originating from databases, networks, files, external services, operating systems, or similar dependencies.
Error propagation
Passing a failure toward a higher-level caller until code capable of handling or translating it is reached.
Recovery boundary
A layer that owns enough context to retry, fall back, translate, report, roll back, or safely terminate an operation.
Cause
The underlying failure retained inside a higher-level error or exception so diagnostic information is not lost during translation.
Fail-fast
Stopping an operation as soon as the first relevant failure is detected.
Error accumulation
Collecting multiple independent failures, commonly used for forms and validation where users benefit from seeing several corrections at once.
Idempotency
A property that allows the same operation to be repeated without producing unintended additional side effects.
Retryable error
A failure likely to succeed on a later attempt without changing the logical request, subject to safe retry policy.
Cancellation
A control signal indicating that work is no longer required and should stop where possible.
Partial success
An operation outcome where some requested items or sub-operations succeed while others fail.
Invariant
A condition the program assumes must always hold in a valid internal state.

Found this useful? Share this guide: