Loading learning content...
Few programming topics spark more debate than exception usage. Should a findById method throw when nothing is found, or return null? Should validation failures be exceptions or return values? The passion in these debates reflects a genuine design tension—there is no universally correct answer.
What we can do is establish principled guidelines that help us make consistent, defensible decisions. These guidelines balance theoretical purity with practical engineering concerns: performance, readability, maintainability, and the expectations of the codebase and team.
This page provides a comprehensive decision framework that you can apply to any exception-or-not dilemma. By the end, you'll be able to justify your choices with clear reasoning rather than gut feeling.
By the end of this page, you will be able to systematically decide when exceptions are appropriate, when to prefer return values, how to apply the 'exceptional' test, and how to balance theory with team conventions and language idioms.
The standard advice is: use exceptions for exceptional conditions. But this begs the question—what makes something exceptional? This vague guidance has led to countless debates and inconsistent codebases.
A Better Definition:
A condition is exceptional when it represents a deviation from the operation's specification that the immediate caller cannot reasonably anticipate and is not equipped to handle.
Let's unpack this definition:
Applying the Definition:
Let's apply this to the classic findById debate:
| Criterion | Analysis | Conclusion |
|---|---|---|
| Deviation from specification? | Depends on the contract! If contract is 'returns user if exists', then not found is within spec. | Depends |
| Cannot reasonably anticipate? | Callers often query for IDs that may not exist (user input, stale references). | ❌ Not exceptional |
| Not equipped to handle locally? | Caller typically knows what to do: show error, create user, try different ID. | ❌ Not exceptional |
Based on this analysis, findById returning null or Option<User> is more appropriate than throwing. But notice this changes if the specification changes:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
/** * Two valid specifications for finding a user by ID. * The choice of specification drives the error handling approach. */ interface UserRepository { /** * Specification 1: "Returns user if exists, null otherwise" * * Contract: ID may or may not exist. Both outcomes are normal. * Result: null is a valid return value, not an error. */ findById(id: string): Promise<User | null>; /** * Specification 2: "Returns the user with the given ID" * * Contract: ID must exist. Caller guarantees validity. * Precondition: ID exists in database. * If precondition is violated, that's an error (exception appropriate). */ getById(id: string): Promise<User>; // Throws if not found} // Usage patterns differ based on specification choice // With findById: Caller handles both outcomesasync function displayUser(id: string): Promise<void> { const user = await repository.findById(id); if (user === null) { // Expected case - handle normally showNotFoundMessage(); return; } displayUserProfile(user);} // With getById: Caller expects success, exception is truly exceptionalasync function processOrder(userId: string, order: Order): Promise<void> { // At this point, userId has been validated to exist // (e.g., extracted from authenticated session) // If user doesn't exist, something is seriously wrong const user = await repository.getById(userId); // May throw, and should await orderService.process(order, user);}The same outcome (entity not found) can be exceptional or expected depending on the operation's specification. Design your APIs to make the expected outcomes clear. Use naming conventions like findXxx (might not find) vs. getXxx (expects to get) to signal intent.
Here's a systematic framework for deciding between exceptions and return values. Work through these questions for each error condition you need to handle:
The Framework Questions Explained:
Some situations clearly call for exceptions. Recognizing these patterns helps you make quick, confident decisions.
| Situation | Why Exceptions | Example |
|---|---|---|
| Constructor failure | Object cannot exist in invalid state; no way to return error from constructor | ImageProcessor fails if no valid codec found |
| Programming errors (bugs) | Should propagate loudly to be caught in testing, not silently ignored | Null reference, index out of bounds, type mismatch |
| Precondition violations | Caller broke the contract; this is their bug, not expected behavior | Passing null when API requires non-null |
| Critical resource unavailable | Cannot proceed without this; need to escalate to high-level recovery | Database connection pool exhausted |
| Security violations | Should not continue; need to fail fast and log | Authentication token invalid, unauthorized access attempt |
| Configuration errors at startup | Application cannot function correctly; fail fast with clear message | Required environment variable missing |
| Unrecoverable state corruption | System integrity compromised; continuing would cause more damage | Data checksum mismatch, impossible enum value |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
/** * Examples of scenarios that clearly warrant exceptions */ // CASE 1: Constructor failure - object cannot be invalidclass EmailAddress { private readonly value: string; constructor(email: string) { if (!this.isValid(email)) { // Exception correct: EmailAddress object cannot exist with invalid email throw new InvalidEmailFormatError(email); } this.value = email.toLowerCase(); } private isValid(email: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }} // CASE 2: Precondition violation - caller's faultclass ShoppingCart { private items: CartItem[] = []; removeItem(itemId: string): void { // Precondition: itemId must exist in cart const index = this.items.findIndex(i => i.id === itemId); if (index === -1) { // Exception correct: caller violated precondition // This indicates a bug in calling code throw new IllegalOperationError( `Cannot remove item ${itemId}: not in cart` ); } this.items.splice(index, 1); }} // CASE 3: Critical resource unavailableclass OrderProcessor { async processOrder(order: Order): Promise<void> { const connection = await this.connectionPool.acquire(); if (!connection) { // Exception correct: cannot proceed, need high-level intervention throw new ResourceUnavailableError( 'Database connection pool exhausted', { currentPoolSize: this.connectionPool.size } ); } // Process order... }} // CASE 4: Configuration error at startupfunction loadConfiguration(): AppConfig { const apiKey = process.env.STRIPE_API_KEY; if (!apiKey) { // Exception correct: app literally cannot function without this // Fail fast, fail loud, fail at startup throw new ConfigurationError( 'STRIPE_API_KEY environment variable is required' ); } return { stripeApiKey: apiKey, /* ... */ };} // CASE 5: Security violationclass AuthenticatedAction { async execute(token: AuthToken, action: Action): Promise<void> { if (!token.isValid()) { // Exception correct: security violation, must not proceed throw new SecurityError('Invalid authentication token'); } if (!token.hasPermission(action.requiredPermission)) { // Exception correct: authorization failure throw new SecurityError( `Permission denied: ${action.requiredPermission} required` ); } await action.perform(); }}Notice the pattern: exceptions are appropriate when the caller made a mistake (precondition violation), when the system cannot possibly continue (critical failure), or when continuing would be dangerous (security). These are not regular outcomes—they're disruptions to normal operation.
Conversely, some situations clearly favor return values over exceptions. These patterns should trigger an automatic preference for non-exception error handling.
| Situation | Why Return Values | Example |
|---|---|---|
| Optional data lookup | Not finding something is a normal, expected outcome | User search by email, config lookup with default |
| Validation functions | Validation failure is expected; the point is to check | isValidEmail(), canUserPerformAction() |
| Try-pattern operations | Explicitly attempting something that might fail | TryParse(), tryConnect(), attemptLogin() |
| High-frequency operations | Exception overhead matters when called in loops | Parse each line of file, validate batch of records |
| User input handling | Invalid input is expected; users make mistakes | Form submission, API request body |
| Feature flags / optional features | Feature might not be available; that's normal | Check if premium feature enabled |
| Resource availability check | Checking if something exists before acting | File exists, connection available, lock acquirable |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
/** * Examples of scenarios that favor return values over exceptions */ // CASE 1: Optional data lookup - null/Option is appropriateinterface UserRepository { // Return null: not finding a user is normal, not exceptional findByEmail(email: string): Promise<User | null>; // DON'T: throw UserNotFoundError - email lookup is speculative} async function handleLogin(email: string, password: string): Promise<void> { const user = await repository.findByEmail(email); // Caller handles null gracefully - this is expected if (!user) { showError("No account found with this email"); return; } // Continue with login...} // CASE 2: Validation functions - Result type is appropriateinterface ValidationResult { isValid: boolean; errors: ValidationError[];} function validateOrder(order: OrderInput): ValidationResult { const errors: ValidationError[] = []; if (!order.items || order.items.length === 0) { errors.push({ field: 'items', message: 'At least one item required' }); } if (order.total < 0) { errors.push({ field: 'total', message: 'Total cannot be negative' }); } // DON'T throw: validation failure is the point of this function return { isValid: errors.length === 0, errors };} // CASE 3: Try-pattern - Boolean or Result returnfunction tryParseInt(value: string): number | null { const parsed = parseInt(value, 10); // Return null on failure - the 'try' prefix signals this if (isNaN(parsed)) { return null; } return parsed;} // Usage in loop (high-frequency) - no exception overheadfunction parseNumbers(values: string[]): number[] { const result: number[] = []; for (const val of values) { const num = tryParseInt(val); if (num !== null) { result.push(num); } // Invalid entries silently skipped - this is often desirable } return result;} // CASE 4: User input handling - Result type with errorstype ParseResult<T> = | { success: true; value: T } | { success: false; error: string }; function parseDate(input: string): ParseResult<Date> { const date = new Date(input); if (isNaN(date.getTime())) { // Return error result - user typos are expected return { success: false, error: 'Invalid date format. Please use YYYY-MM-DD.' }; } return { success: true, value: date };} // CASE 5: Feature availability checkclass FeatureService { // Returns null if feature not available - that's normal getFeature(name: string): Feature | null { const feature = this.features.get(name); if (!feature || !feature.isEnabled) { return null; // Normal outcome: feature might not exist } return feature; }} // Usage: check before useconst advancedSearch = featureService.getFeature('advanced-search');if (advancedSearch) { showAdvancedSearchUI();} else { showBasicSearchUI();}Return values are appropriate when the caller expects both outcomes, when the operation's purpose is to check for something (validation, existence, parsing), or when failure is so common that exception overhead matters. The caller should not be surprised by the failure case.
Many real-world scenarios don't fall neatly into the clear-cut categories. Let's examine some genuinely ambiguous cases and how to reason through them.
Gray Area #1: External Service Failures
When an HTTP call to a payment provider fails, is that exceptional? Arguments for both sides:
Resolution: The answer often depends on the layer of your system:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
/** * Different layers handle the same underlying failure differently */ // Low-level HTTP client: Returns Result, failures are expectedinterface HttpResponse<T> { ok: boolean; status: number; data?: T; error?: string;} class HttpClient { async post<T>(url: string, body: unknown): Promise<HttpResponse<T>> { try { const response = await fetch(url, { method: 'POST', body: JSON.stringify(body) }); if (!response.ok) { return { ok: false, status: response.status, error: await response.text() }; } return { ok: true, status: response.status, data: await response.json() }; } catch (networkError) { return { ok: false, status: 0, error: 'Network error: ' + networkError.message }; } }} // Mid-level API client: Converts to exceptions for critical failuresclass PaymentGatewayClient { constructor(private http: HttpClient) {} async chargeCard(payment: PaymentRequest): Promise<PaymentResult> { const response = await this.http.post<PaymentResponse>( '/charges', payment ); // Transient failures: return for retry logic if (!response.ok && this.isTransient(response.status)) { return { success: false, retryable: true, error: response.error }; } // Permanent failures: throw - this is genuinely exceptional if (!response.ok) { throw new PaymentGatewayError( `Payment gateway error: ${response.error}`, response.status ); } return { success: true, chargeId: response.data.id }; } private isTransient(status: number): boolean { return status === 429 || status >= 500; }} // High-level service: Exceptions are appropriateclass PaymentService { constructor(private gateway: PaymentGatewayClient) {} async processPayment(order: Order, card: Card): Promise<void> { const result = await this.gateway.chargeCard({ amount: order.total, currency: order.currency, card: card.token }); if (!result.success) { if (result.retryable) { // Retry logic here, or: throw new PaymentProcessingError( 'Payment temporarily unavailable', { retryable: true } ); } } // Success: record the charge order.chargeId = result.chargeId; }}Gray Area #2: Database Constraint Violations
An insert fails because of a unique constraint. Exception or return value?
| Context | Approach | Reasoning |
|---|---|---|
| User registration (email must be unique) | Return value (Result type) | Duplicate email is expected - users try existing accounts |
| Internal data migration | Exception | Duplicates indicate corrupted source data - unexpected |
| Idempotent operation retry | Return value (already exists = success) | Duplicate is expected and means 'already done' |
| Concurrent modification race | Exception or retry | Indicates contention that needs handling |
The same underlying database error can warrant different handling based on what operation is being performed. Design your APIs with the caller's context in mind. A registration service and a data migration script have different expectations about duplicate records.
Beyond the theoretical framework, practical engineering requires considering the norms of your language ecosystem and team. Deviating from conventions creates friction and bugs.
| Language | General Convention | Notes |
|---|---|---|
| Java | Liberal use of checked exceptions | Compiler enforces handling; Result types less common |
| C# | Moderate exception use | Similar to Java but no checked exceptions |
| Python | Exceptions for many failures | 'EAFP' - Easier to Ask Forgiveness than Permission |
| Go | Explicit error return values | Multiple returns; errors are values, not exceptions |
| Rust | Result types, no exceptions | Result<T, E> for recoverable; panic for unrecoverable |
| TypeScript/JS | Mixed - depends on framework | Promises reject (exception-like) but often wrapped in Result |
| Kotlin | Unchecked exceptions + Result/sealed classes | Combines Java interop with modern patterns |
| Swift | Throws + Optional + Result | Clear distinction between nil, throws, and Result |
12345678910111213141516171819202122232425262728293031323334353637383940
/** * TypeScript: Community increasingly favors Result types * but many libraries still throw (Prisma, Axios by default, etc.) */ // Option 1: Follow library conventions (Prisma throws)async function getUser(id: string): Promise<User> { try { return await prisma.user.findUniqueOrThrow({ where: { id } }); } catch (error) { if (error instanceof Prisma.NotFoundError) { throw new UserNotFoundError(id); } throw error; }} // Option 2: Use Result pattern (team convention)type Result<T, E = Error> = | { ok: true; value: T } | { ok: false; error: E }; async function getUserResult(id: string): Promise<Result<User, 'NOT_FOUND' | 'DB_ERROR'>> { try { const user = await prisma.user.findUnique({ where: { id } }); if (!user) { return { ok: false, error: 'NOT_FOUND' }; } return { ok: true, value: user }; } catch (error) { return { ok: false, error: 'DB_ERROR' }; }} // Key: Be CONSISTENT within your codebase// If your team uses Result, use Result everywhere// If your team uses exceptions, use exceptions everywhereIt's better to consistently follow your team's (potentially suboptimal) convention than to introduce a 'correct' but inconsistent pattern. Consistency enables developer productivity. If you want to improve your team's practices, do it through discussion and gradual migration, not by sprinkling different patterns throughout the codebase.
We've developed a comprehensive framework for deciding when to use exceptions versus alternative error handling mechanisms. Let's consolidate the key guidelines.
Quick Reference Checklist:
What's Next:
With a clear framework for when to use exceptions, we'll now explore the broader philosophy of error handling. The next page examines different error handling philosophies (fail-fast, defensive, etc.) and how they shape overall system design.
You now have a systematic framework for deciding between exceptions and return values. This decision-making ability, combined with understanding the underlying concepts, positions you to design error handling that is both correct and pragmatic.