Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

31KB

FOCUS Architecture — Chapter-08: Errors Are Values

  • Source: /library/FOCUS Architecture/source-file.pdf
  • PDF pages: 158–187
  • Pages without text: none

Errors Are Values In this chapter, you’ll: model payment failures as the sealed type PaymentResult and write the exhaustive switch that consumes each case on screen; classify any new failure as an expected error or a programmer defect, with a single yes-or-no question; state and apply the FOCUS rule: exceptions belong only at the infrastructure boundary, translated once into a typed Failure . It’s Friday night, the customer at table 4 tries to pay $41.80, and the card gets declined for insufficient funds. Swapping cards would have fixed it; the screen says “Something went wrong” and she leaves without paying for the brownie. You’re going to find out the blame doesn’t sit with a badly written message: it sits with a failure that crossed the whole program without showing up in a single signature. By the end of this chapter, the failure will be a typed value, and the compiler will bill you for every case you forget to handle. Chapter 7 ended on an uncomfortable question: a pure function returns a value, always the same one for the same input, but what does it return when the calculation can fail? Throwing an exception is a side effect, and side effects are exactly what we tore out of calculateTotal . The answer fits in one sentence: the failure becomes a return value too. Said like that, it sounds like a syntax

trick; what follows is the demonstration that it’s the natural way to write the part of the code that makes the most money, the part that goes wrong. The payment that only said “Something went wrong” Before the technique, the pain. The tab payment was implemented like this, and code just like it is serving customers right now at thousands of registers out there: Dart class CardDeclinedException implements Exception { CardDeclinedException(this.reason); final String reason; } // The simulated processor: declines any card ending in 7. String chargeProcessor(int totalInCents, String cardNumber) { if (cardNumber.endsWith(“7”)) {

throw CardDeclinedException(“insufficient funds”); } return “COMP-4211”; } void showOnScreen(String message) => print("[screen] $message”); void showError(String message) => print("[screen] $message”); // The signature promises a receipt. Nothing in it mentions failure. String payTab(int totalInCents, String cardNumber) { return chargeProcessor(totalInCents, cardNumber); } void main() { // The customer at table 4 pays $41.80 with a card ending in 7.

