|
| 1 | +--- |
| 2 | +name: type-safe-error-handling |
| 3 | +description: Replace string-matched error classification with typed domain errors, value objects, and explicit boundary mappings. Use when code derives behavior from error messages, substrings, magic prefixes, loosely typed status strings, denial reasons, protocol categories, validation messages, or other stringly typed logic that should be represented at the data type level. |
| 4 | +allowed-tools: |
| 5 | + - Bash |
| 6 | + - Read |
| 7 | + - Edit |
| 8 | + - Grep |
| 9 | +--- |
| 10 | + |
| 11 | +# Type-Safe Error Handling |
| 12 | + |
| 13 | +Use this skill when code lets human-readable text carry domain meaning. The |
| 14 | +agent should learn the shape of the domain, then make the semantic decision live |
| 15 | +in a type instead of in wording. |
| 16 | + |
| 17 | +## Mental Model |
| 18 | + |
| 19 | +A message can describe a fact, but once behavior depends on that message the |
| 20 | +message has become an accidental API. If changing a sentence can change an auth |
| 21 | +decision, retry policy, denial category, telemetry status, or protocol response, |
| 22 | +the code is missing a type. |
| 23 | + |
| 24 | +Think in terms of information flow: |
| 25 | + |
| 26 | +- Where is the domain fact first known? |
| 27 | +- Where is it collapsed into a `String`, status label, magic prefix, or loosely |
| 28 | + typed field? |
| 29 | +- Which later decision is trying to reconstruct that fact? |
| 30 | +- Which existing enum, value object, source error, or wire type should carry the |
| 31 | + fact instead? |
| 32 | + |
| 33 | +## Principles |
| 34 | + |
| 35 | +- Name the semantic fact at the source. `UnknownAccount`, `InvalidCredentials`, |
| 36 | + `MissingCredentialMaterial`, or `VerifierUnavailable` are better decision |
| 37 | + inputs than messages containing those words. |
| 38 | +- Preserve meaning across layers. A boundary conversion should be |
| 39 | + variant-to-variant or field-to-field, not format-and-parse. |
| 40 | +- Keep display text at presentation edges. Logs, diagnostics, and user-facing |
| 41 | + messages may stay stable, but they should explain typed decisions rather than |
| 42 | + drive them. |
| 43 | +- Preserve source chains when they explain cause. Do not choose between correct |
| 44 | + classification and observability if the error model can represent both. |
| 45 | +- Let wire compatibility and domain modeling coexist. Keep external strings, |
| 46 | + codes, or protobuf fields stable when required, but convert to richer domain |
| 47 | + types inside the owning package. |
| 48 | +- Respect local architecture. In Rust, keep value objects inside the owning |
| 49 | + crate unless ADR 0002 justifies a package boundary. For wire and persistence |
| 50 | + contracts, follow ADR 0009. |
| 51 | + |
| 52 | +## Key Examples |
| 53 | + |
| 54 | +Typed boundary mapping: |
| 55 | + |
| 56 | +```rust |
| 57 | +impl From<ApiKeyError> for AuthCalloutError { |
| 58 | + fn from(error: ApiKeyError) -> Self { |
| 59 | + match error { |
| 60 | + ApiKeyError::Empty => CredentialError::InvalidRequest(...).into(), |
| 61 | + ApiKeyError::Unknown => CredentialError::InvalidCredentials(...).into(), |
| 62 | + ApiKeyError::AudienceMismatch { .. } => CredentialError::InvalidRequest(...).into(), |
| 63 | + ApiKeyError::CallerIdDerivation(_) => CredentialError::InvalidCredentials(...).into(), |
| 64 | + } |
| 65 | + } |
| 66 | +} |
| 67 | +``` |
| 68 | + |
| 69 | +String-derived behavior: |
| 70 | + |
| 71 | +```rust |
| 72 | +let category = if error.to_string().contains("not found") { |
| 73 | + DenialCategory::InvalidCredentials |
| 74 | +} else { |
| 75 | + DenialCategory::InternalError |
| 76 | +}; |
| 77 | +``` |
| 78 | + |
| 79 | +The first shape carries meaning through variants. The second makes wording part |
| 80 | +of control flow. |
| 81 | + |
| 82 | +Local examples to learn from: |
| 83 | + |
| 84 | +- `rsworkspace/crates/a2a-auth-callout/src/denial_category.rs` classifies |
| 85 | + `CredentialVerification(String)` with `msg.contains(...)`. The category |
| 86 | + decision belongs in typed credential or domain errors that map directly to |
| 87 | + `DenialCategory`. |
| 88 | +- `rsworkspace/crates/a2a-auth-callout/src/account_resolver.rs` converts |
| 89 | + `AccountResolverError` through `value.to_string()`. The resolver already knows |
| 90 | + whether the problem is `EmptyRequest` or `Unknown`; that distinction should |
| 91 | + survive until category mapping. |
| 92 | +- `ApiKeyError`-style mappings should stay variant-to-variant. Empty input, |
| 93 | + unknown key, caller-id derivation failure, and audience mismatch are different |
| 94 | + facts even if their display messages all end up near credential verification. |
| 95 | + |
| 96 | +## Smells |
| 97 | + |
| 98 | +Pause and reason from the principles when you see: |
| 99 | + |
| 100 | +- `contains`, `starts_with`, regexes, or equality checks against error messages. |
| 101 | +- `map_err(|e| SomeError(e.to_string()))` before another layer classifies the |
| 102 | + result. |
| 103 | +- catch-all `String` variants used for multiple semantic cases. |
| 104 | +- tests proving semantic behavior through substrings. |
| 105 | +- protocol categories inferred from local wording instead of typed tags. |
| 106 | + |
| 107 | +## Verification |
| 108 | + |
| 109 | +Confidence comes from tests that prove the type-level contract: |
| 110 | + |
| 111 | +- each source variant maps to the intended typed category. |
| 112 | +- wire-visible strings or codes remain compatible when compatibility matters. |
| 113 | +- wrapped sources remain available through `source()` where supported. |
| 114 | +- unknown or internal errors do not accidentally become user-correctable errors. |
| 115 | + |
| 116 | +String assertions are allowed for stable display text, but not as the only proof |
| 117 | +of semantic behavior. |
| 118 | + |
| 119 | +## Done |
| 120 | + |
| 121 | +The task is done when a future wording change cannot change behavior, and a |
| 122 | +reviewer can point to the type that owns each semantic decision. |
0 commit comments