Two steps of a transfer chain describe their failures with different types — what must happen before the two compose?
answer
- one chain, one failure description
- two vocabularies cannot join directly
- map each failure before the join
- widen the type or re-describe it
- narrow costs implementers, wide costs callers
basics
~20 sA chain yields one value, so its failure side has one shape: each step's failure must first be mapped into a shared description. That is either a wider type with a variant per source, or a re-description in the calling layer's own vocabulary.
solid answer
~40 sA chained expression produces a single value, so there is exactly one failure shape it can carry. A parse failure and a directory-lookup failure described by different types therefore cannot simply be joined. One of two things must happen. Either both are mapped into a wider failure type with a variant for each, which keeps the detail and grows what callers must match, or each is re-described in the vocabulary of the layer doing the chaining, which keeps the caller's work small and discards detail unless the original is carried as a cause. The choice is really about where the boundary sits: narrow failure types are kind to callers, wide shared ones are kind to implementers.
code
pseudocode · 6 lines# parseAmount and lookupAccount describe failures differently
step1 = parseAmount(raw).mapFailure(e -> Rejected(field: "amount", cause: e))
step2 = lookupAccount(id).mapFailure(e -> Rejected(field: "destination", cause: e))
# with one failure shape on both, the chain composes
step1.then(amount -> step2.then(account -> Success(transfer(amount, account))))go deeper
Remember that the chained result carries one failure shape, so differently described failures have to be converted into a common one first.
Explain the two routes: widen into a type with a variant per source, or map into the calling layer's own vocabulary, and say what each does to the caller's match.
Show where you put the boundary and why, and mention carrying the original as a cause so re-describing does not cost you the diagnosis.
Decide the house rule: which failure vocabulary a module publishes, and how you stop a shared type from becoming the union of everything the system can fail at.
## Why a chain has room for only one description Chaining two steps produces one value, and that value has one success shape and one failure shape. If the first step's failure is described by a parse-specific type and the second step's by a lookup-specific type, there is no single shape the chained result could have — the two are not the same type and nothing in the chaining rule invents a union for you. So the conversion has to happen before the join, on the failure side of each step. Note which side is being converted. The success values are free to differ, because each step consumes the previous one's success value and produces a new one; that is what chaining is for. It is the **failure** side that has to be uniform, because any step may be the one that produces it. ``` # each step describes its failure in its own terms step1 = parseAmount(raw).mapFailure(e -> Rejected(field: "amount", cause: e)) step2 = lookupAccount(id).mapFailure(e -> Rejected(field: "destination", cause: e)) # both failures now have one shape, so the chain has a single failure type step1.then(amount -> step2.then(account -> Success(transfer(amount, account)))) ``` ## The two ways to unify 1. **Widen into a shared type.** Define one failure type with a variant for each source — a parse variant, a lookup variant, a policy variant — and map every step into it. Nothing is lost, and any caller can tell precisely what happened. 2. **Re-describe into the layer's own vocabulary.** The layer that owns the chain publishes a small failure type of its own and maps everything into it. Callers match three or four shapes instead of twenty. 3. **Carry the original as a cause.** This is not a third choice so much as a repair for the second: the new failure keeps a reference to the description it replaced, so diagnostics retain the detail even though callers match only the layer's own shapes. ## What each choice costs | | one wide shared type | a narrow type per layer | |---|---|---| | work for the implementer | little, everything already fits | a mapping at each boundary | | work for the caller | matches many shapes, most irrelevant | matches only what it can act on | | detail retained | all of it | only what the mapping keeps | | effect of a new variant | reaches every caller of the shared type | stops at the boundary that maps it | | where it drifts | grows until it means everything | grows a mapping layer of its own | The failure modes are opposite and both are common. A shared type drifts toward being the union of everything the system can do, at which point a top-level handler is matching on parse minutiae. Per-layer types drift toward a wall of mappings that mostly rename things. The useful discipline is to ask, at each boundary, which shapes the layer above genuinely branches on — and to map into exactly those. ## Where the boundary usually falls - Between a technical concern and a domain concern: parsing, decoding and lookup failures become domain rejections at the first layer that speaks the domain. - At a module's published surface, where the failure type is part of the contract. - At the outermost boundary, where whatever remains becomes a response, a retry or a log line. ## A common wrong turn: one message string The quickest way to make two failure descriptions join is to reduce both to text, and it does compose. What it removes is every caller's ability to branch: a layer that wanted to retry on a lookup failure and reject a parse failure now sees one shape and a string, so the decision moves into matching on wording, which nothing checks and any rewording breaks. Text is the right final form at the outermost boundary, where the next reader is a person. It is the wrong intermediate form anywhere a caller above still has a choice to make, which is why the shared type and the per-layer type are the only two real options. ## What interviewers listen for - That you identify the failure side, not the success side, as the one that must be uniform. - That you name both directions of the trade-off rather than declaring one of them correct. - That you mention keeping the original description as a cause, because losing it is the usual regret. - That you can say what would make you narrow a shared type: a caller matching shapes it cannot act on.
- When has a shared failure type outgrown its boundary?When a caller matches variants belonging to layers it never calls. If the outermost handler is branching on a parse-level description, the type has spread too far and should be re-described one layer down into shapes that handler can act on.
- How do you re-describe a failure without losing the original detail?Carry it as a cause on the new failure. Callers match the layer's own small vocabulary while diagnostics keep the underlying description, so nothing is discarded and the set of shapes callers must handle stays small.
saying these in an interview costs you the question
- Assumes two differently described failures chain with no conversion
- Widens the shared failure type for every new step, forever
- Drops the original description when re-describing at a boundary
- Thinks the success side's types decide whether the chain composes
- Says making every failure a plain message string solves it for free