try { final receipt = payTab(4180, “5090 1117”); showOnScreen(“Paid. Receipt $receipt."); } catch (e) { // Any failure lands here and becomes the same message. showError(“Something went wrong”); } } Two words in this code need a definition before the critique. An exception is an object that interrupts execution at the point of the throw and climbs the call stack until something catches it; try/catch is the construct that catches it: the try fences off the watched block, and the catch receives any exception that blows up inside it. The mechanism creates what this book calls invisible control flow: an execution path no signature declares. payTab promises to return a String , and the return type is the only promise the compiler reads; the throw three calls down leaves no trace in it. Now the scene. The customer at table 4 taps her card, the processor answers “insufficient funds,” and that valuable piece of information dies inside the catch . The screen shows “Something

went wrong.” She tries again, same message, and walks out thinking Rosie’s Coffee Shop app is broken. In her bag was a second card with plenty of room left. The sale wasn’t lost for lack of money; it was lost for lack of a type. This failure has three layers, and separating them matters because a different piece of the chapter fixes each one. First: the signature lies. String payTab(...) claims that paying always produces a receipt, and whoever reads that signature, human or compiler, has no way to know that a declined card, a dropped connection, and an insufficient loyalty balance all live behind that String . Second: the compiler doesn’t collect. Delete the entire try/catch and the code still compiles without a single warning; failure handling is optional, and under deadline, everything optional disappears. Third: the message doesn’t help someone who could have helped themselves. The catch (e) received an object carrying the exact reason for the decline and flattened it into the most useless sentence in the history of interfaces. Someone who just needed to swap cards got the same warning as someone who’d hit a bug. Expected error is not a defect Before fixing the payment, you need a criterion, because not every failure deserves the same fate. The distinction that governs the rest of the book separates expected error from programmer defect. An expected error is part of the business flow: a declined card, a dropped connection, a loyalty balance that doesn’t cover the redemption. Rosie knows these things happen every single night; the program should know too, and the place where a program keeps knowledge is its type system. A programmer defect is the violation of a premise that should always hold: an index past the end of a list, a null value where null was supposed to be impossible. No business rule at the coffee shop mentions

those cases, because they don’t belong to the business; they belong in the bug tracker, the system where the team logs and tracks defects. The criterion fits in one question: is this failure part of the business flow? If it is, it becomes a return value, with its own type and its own data. If it isn’t, it’s a bug, and a bug should stay an exception: the program crashes early, with a stack trace: the list of calls that led up to the offending line, aimed straight at the guilty spot. At the coffee shop, the ruler reads like this: Failure Classification Why Card declined by the processor value staff can offer another card Index out of range on the item list exception (bug) no flow produces this No connection to the processor value happens weekly and the screen reacts Null field where null was impossible exception (bug) violated premise The question you should be asking: why not catch the bug too and show a friendly screen? Because catching the bug hides the defect. An out-of-range index signals that some earlier calculation is wrong, and the only useful reaction is to crash right there, with the whole stack pointing at the guilty line, ideally in your test environment. A generic catch wrapped around the bug trades today’s stack trace for corrupted data next week. Crashing early is mercy; the friendly screen is what’s actually cruel.

The failure becomes part of the return type Criterion in hand, fixing the payment starts with the type. The idea has a family name: a Result (also called an Either in some languages) is a return type that carries either success or failure, one of the two, never both. Instead of returning String and throwing the rest out the back door, the function now returns a type that enumerates every possible outcome. Each language offers its own tool for enumerating outcomes, and two of them show up here; the chapter uses four languages in total, and chapter 18 consolidates the same recipe across all ten in the book. The first tool is a sealed class: a closed hierarchy where the declaration itself guarantees the list of subtypes is complete, and the compiler knows the whole list. It’s the opposite of ordinary inheritance, open to any subclass in any file. The same concept shows up in other languages as a union type: a type declared as the union of named alternatives. Sealed class and union type are two names for the same idea, a closed list of cases, and that closed list is what buys this chapter’s central property: exhaustiveness, the compiler-backed guarantee that a switch over the type handled every case. In Dart, PaymentResult looks like this: Dart sealed class PaymentResult {} final class PaymentApproved extends PaymentResult { PaymentApproved(this.receipt);

final String receipt; } final class CardDeclined extends PaymentResult { CardDeclined(this.reason); final String reason; } final class NoConnection extends PaymentResult {} // The signature now tells the truth: paying can approve, // decline the card, or lose the connection. Nothing else. PaymentResult payTab(Tab tab, String cardNumber) { // Simulated processor: declines cards ending in 7, drops the // connection on 9.

if (cardNumber.endsWith(“7”)) { return CardDeclined(“insufficient funds”); } if (cardNumber.endsWith(“9”)) { return NoConnection(); } return PaymentApproved(“COMP-4211”); } // The screen consumes every case with a distinct action. No default. String paymentScreen(PaymentResult result) { return switch (result) { PaymentApproved(:final receipt) => “Paid. Receipt $receipt.",

CardDeclined(:final reason) => “Card declined: $reason. Want to try another card?", NoConnection() => “No connection to the processor. Try again?", }; } The Tab coming in is the list of immutable Item values from chapter 7, prices in cents, wrapped in its own type: that’s the modeling chapter 7’s second pitfall asked for, and whatever version you built works here too. Read the type top to bottom: sealed class declares the closed hierarchy, and the three final class declarations are the complete list of outcomes. Notice that each case carries exactly the data the screen is going to need. PaymentApproved carries the receipt, CardDeclined carries the reason the processor gave, and NoConnection carries nothing, because the only useful response is offering another attempt. No case carries a generic error string; if a case has no useful data, it carries none. Compare the two signatures, because the whole chapter’s difference lives in them. Before: String payTab(...) , a false promise. Now: PaymentResult payTab(...) , the whole truth. Whoever calls the second version receives a value that’s useless until it’s opened in a switch , and Dart’s switch expression demands exhaustiveness: all three cases handled, no default , or a compile error. The payoff shows up the next day. Add a new result and every switch that consumes that type breaks right away, and you handle the new case because the compiler won’t let it through. The signature stopped lying, and the compiler became the inspector of failure handling. The first two pains from the anti-solution died in this

block. The third died with them: with the decline reason typed and in hand, the screen offers “Want to try another card?” instead of “Something went wrong.” In TypeScript, the closed list is written as a union ( | ), and exhaustiveness is bought with a three-line function: TypeScript type PaymentApproved = { type: “approved”; receipt: string }; type CardDeclined = { type: “cardDeclined”; reason: string }; type NoConnection = { type: “noConnection” }; type PaymentResult = PaymentApproved | CardDeclined | NoConnection; // If every case was handled, this default is unreachable and the // argument arrives with type never. Missed a case? tsc flags it now. function assertNever(value: never): never { throw new Error(Unhandled case: ${JSON.stringify(value)}); }

// The screen consumes each case; assertNever closes the door. function paymentScreen(result: PaymentResult): string { switch (result.type) { case “approved”: return Paid. Receipt ${result.receipt}.; case “cardDeclined”: return Card declined: ${result.reason}. Want another card?; case “noConnection”: return “No connection to the processor. Try again?"; default: return assertNever(result); } } The | in the PaymentResult declaration is the union type in its most literal form, and the fixed-value type field is what TypeScript calls a discriminated union: the compiler reads case “approved” and narrows result to the right type inside that branch.

The difference from Dart is in the billing. TypeScript’s switch accepts missing cases without complaint, because exhaustiveness here is opt-in: the feature only kicks in if you ask for it. The assertNever in the default is that request. The never type is the type with no possible values; if the three case branches cover every member of the union, what’s left for the default is nothing, and result arrives there typed as never , which matches the parameter. Drop a case, and what’s left over stops being nothing: result becomes NoConnection , the argument no longer fits never , and tsc flags the line. Three lines of function turn a loose switch into an exhaustive one. Try it: run https://focus.kodel.com.br/en/dart/08-01 or https://focus.kodel.com.br/en/ts/08-01 and delete the no- connection case from the switch (in Dart, the NoConnection() line; in TypeScript, the case “noConnection” and its return ). Predicted result: Dart answers Error: The type ‘PaymentResult’ is not exhaustively matched by the switch cases since it doesn't match ‘NoConnection()'. , and TypeScript answers error TS2345: Argument of type ‘NoConnection’ is not assignable to parameter of type ‘never’. The program doesn’t even get to run: the forgotten failure became a compile error. If your everyday language is C#, Java, PHP, or another of the ten, chapter 18 shows the equivalent of PaymentResult in each one, with the exhaustiveness caveats of every compiler. This chapter’s idea doesn’t depend on syntax: it depends on a closed list of cases the compiler knows about. Three steps, two rails

Paying a real tab isn’t one operation, it’s three. The customer wants to redeem 100 loyalty points as a discount, pay the rest by card, and earn points on the new purchase. Each step can fail for its own reason: the point balance might not cover the redemption, the card might get declined, the connection might drop halfway through. Scott Wlaschin named this way of seeing composition Railway-Oriented Programming, in a series of articles and talks on fsharpforfunandprofit.com between 2013 and 2014. The image: the flow is a railway with two parallel tracks. The train starts on the success track, and every step is a potential switch; fail, and the train switches to the failure track and rides it straight to the end, and every step still ahead gets skipped.

The diagram calls for a new case in the type. PaymentResult was born with three outcomes and now gains a fourth, InsufficientLoyaltyBalance , which carries the data the screen needs: how many points were missing. And here’s where exhaustiveness sends its first invoice in your favor: the instant that fourth final class lands in the file, the screen’s switch breaks the build with the same message from the “Try it” above, now

pointing at InsufficientLoyaltyBalance() . You don’t go hunting for the spots that need to handle the new case; the compiler hands you the list. The composed flow looks like this: Dart final class InsufficientLoyaltyBalance extends PaymentResult { InsufficientLoyaltyBalance(this.missingPoints); final int missingPoints; } // Paying the tab is three steps, and each one can switch // to the failure track. The switch is explicit: a return. PaymentResult payTab( Tab tab, Customer customer, String cardNumber, { required int pointsToRedeem,

}) { // Step 1: validate the loyalty point redemption. if (customer.loyaltyPoints < pointsToRedeem) { return InsufficientLoyaltyBalance( pointsToRedeem - customer.loyaltyPoints, ); } // Step 2: charge the card through the processor. final charge = chargeCard(cardNumber); if (charge is! PaymentApproved) { return charge; } // Step 3: credit the points this purchase earned.

final credit = creditPoints(customer); if (credit is! PaymentApproved) { return credit; } return charge; } Each step produces a PaymentResult , and the early return is the track switch: if the charge didn’t come back approved, that same value (decline or dropped connection) gets returned upward, and step 3 never runs. No pyramid of nested if , no stack of try ; the failure travels through the same channel as success, the return value. One caveat for page length: real payment is asynchronous, and nothing changes when these functions return a Future or a Promise of the same type; chapters 13 through 15 make that transition at a comfortable pace. Rust was born with this railway built in, and the leanest version of the same flow shows how much of the ceremony was just the language missing support for it: Rust enum PaymentFailure {

CardDeclined { reason: String }, NoConnection, InsufficientLoyaltyBalance { missing_points: u32 }, } struct Customer { loyalty_points: u32, } // Three steps, three ?. Each ? is the switch to the failure track. fn pay_tab( customer: &Customer, card_number: &str, points_to_redeem: u32, ) -> Result<String, PaymentFailure> { validate_redemption(customer, points_to_redeem)?;

let receipt = charge_card(card_number)?; credit_points(customer)?; Ok(receipt) } Result<String, PaymentFailure> is Result as a standard-library citizen: Ok carries success, Err carries failure, and the failure enum is the same closed list Dart wrote with sealed class . The novelty is the ? at the end of every step. It does exactly what the two if with return did in the Dart version: if the step returned Err , return that Err to the caller right away; if it returned Ok , unwrap the value and keep going. The track switch that’s explicit in Dart, three lines per step, is one character in Rust. On the consuming side, Rust’s match is exhaustive by obligation: a missing arm produces error[E0004]: non-exhaustive patterns , a direct cousin of the message Dart showed in the “Try it” above. Try it: run https://focus.kodel.com.br/en/rust/08-02 and remove the Err(PaymentFailure::NoConnection) arm from the final match . Prediction: error[E0004]: non-exhaustive patterns: Err(PaymentFailure::NoConnection) not covered , before a single line executes. Go’s counterpoint

One of the chapter’s four languages has treated errors as values since day one, no sealed class, no union type, no ? . Go returns errors the most literal way there is, a second return value, and has done that since 2009. Rob Pike made the case in “Errors are values” (the official Go blog, 2015), and the phrase became the name of the principle: errors-as-values is the decision to design a language, or a codebase, so that failure is ordinary data, handled like any other, instead of an invisible jump up the stack. The payment flow in Go: Go // Three steps, three if err != nil. The error is ordinary data, // visible in the flow, but the compiler doesn't know which errors exist. func payTab( customer Customer, cardNumber string, pointsToRedeem int, ) (string, error) { // Step 1: validate the loyalty point redemption. if err := validateRedemption(customer, pointsToRedeem); err != nil { return “", err

} // Step 2: charge the card through the processor. receipt, err := chargeCard(cardNumber) if err != nil { return “", err } // Step 3: credit the points this purchase earned. if err := creditPoints(customer); err != nil { return “", err } return receipt, nil }

Credit first, and it’s substantial. The (string, error) signature doesn’t lie: the caller gets the failure right in front of them, on the same = that receives the receipt, and the famous if err != nil is the track switch written out by hand. No invisible flow; an entire generation of Go programmers never watched an exception cross ten stack frames in silence, and the language’s panic stayed reserved for what this chapter calls a programmer defect. Go got the principle right before most mainstream languages took Result seriously. Now, the loss: error is an open interface, not a closed list. The compiler doesn’t know that chargeCard can return a decline or a dropped connection, doesn’t force anyone to tell the two apart, and quietly accepts _ in place of err . The error is visible, but exhaustiveness doesn’t exist: you get the failure track and lose the inspector who checks whether every switch got handled. Try it: run https://focus.kodel.com.br/en/go/08-03 and swap one of the error receivers in main for _ . Uncomfortable prediction: it compiles and runs without a single warning, and the ignored failure simply vanishes. It’s the “Something went wrong” from the opening section, now without even the message. Exceptions only at the boundary One question is still left over from the anti-solution: if an expected error becomes a value, who’s still allowed to throw and catch an exception? The FOCUS rule fits in one line: an infrastructure exception exists only at the infrastructure boundary, where it gets translated exactly once into a typed Failure ; from there inward, only Results circulate through the app. The infrastructure boundary is the layer that talks to the

world outside your process: network, database, disk, card processor. It’s the only territory where someone else’s exceptions are unavoidable, because the libraries living there are the ones throwing them. The Failure family is the closed list of domain failures that boundary produces by translating each low-level exception into the vocabulary of the business. The translation looks like this: Dart try { return TabFound(queryServer(table)); } on SocketException { return InfraFailure(Failure.noConnection); } Five lines of translation: the networking library’s SocketException dies right here, and what goes out to the rest of the app is Failure.noConnection , a domain value. This is the only legitimate try/catch in Rosie’s Coffee Shop app, and it lives in the repository; chapter 15 builds this boundary in detail, with the complete Failure family, and chapter 14 shows use cases returning a Result from there on up. A programmer defect stays outside this rule: a bug throws an exception, a bug’s exception doesn’t get caught, and crashing early with a stack trace remains the right answer.

Every pattern that solves a real problem opens the door to two new ways of overdoing it, and this chapter doesn’t end without laying out the criticism and the FOCUS answer. The first comes from Wlaschin himself, in the same 2013-14 material that named the rails: “don’t take it to extremes.” Not every failure in the universe should become a Result case; panics, bugs, and unrecoverable conditions stay out. FOCUS agrees by construction: the classification section exists exactly for that, and a programmer defect is still an exception. The second criticism goes by the nickname Result hell: wrapping and unwrapping Results layer after layer, each function converting the error from the layer below into the type of the layer above, until the useful code disappears under the bureaucracy. The FOCUS answer is this chapter’s rule: the translation happens exactly once, at the boundary. The repository converts SocketException into a Failure , and that same Failure travels from the use case to the screen without changing clothes on every floor. Anyone re-wrapping the error on every layer isn’t following the pattern; they’re paying twice for the same insurance. If your code still starts looking like a Result notary office, chapter 19 dissects that overreach as an anti-pattern, with its warning signs. There’s a reason this discipline has gotten more urgent. GitClear, in the 2026 edition of the code-quality survey it publishes at gitclear.com, analyzed hundreds of millions of changed lines and measured a 47% rise in error masking (the catch that swallows a failure and moves on as if nothing happened) in AI-generated code. The tool synthesizing half your code learned, from our own repositories, that failure hides behind an empty catch . Result is the structural antidote: there’s no silent catch where there’s no catch at all, and the exhaustive switch won’t compile with a swallowed case.

A first-person opinion to close out the section, because this subject leaves scars. Java tried failure exhaustiveness back in 1995 with checked exceptions, the ones a method must declare and the caller must handle: throws in the signature was mandatory and the compiler collected on it, the same promise this chapter makes. I wrote Java for years and watched that promise rot: since the failure was a jump, not a piece of data, the path of least resistance was an empty catch (Exception e) {} just to silence the compiler, and the whole mechanism fell into enough disrepute that Kotlin and C# dropped it on purpose. In my reading, Java’s mistake wasn’t demanding handling; it was demanding handling for a stack jump. Sealed class with an exhaustive switch gets right what throws got wrong, because the failure arrives as a value: you can stash it in a variable, return it, pass it along, and the switch arm that handles it returns something useful to the screen instead of existing only to quiet the inspector. Pitfalls The default that rebuilds “Something went wrong” with types. The first temptation for anyone coming from try/catch is to close the switch with a catch-all case: Dart // Pitfall: the default swallows the cases you haven't handled yet. String paymentScreen(PaymentResult result) { return switch (result) { PaymentApproved(:final receipt) => “Paid. $receipt.",

_ => “Something went wrong”, }; } It compiles, it runs, and it drags the chapter back to square one: the decline reason dies again before it reaches the customer. Worse, the _ kills exhaustiveness for good. When InsufficientLoyaltyBalance joins the type, the compiler won’t flag this switch, because the wildcard already “handles” the new case. The fix is having no wildcard: every case gets its own arm, and the day the type grows, the compiler hands you the list of screens to update. The Result thrown back into an exception halfway through. The second temptation is to receive the PaymentResult and throw: case CardDeclined: throw PaymentException(...) . That sends the failure back into invisible flow at exactly the layer where it had just become data, and some screen three frames up is going to need a try/catch to recover what was already typed and in its hand. The boundary rule runs both ways: an exception becomes a value on the way into the domain, and it doesn’t turn back into an exception while it’s still inside. Q&A So I never use try/catch again? You use it, in exactly one place: the infrastructure boundary, where the network, database, and disk libraries throw their own exceptions and your repository translates each one into a typed Failure ,

exactly once. Outside the boundary, a try/catch around business logic is a sign that an expected error got modeled as an exception. My language doesn’t have a sealed class or a union. Now what? The recipe survives: a base class with known subtypes and a switch covering all of them work in any language with polymorphism; what changes is how much exhaustiveness the compiler checks for you. Chapter 18 shows PaymentResult in all ten languages in the book, with each one’s degree of enforcement. Shouldn’t NoConnection be an exception? The network actually dropped. The SocketException exists, but it dies at the boundary. A dropped connection is expected by the business (the coffee shop sits in a basement with basement Wi-Fi), the screen has a useful response for it, and whatever is expected and has a response is a value. Exceptions stay reserved for what should never happen at all. Quick tip Let the linter collect on the exhaustiveness your team promises: in TypeScript, turn on typescript-eslint’s switch- exhaustiveness-check rule and assertNever becomes a welcome redundancy; in Dart, the exhaustive_cases lint extends the same enforcement to old-style enums. Zero cost, and the inspector starts working in the editor, before the compiler even runs. Tip 8 If the failure shows up in the signature, it gets handled; if it doesn’t, it gets forgotten.

Quick reference Situation at the coffee shop Value or exception? Destination Processor declined the card value CardDeclined(reason) case in the Result Connection dropped mid- payment value NoConnection case in the Result Point balance doesn’t cover the redemption value InsufficientLoyaltyBalance case Index past the end of the list exception crash early with a stack trace; it’s a bug Null where null was impossible exception crash early; violated premise is a bug SocketException in the repository exception becomes a Failure at the boundary Exercises

  1. Classify each failure below as a return value or an exception, with the justification following the chapter’s criterion (is it part of the business flow?). The label alone doesn’t count; the justification is the exercise.
  2. The processor declined the customer’s card.

items[5] on a tab that has 3 items.

  1. The app lost its connection while charging the card.
  2. The customer asked to redeem 200 points and has 120. Answer key: failure 1 is a value, because a declined card is register routine and the screen has a useful response (swap cards); it’s the CardDeclined(reason) case. Failure 2 is an exception: no rule at the coffee shop produces index 5 on a list of 3, so some earlier calculation is wrong, and the program should crash early pointing at the line. Failure 3 is a value: a dropped connection is expected, and a useful response exists (try again); the corresponding SocketException dies at the boundary, translated into the NoConnection case. Failure 4 is a value, and it’s the case the type gained in the rails section: InsufficientLoyaltyBalance(missingPoints: 80) , which carries the data the screen needs to suggest a smaller redemption. If you classified failure 2 as a value “so the app doesn’t crash,” reread the classification section: catching a bug doesn’t fix the bug, it only hides it.
  3. Rosie wants to split a tab between two customers. Could you model the SplitResult before writing a single line of logic? Enumerate the outcomes (think: the split closes the tab evenly, a rounding cent is left over, one of the two shares comes out to zero) and decide what data each case carries for the screen to act on. If your first draft has an Error(message: String) case, it’s still “Something went wrong” wearing a new badge. Next chapter: today’s payTab was born with the processor baked right into the function body, which is why the listing had to fake the decline with a magic card number; through which door does the real processor, the server that goes down, and every other failing dependency actually enter the function? Chapter 9 answers that question with dependency injection.

Powered by TurnKey Linux.