Migrating Custom Error Handling to the Exceptions Framework
Introduction
This tutorial is a practical companion of the Exception Handling tutorial, with a more in-depth coverage of specific error-handling patterns in larger applications, and in particular the migration of existing code bases to the Exceptions framework.
It is intended for authors of Wolfram Language packages that already do structured error handling — typically with a small home-grown mini-framework — and who are considering switching to the Exceptions framework. It can also serve as a source of additional information and examples for those who are starting a new project or have existing projects with only rudimentary error handling but would like to make improvements in this department.
Approaches taken by common solutions that emerged over the years vary along several largely independent design axes. This tutorial takes the axis-decomposition view from the start. A package’s existing error-handling design is described as a position vector along several largely independent architectural axes (Section 3).
Migration is then a matter of:
- Identify the exception type hierarchy your package will use going forward (Section 2);
- Identify your position vector (Section 3);
- Pick the corresponding migration recipe (Section 6);
- Optionally use cross-cutting helpers that are common for the majority of approaches (Section 5);
- Make other and more specific design choices, such as whether your package will use modular selective catching (Section 4), etc.
This tutorial assumes basic familiarity with the Exceptions framework primitives, covered in the Exception Handling tutorial.
How to Read This Tutorial
This tutorial can be roughly split into two parts: conceptual and practical.
The conceptual part discusses the design space and can help you to both position your current project in that space and figure out the best migration strategy specifically for you. It contains the following sections:
The practical part contains specific recipes that can be used in the migration process. In practice, it is less likely that some particular recipe will cover 100% of your needs, and also you may need to use some combination of recipes for your specific situation. Generally, they are intended to serve as blueprints and sources of ideas, rather than as direct copy and paste material. This part consists of the following sections:
It is recommended that you at least skim through the conceptual part of this tutorial before moving to the practical part, since that would help you navigate the design space and find the best approach for your particular use case. However, the practical part should be sufficiently self-contained for reading on its own, if you are very code oriented. It also contains back-references to the relevant sections of the conceptual part, along with the code and examples.
For more pragmatic readers, start with Section 3 to classify your package along the seven design axes, then go directly to the matching recipe in Section 6. Read Section 2 when designing your exception type hierarchy and Section 4 when your package has multiple independent subsystems that need to selectively catch each other's errors, and for a broader architectural discussion.
Some of the code cells in the sections below are marked as "Pseudocode" by a comment inside the cell. Those cells are not evaluatable, but the code inside usually is valid Wolfram Language code that can, in principle, be evaluated. In some cases, it may contain generic or missing parts. The role of this code is to illustrate the concept. The code cells without "Pseudocode" comments always represent evaluatable code; they can and should be evaluated.
Organization of This Tutorial
This tutorial guides you through migrating an existing Wolfram Language package to the Exceptions framework. It is structured so that you can start from the section most relevant to your package's current error-handling design without reading everything sequentially, though sequential reading is also supported.
- 1. Introduction: motivation and overview. Includes a reading guide, setup instructions and an optional bootstrap walk-through for packages that currently use pure return-value error propagation with no throws.
- 2. Designing the Exception Type Hierarchy: how to decide on the shape (flat, shallow or deep) and the leaf representation of your exception type tree. Subsections:
- Three Baseline Shapes: flat, shallow and deep tree designs, with tradeoffs and guidance on when each is appropriate.
- Leaf Identity: Variant A (a dedicated symbol per leaf type, the recommended default) vs. Variant B (a parametric leaf with an open argument).
- 3. The Classification Axes: a seven-axis framework for characterizing the error-handling design of an existing package. Use this section to locate your package's current position before selecting a recipe. Subsections:
- AX1 — Layering: single-layer (throw sites own the user-facing message) vs. two-layer (internal tags translated at the public boundary).
- AX2 — Error Identity, Payload Shape and Selective Catching: how errors are distinguished; what data is carried in the payload; and whether catching is non-selective, type based or payload pattern based.
- AX3 — Propagation Depth: zero-depth (pure return-value chains), direct (one hop from throw to the boundary) and nested (inner catches for local recovery).
- AX4 — Head Binding: when the public-function head is attached: explicit (at throw time), late/automatic (at catch time) or early (inherited from the surrounding scope).
- AX5 — Debug-Mode Integration: debug strategies from none/ad hoc to project-wide.
- AX6 — Public Error-Return Convention: whether the public boundary returns unevaluated, $Failed + Message or a Failure[] object.
- AX7 — Evolution Stance: drop-in replacement, incremental adoption or architectural migration.
- Relationships between Axes: which axis choices constrain or imply others, and how mixed stances are handled.
- 4. Modular Architectures and Selective Catching: the package-root/module-root/directError subtree pattern for packages with multiple subsystems. Gives each subsystem its own inner catch for local recovery, while unhandled errors propagate to the package boundary.
- 5. Helpers Shared by Every Recipe: three cross-cutting utilities: CC.1 promotes a returned Failure[] into the exception channel; CC.2 propagates an existing message as an exception; CC.3 auto-installs a fallthrough rule that turns unmatched calls into typed exceptions.
- 6. Migration Recipes: concrete, runnable migration procedures keyed to axis position. The five recipes:
- Recipe O1 — Bootstrap from Return-Value Propagation: for packages with no existing throws (AX3 = zero-depth). Identifies natural throw points in each return-value chain and converts them one at a time. Full step-by-step worked example included.
- Recipe O2 — Single-Layer with Late-Bound Message Dispatch: for AX1 = single-layer, AX2 = non-selective. A compact throwError/catchError helper pair where the public-function head is bound at the catch site.
- Recipe O3 — Single-Layer with Selective Catching by Payload-Test: for AX1 = single-layer, AX2 = selective by payload pattern. A transitional customCatchTyped[] wrapper preserves existing semantics while call sites migrate incrementally.
- Recipe O4 — Two-Layer with Internal-Tag Plus Boundary Translation: for AX1 = two-layer. A custom registry maps internal string tags to Message + Failure pairs. The exception handler consults the registry at the public boundary, to emit proper messages and return public Failure objects.
- Recipe O5 — Debug-Mode Wiring: orthogonal add-on to any recipe.
- 7. Implementation Notes for Custom Solutions: HoldFirst/HoldAll requirements, held payloads, operator forms and MessageName evaluation process. Applies to all recipes.
- 8. Performance Considerations: When exception overhead matters, and how to isolate hot loops from the framework using a mixed Catch/ThrowException pattern.
- When the Overhead Matters: Quantifying the overhead and identifying when it exceeds the call cost.
- Mixed Pattern: ad hoc Catch in Hot Paths, Framework Boundary Outside: Place a bare Catch inside the hot loop and a ThrowException at the boundary; the inner Catch absorbs the throw without framework overhead.
- 9. Summary: What the migration buys across all recipes, a practical caution about public-symbol collisions and the architectural upgrade paths beyond the drop-in.
- 10. Looking Ahead: Two in-development framework directions: type properties and an error-translation layer that replaces hand-built registries.
Setup
Almost every snippet in this tutorial throws or catches against the same registered exception type root, so the registration lives here once and is not repeated:
RegisterExceptionType[pkgException]
RegisterExceptionType[pkgInputError, pkgException]
RegisterExceptionType[pkgInternalError, pkgException]Sections that need additional registered exception types introduce them locally.
Bootstrap from Return-Value Propagation
Most of this tutorial assumes that your project already uses some form of non-local control flow — typically Catch/Throw or their derivatives. If instead it relies on the traditional return-value analysis and explicit error propagation, you will need to first convert the explicit error-propagation chains, where you plan to start using exceptions, into exception-mediated propagation.
This is generally not a mechanical wrap-every-helper-in-ThrowException rewrite, because errors returned back to callers from the callees may either be locally handled and not propagate to the public functions or may get transformed in various ways during their “manual” propagation up the call stack. The decision is per call chain: at which layer does the error first acquire a stable shape that all enclosing callers can understand? That layer is the natural throw point.
One thing to keep in mind is that any internal function should ideally have a single error mode: either it returns some form or failure or it throws an exception, but not both. This is one of the facets that can make a bootstrap migration less trivial than it looks, since "regressions” of this kind can be common during the migration — a layer that used to consistently return now throws and returns from different code paths.
Section 3 (AX3 — Propagation depth) discusses this in detail under Zero-depth propagation; Recipe O1 in Section 6 walks through a concrete bootstrap migration end-to-end.
2. Designing the Exception Type Hierarchy
Exception type hierarchy design is the single highest-leverage decision in any migration. The Exceptions framework lets you register a tree of exception types and have one boundary CatchExceptions intercept the whole tree (or any subtree). The shape of that tree determines what your boundary catches can express.
Three Baseline Shapes
A package’s exception type tree usually fits into one of three shapes:
- The package has few throw sites and no need for type grouping and/or intermediate catches in internal functions.
- The set of error identities is owned by an external system (OS error numbers, database error codes, network status codes, vendor SQL state codes, …) and cannot be meaningfully grouped from the package’s perspective. See Leaf identity below.
- Shallow tree — one root plus one or two intermediate parent types grouping the leaves. Pays off if a project has more complex internal structure and the need to selectively catch groups of exceptions internally. The most common grouping axes:
- Deep tree — a multi-level hierarchy that mirrors a natural grouping in the problem domain or in the package’s own structure.
- Example: a connector to a complex external system whose own error taxonomy already has nested categories (subsystem → operation → cause) is a natural fit for a deep tree that mirrors that taxonomy.
- Example: a package with a clear module structure where each module gets its own intermediate parent exception type and you intend to use the modular selective-catch pattern (Section 4).
A flat tree is not a regression. It is just the position that says "I will discriminate at the leaf, not at any intermediate parent". On the other hand, the directory layout is then often a starting point for the latent exception type hierarchy, since source files and subsystem subdirectories already encode the grouping designers wanted, and one of the migration steps is to make that grouping explicit in the exception type tree.
When to Add Intermediate Parents
If unsure, you can start with the flat tree and refine the tree later, since adding intermediate parents does not invalidate existing catch rules.
1. Register the tree shape that best matches your current consumer needs (often flat or shallow).
2. Add intermediate parents lazily, one at a time, when a use case asks for catch-by-ancestor.
3. Refactor existing leaf exception types under a new intermediate parent — this is monotonic and does not break existing catch rules naming the leaves directly.
Example—Flat to Shallow
Starting point — a single-layer package with five flat leaf exception types.
(* Pseudocode *)
RegisterExceptionType[pkgInputError]
RegisterExceptionType[pkgIOReadError]
RegisterExceptionType[pkgIOWriteError]
RegisterExceptionType[pkgComputeError]
RegisterExceptionType[pkgConfigError]
Suppose that later the requirement changes to retry on any IO error but propagate everything else. Add one intermediate parent and re-parent the two IO leaves.
(* Pseudocode *)
RegisterExceptionType[pkgException]
RegisterExceptionType[pkgIOError, pkgException]
RegisterExceptionType[pkgInputError, pkgException]
RegisterExceptionType[pkgIOReadError, pkgIOError]
RegisterExceptionType[pkgIOWriteError, pkgIOError]
RegisterExceptionType[pkgComputeError, pkgException]
RegisterExceptionType[pkgConfigError, pkgException]
Existing catches naming
directly still work. New consumers can write
(* Pseudocode *)
CatchExceptions[pkgIOError -> retryHelper] @ body
Leaf Identity
Inside the hierarchical-tree position there is a second, finer decision: should every leaf be a registered symbol, or are some leaves open string parameters on a registered branch?
Variant A — Symbol per Leaf (the Recommended Default)
Every distinct error kind is its own registered symbol.
(* Pseudocode *)
RegisterExceptionType[pkgException]
RegisterExceptionType[pkgInputError, pkgException]
RegisterExceptionType[pkgMissingColumn, pkgInputError]
RegisterExceptionType[pkgTypeMismatch, pkgInputError]
- Variant A is usually the right baseline for project-owned error vocabularies. It gives you:
Variant B—Open-Parameter Branch
A registered branch type carries an open parameter on its leaves — most often a string (e.g. a SQL state code), but it can equally be an integer error code, a composite identifier or any other value drawn from an externally owned namespace/error set.
RegisterExceptionType[pkgException]
RegisterExceptionType[pkgJDBCException, pkgException]
ThrowException[pkgJDBCException,
<|"SQLState" -> "23000",
"VendorCode" -> 1062,
"Message" -> "Duplicate entry"|>]The leaf identity of an SQL error is "the
string”; the project does not own that namespace, so registering a symbol per code would be both pointless overhead and impossible to keep current.
Notes
The two approaches compose. The project’s overall tree can have most of its leaves as registered symbols (Variant A) and one or two branches with open-parameter leaves (Variant B); catch-by-parent works the same way regardless. Inside a single Variant-B branch, the open parameter itself can be composite — for example,
can carry both a
and a
, allowing handlers to discriminate on either or both.
- Use Variant A when the project owns the taxonomy. Almost always the right choice.
- Use Variant B when the namespace of identities is owned by the outside world and is open or unbounded — external system error codes (OS, databases, network, foreign processes, …).
3. The Classification Axes
A package’s error-handling design can be described as a point in a multidimensional space, with each axis capturing one independent architectural decision. The axes are largely independent of each other: a value on one tells you only a little about values on others. The choice of an appropriate migration recipe (or their combination) in Section 6, is determined almost entirely by where the package sits.
Classification Axes at a Glance
A brief summary of the seven axes covered in this section.
- AX1 — Layering. Whether throw sites build the user-facing diagnostic directly (single-layer) or raise an internal error that a separate translation layer maps to user-facing form at the package boundary (two-layer).
- AX2 — Error identity, payload shape and selective catching. How an error is represented and how the catch site selects what to handle. Three sub-dimensions:
- Selective catching — whether a given catch intercepts everything beneath it or only some exceptions, with the rest propagating through.
- Error identity — single shared identity for all errors, flat string identity or hierarchical symbolic error identity.
- Payload shape — association, Failure[], custom head used as a container with positional arguments carrying parts of error information or something else.
- AX3 — Propagation depth. Whether errors traverse only the package boundary catch (direct) or also one or more inner catch–rethrow steps (nested).
- AX4 — Head binding. Where the public-function head used in
comes from: explicit argument to the catch site, late discovery via stack walk or early binding at the throw site.
- AX5 — Debug-mode integration. How thoroughly a debug switch is wired into error handling: none, point-only (a few helpers) or project-wide.
- AX6 — Public error-return convention. What the public function returns once an error has been caught at its boundary: unevaluated input plus Message[], $Failed plus Message[] or a Failure[] object.
- AX7 — Evolution stance. The intent with which a package adopts the framework: drop-in replacement, incremental adoption or architectural migration. May not affect the first migration step (a drop-in replacement) but instructs the next steps, which may or may not involve deeper architectural changes.
The independence is real but not absolute. AX1 (layering) correlates with AX2’s error identity sub-dimension and with AX4 (head binding) — the relationships are cataloged in Section 3.3 (Relationships between axes). AX6 (return convention) and AX7 (evolution stance) are independent of AX1–AX5 in a different way: they are decisions every migration must make, but they live one scope outside the internal-propagation axes. AX6 affects only the boundary wrapper, and AX7 is a property of the migration plan rather than of the migrated runtime. The axes still form a usable basis for classifying packages — just not a strictly orthogonal one.
The remainder of this section walks through each axis. For every axis, we first describe the variants typically used by custom mini-frameworks in existing projects — with code snippets showing the shape of each pattern, so you can recognize where your code sits. The variant supplied by the Exceptions framework as its native default then appears in its own Framework angle subsection, with code where useful. The closing Section 3.3 (Relationships between axes) collects the variants that regularly co-occur across axes.
AX1—Layering: Single-Layer vs. Two-Layer
This is one of the biggest distinctions (along with the distinction of whether or not your project currently uses non-local control flow at all).
Single Layer
The single-layer architecture is one when the shape of the thrown payload closely determines the user-facing return shape. It has the following features:
- Translation, if any, is mechanical: in the most common case, the throw site captures a message-name fragment (a string tag) and held arguments, and the catch site combines them with the public function’s head to obtain
. Other shapes — prepending a tag to an existing Failure[], adding a
field, etc. — follow the same pattern: the catch site does a fixed, per-payload-shape transformation with no per-error logic.
A small single-layer mini-framework follows the shape sketched below. The throw site captures only a message-name fragment and held arguments; the catch site combines them with the public function’s head (pseudocode — see Recipe O2 in Section 6 for the runnable form on top of the Exceptions framework).
(* Pseudocode — see Recipe O2 in Section 6. *)
throwFailure[name_String, args___] :=
Throw[<|"name" -> name, "args" :> {args}|>, $tag]
catchFailure[head_Symbol, body_] :=
Catch[
body,
$tag,
(* handler: build head::name from the captured fragment, *)
(* emit the message, return Failure["MyPkg", …] *)
…
]
Two-Layer
The two-layer architecture is one when the throwing code uses an internal error vocabulary; a separate translation registry maps each internal tag to user-facing messages and return values (Failure object, $Failed, etc.). It has the following features:
- Internal tags are often plain strings or expressions with a custom head, like
; the registry is parametrized by the enclosing public function’s head.
- projects where one internal error may map to several user-facing functions, that may handle it differently
A two-layer skeleton has the following shape. (Pseudocode — see Recipe O4 in Section 6 for the runnable form.)
(* Pseudocode — see Recipe O4 in Section 6. *)
$rewrites = <||>;
defineHandler[internalTag_String, fn_] :=
AssociateTo[$rewrites, internalTag -> fn];
raise[internalTag_String, fields_Association] :=
Throw[<|"InternalTag" -> internalTag, "Fields" -> fields|>, $tag]
withErrorHandling[head_Symbol, body_] :=
Catch[
body,
$tag,
(* handler: look up internalTag in $rewrites, *)
(* call fn[fields] to obtain {userMsgName, msgArgs}, *)
(* emit Message[head::userMsgName, …], return Failure[…]. *)
…
]
The two-layer model is more general but has a slightly higher fixed cost — the registry must exist before the first low-level tag is useful. Single-layer projects that grow into two-layer ones typically pay for the migration in confused public-facing messages and ad hoc per-call-site translations. If you are starting a new project, you will likely be better off by using the two-layer model from the start.
Framework Angle
At present, the Exceptions framework is neutral on this axis. However, in future versions it may provide a level of automation for the error translation layer in the two-layer model, which is generally considered a better and more robust architecture than a single-layer model.
For today’s framework, the same CatchExceptions[pkgExceptionhandler] boundary catch supports both layering choices; only the handler’s body differs. Both forms are covered with executable code in Section 6 (Migration recipes) (Recipes O2 and O4 respectively).
Single Layer
There are two major forms such architectures usually take: late-bound and early-bound forms. Both are single-layer designs — the difference is purely about where the public function's head is bound, not about layering. See AX4 for the head-binding axis.
- Late-bound form — the throw site stores only a fragment (
), and the public-function symbol (head) is supplied by the boundary catch at catch time. - The throw site stores the message tag name as a string plus the message arguments in the exception payload (
). - The boundary handler reads those keys, combines the tag string with its supplied
into a MessageName[head,tag], emits the message and constructs a Failure[].
- Early-bound single-layer form: the throw site has access to the public-function symbol/head and packages the full MessageName[head,tag] directly into a
field.
The CC.2 helper (Section 5 (Helpers shared by every recipe)) implements the early-bound form; Recipe O2 (Section 6 (Migration recipes)) implements the late-bound form.
Two-Layer
In this form, there is a separate translation step between the internal errors propagating through the system and the public errors seen by the users. It has the following typical logic:
- The throw site stuffs an internal tag plus a fields association into the exception payload.
(<|"InternalTag" -> "missing_column", "Fields" -> <|"Column" -> col|>|>).
- The boundary handler looks up the internal tag in a translation registry, calls the per-tag rewrite function to obtain a user-facing message-name fragment plus arguments and constructs a Failure[].
AX2—Error Identity, Payload Shape and Selective Catching
This axis captures how an error is represented, and how the catch site selects what to handle. It has three largely independent sub-dimensions, which are, however, related to each other, which is why they have not been promoted to separate axes in this document; the Exceptions framework supplies a coherent default along all three.
Sub-Dimension 1: Selective Catching
Does the package catch all its errors at one place, or does it catch some errors selectively (handle them or recover) and let others propagate?
Non-Selective Catching (the Most Common Existing Pattern)
In this case, whichever catches the package has, none of them lets any exception propagate through to an enclosing catch — every catch deals with everything thrown beneath it.
(* Pseudocode *)
$tag = "PkgThrowTag";
publicFunc[args___] := Catch[implementation[args], $tag, ...]
In practice, this usually means a single boundary catch wrapping each public function with no inner catches. This can be justified for simple packages but misses an architectural opportunity in larger ones — per-module recovery, retry, fallback (see Section 4).
Selective Catching by Tag
Different errors thrown with different Throw tags; an inner catch names a specific tag and lets others fly through.
(* Pseudocode *)
Catch[
Catch[body, pkgCacheError, recoverFromCacheMiss],
pkgError, boundaryHandler
]
This is native to Throw/Catch but rarely used in practice. It could, however, be made reasonably safe by using private context-scoped symbols as tags or composite tags like
matched with Catch[expr,myCustomTag[specificType],…]. Proliferating string tags, on the other hand, is genuinely collision prone, since strings are not scoped.
Selective Catching by Payload-Test (Rethrow on Miss)
Single shared throw tag; the inner catch matches the payload against a pattern, runs a handler if it matches and rethrows manually otherwise. This is the closest existing custom mini-framework equivalent of the Exceptions framework’s catch-by-exception-type, implemented by hand.
(* Pseudocode *)
SetAttributes[customCatch, HoldFirst]
customCatch[expr_, kindPattern_, handler_] := Catch[
expr,
$tag,
Function[{data, t},
If[MatchQ[data["Kind"], kindPattern],
handler[data],
Throw[data, t]
]
]
]
In contrast with the flat-identity variant under sub-dimension 2 (which catches everything wholesale and uses identity only to choose the output), this pattern uses the payload of Throw to choose what to catch in the first place.
Notes
Selective catching of any flavor is, technically, a form of nested propagation along AX3 — a selective catch that does not match a given exception auto-rethrows it to the next outer catch. The Exceptions framework hides that rethrow plumbing from the user, and the selective-catch model is what makes the more complex workflows of Section 4 (modular per-module catches under a project-level boundary) expressible compactly.
Sub-Dimension 2: Error Identity
How is each kind of error identified? Typical variants include single shared identity, flat string (or otherwise unique) identity and hierarchical symbolic identity (Exceptions framework native).
Single Shared Identity
The whole package uses one throw token; the kind of error has to be inferred from payload contents.
(* Pseudocode *)
Throw[<|"What" -> "missing column", "Column" -> col|>, $tag]
In this case, errors may not have a separate dedicated identity. Instead, error identity is implicitly encoded in the dispatch function that handles the errors.
This is perhaps the most ad hoc method of error handling. This may be suitable when there are very few error kinds, or when errors are not distinguished at the catch site.
Flat String (or Otherwise Unique) Identity
Each error kind has its own identity drawn from a flat namespace — most often a string tag, positionally extractable from the payload (the tag of a Failure[], the first argument of a custom expression like
or a
key in an association), but it can equally be an integer code or any other identifier from a flat set.
(* Pseudocode *)
Throw[Failure["missing_column", <|"Column" -> col|>], $tag]
- Each error stands on its own — there is no notion of one error kind being a sub-case of another and no way to catch a group of related kinds without listing them by name.
- Typically used with non-selective catching: the boundary catches everything, then dispatches on identity to choose the user-facing output.
Hierarchical Symbolic Identity (Exceptions Framework-Native)
Each error kind is a registered symbolic exception type with parent exception type(s).
(* Pseudocode *)
ThrowException[pkgMissingColumn, <|"Column" -> col|>]
The hierarchical-identity position has a further sub-decision — symbol-per-leaf vs. open-parameter-on-branch — covered in Section 2 under Leaf identity; that decision determines the granularity at which catch sites can discriminate.
The Exceptions framework’s hierarchical identity composes cleanly with selective catching by exception type — see the Framework angle below.
Sub-Dimension 3: Payload Shape
What does the thrown value look like? Common shapes:
- Association. The dominant choice in modern code; easy to read in handlers, easy to extend with extra keys.
(* Pseudocode *)
Throw[<|"Tag" -> "missing_column", "Column" -> col|>, $tag]
- Failure[tag,assoc]. Convenient when the throw site already has enough information to build the eventual user-facing failure. The boundary handler often passes it through unchanged.
(* Pseudocode *)
Throw[Failure["missing_column", <|"Column" -> col|>], $tag]
- Custom positional head. A custom expression like
carrying the error identity (a string) plus error-specific data positionally. Common in older code.
(* Pseudocode *)
Throw[pkgError["missing_column", col, tbl], $tag]
These three shapes are interconvertible. The Exceptions framework uses an Exception[] object as its propagation unit; when extra fields are needed, they are passed as an Association (the second argument to the Exception/ThrowException constructor), which is also the form to which other constructor calls are internally normalized.
Framework Angle — Registered-Exception-Type Dispatch
The Exceptions framework supplies a coherent default along all three sub-dimensions:
- Selective catching. CatchExceptions[Typehandler] only intercepts what matches the exception type pattern; everything else is auto-rethrown.
- Identity. Registered symbolic exception types with parent exception type(s) (see Section 2).
- Payload shape. An Exception[] object whose data is supplied as an Association.
Example
This is a simple example illustrating these features within the Exceptions framework.
RegisterExceptionType[pkgException];
RegisterExceptionType[pkgMissingColumn, pkgException];
RegisterExceptionType[pkgTypeMismatch, pkgException];
ThrowException[pkgMissingColumn, <|"Column" -> col, "Table" -> tbl|>]doRecovery[c_] := "recovered: " <> c;CatchExceptions[
pkgMissingColumn -> Function[data, doRecovery[data["Column"]]]
] @ ThrowException[pkgMissingColumn, <|"Column" -> "price"|>]CatchExceptions[{
pkgMissingColumn -> Function[data, doRecovery[data["Column"]]],
pkgException -> Automatic
}] @ ThrowException[pkgTypeMismatch, <|"Got" -> 1|>]Architectural Notes
- It is strictly more expressive than exception type dispatch — patterns can match arbitrary structure inside the payload, which exception type dispatch cannot — so it has no general one-line equivalent on top of registered exception types.
- Auto-rethrow on miss is built in. No need for the explicit Throw[data,t] rebound shown in the payload-test snippet — CatchExceptions already rethrows what it does not match.
- Hierarchical identity beats Alternatives[] patterns architecturally.
- A list-of-types catch like CatchExceptions[{type1,type2,type3}handler] is the exception-type-dispatch equivalent of a payload-pattern Alternatives[]. It works, but it is usually a sign that the discriminator the catch is using deserves a name.
- Promoting
to a common parent type and catching the parent is the architectural improvement; the original move from payload-pattern to registered-exception type dispatch (the previous bullet) is the bigger one. Once you are pattern-matching against types, Alternatives[] is subsumed by parent-type catch.
This tutorial’s goal is to explain how to migrate to registered-exception-type dispatch — the Exceptions framework’s native model. The recipes in Section 6 spell out the corresponding wrapper for each starting position.
AX3—Propagation Depth: Direct vs. Nested
How many catch–rethrow steps does it take for errors to reach the package boundary? The most useful first cut along this axis distinguishes three positions:
Zero-Depth—When the Package Uses No Throw at All
This section is most relevant to you if your package does not use any nonlocal error handling (based on Throw/Catch or its derivatives) at all but instead relies on the traditional error value propagation from function to function.
A case worth calling out separately, before the throw-based variants below: some packages do not use Throw/Catch for error propagation at all:
- Every internal function returns its error indication as a value ($Failed, Failure[], None, Missing[], ...)
- Every caller checks the return and either propagates it upward as another return value, transforms it into a different error indication or recovers locally.
- The public function’s job is then to convert whatever error indication bubbles up to the user-facing return convention (see AX6 for more on that).
Migrating such a package to use ThrowException is rarely a matter of "wrap every internal function in a throw and add a single boundary catch”. The reason is that the call chain may rely on each layer’s right to change the error representation and a uniform throw flattens that distinction.
(* Pseudocode *)
bazz[input_] := If[!validQ[input], $Failed, doWork[input]]
bar[input_] := With[{r = bazz[input]},
If[r === $Failed, Failure["bar", <|"Input" -> input, "Stage" -> "bazz"|>], r]]
foo[input_] := bar[input] (* passes Failure[] through unchanged *)
pub[input_] := With[{r = foo[input]},
If[FailureQ[r], translateForUser[r], r]]
The error indication starts as $Failed, becomes a structured Failure[] at
’s boundary (where the surrounding context — input value, originating sub-stage — is added), passes through
unchanged and is finally transformed by
into a user-facing message-plus-failure. Each layer transforms the error representation in a way that depends on the information it has and the information its caller will have.
If you migrate this naively by making every internal function throw, you collapse the chain. Suppose
becomes If[!validQ[input],ThrowException[someError,Association["Input"input]]], and
adds a single boundary catch. Then
’s opportunity to add the
context disappears — the throw goes straight to
, and
does not know which sub-stage produced the failure. The structured information that the chain previously accumulated has to be regenerated by
itself (less natural, since
does not know it is being called from
’s
stage versus some hypothetical other stage) or recovered by stack-walking at the boundary (fragile).
The migration analysis for such projects has to ask, layer by layer:
- Is this layer adding context that the caller could not add as well? If yes, the layer is doing work; converting it to a thin pass-through (a throw point with no transformation) loses information.
- Does any caller of this layer recover from its errors locally? If yes, throwing across the layer breaks that recovery — the caller would now have to wrap a CatchExceptions around the call, which is more boilerplate than the existing return-and-check.
- Does the layer’s error vocabulary differ from its caller’s? If yes, throwing flattens that distinction; the catch site sees only the throw site’s representation.
A reasonable migration strategy is to introduce throws selectively, at the layer where the error first acquires a stable shape that all enclosing callers can understand. In the example above,
’s Failure[] is the natural throw point:
already builds a structured representation, and every caller of
(including
and
) treats Failure[] uniformly.
can keep returning $Failed;
can convert that into a thrown exception instead of a returned Failure[];
becomes irrelevant to error handling (the throw flies through it);
’s explicit FailureQ check becomes a CatchExceptions boundary handler. The deeper layer (
) is unchanged.
This is not a recipe — it is a planning step. Recipe O1 in Section 6 walks through an end-to-end bootstrap migration with several call chains; the recipes after it (O2, O3, O4) assume the package already uses throws somewhere.
Direct and Nested Propagation
When the package does use Throw/Catch or some of their derivatives, the rest of this axis classifies how many catch–rethrow steps it takes for an error to reach the package boundary.
Direct Propagation
No internal function catches; every throw reaches the package boundary in one hop.
(* Pseudocode *)
publicFunc[input_] := CatchExceptions[pkgException] @ Module[{},
If[!validQ[input], ThrowException[InvalidArgError, <|"Value" -> input|>]];
compute[input]
]
- Trivial to reason about; no machinery needed for cause-chain preservation (reporting nested inner errors).
Nested Propagation
Some internal function catches a thrown exception and re-emits — possibly with a different identity or after a partial recovery. Some observations here:
- Genuinely nested catch-and-rethrow is rare in projects built before the Exceptions framework became available: most internal helpers either let throws fly through to the boundary or convert throws into ordinary Failure[] returns rather than rethrowing.
- Where it does happen, preservation of the original error across the catch–rethrow step is incidental; if the original is lost across a nested catch, it is simply gone.
The example below illustrates the pattern.
reevaluates
up to
times on transient failure, pausing between attempts; when the retry budget is exhausted, the final transient failure is converted into a permanent one carrying the original as its cause. So this models a situation where there are local recoverable internally handled exceptions but also more global ones that propagate to the package’s boundary, and internal functions may convert one type to another. This is, of course, just one of many possible scenarios that use the nested exceptions propagation model.
Options[withRetry] = {"RetryInterval" -> 0.1};
SetAttributes[withRetry, HoldFirst]
withRetry[body_, 0, OptionsPattern[]] := CatchExceptions[
TransientError -> Function[exc,
ThrowException[
PermanentError,
<|"Cause" -> Exception[exc], "RetriesExhausted" -> True|>
]
]
] @ body
withRetry[body_, retries_Integer?Positive, opts:OptionsPattern[]] :=
CatchExceptions[
TransientError -> Function[exc,
Pause[OptionValue["RetryInterval"]];
withRetry[body, retries - 1, opts]
]
] @ body
The recursive structure mirrors the way one emulates
with a recursive
in JavaScript: each catch handler reschedules the next attempt by calling the outer helper with one fewer retry, and the base case (zero retries left) is a separate rule that performs the conversion. The recursive call passes
through unchanged because
’s HoldFirst attribute keeps it from evaluating before each fresh CatchExceptions wraps it.
This is a concrete example of use.
ClearAll[flakyOp, $attempt];
$attempt = 0;
flakyOp[n_] := (
$attempt++;
If[$attempt < n,
ThrowException[TransientError, <|"Attempt" -> $attempt|>],
"ok on attempt " <> ToString[$attempt]]);$attempt = 0;
CatchExceptions[PermanentError] @ withRetry[flakyOp[3], 5, "RetryInterval" -> 0]$attempt = 0;
CatchExceptions[PermanentError] @ withRetry[flakyOp[3], 1, "RetryInterval" -> 0]Framework Angle—Selective Catching Is Propagation in Disguise
The Exceptions framework helps here in two ways.
- CatchExceptions[Typerebuild] @ body makes the catch–transform–rethrow step a single composable expression, removing one of the practical reasons projects avoided nested catches in the first place.
- Selective catching is, technically, a form of nested propagation in disguise. A CatchExceptions[ChildTypehandler] @ body only intercepts exceptions whose type is
or a registered descendant; an exception of an unrelated type is auto-rethrown to the next outer catch. The rethrow plumbing that ad hoc mini-frameworks based on Catch need to write by hand is built in. This is what makes the modular architecture in Section 4 (Modular architectures and selective catching) practical.
AX4—Head Binding: Where the Public-Function Head Comes From
The "head" is the symbol whose
is used to build the user-facing message. One can distinguish three variants here for the head binding: explicit, late/automatic and early.
Explicit Binding
The catch site receives the head as an argument; every public function repeats the head in its top-level catch wrapper.
(* Pseudocode *)
publicFunc[args___] := catchFailure[publicFunc, body[args]]
This version is the most general and architecturally robust. In this case, since the public function's name is provided explicitly, the catchFailure function is free to implement arbitrary translations of caught internal errors into the public-facing ones.
Late/Automatic Binding
The catch site discovers the head by various dynamic mechanisms. This removes the per-call-site repetition at the cost of some fragility.
One of them is walking the evaluation stack at catch time. Below is an example pseudocode illustrating the approach (the snippet shows the structural pattern; a real implementation also filters out internal stack frames so that
is reliably the public function and not some intermediate helper).
(* Pseudocode *)
SetAttributes[catchFailureAuto, HoldFirst]
catchFailureAuto[body_] := With[
{head = First[Cases[Stack[_], h_Symbol[___] :> h, {1}, 1], $Failed]},
catchFailure[head, body]]
publicFunc[args___] := catchFailureAuto[body[args]]
Another is to define a function via some custom definition operator or macro that would add code to automatically set some global variable to the public symbol in question (the code below is also just a schematic simplified version of such an approach).
(* Pseudocode *)
SetAttributes[defineFunc, HoldAll]
defineFunc[SetDelayed[func_Symbol[args___], rhs_]] := SetDelayed[
func[args],
Block[{$CurrentFunction = func}, rhs]
]
SetAttributes[catchFailureAuto2, HoldFirst]
catchFailureAuto2[body_] := catchFailure[$CurrentFunction, body]]
defineFunc[
publicFunc[args___] := catchFailureAuto2[body[args]]
]
Some variations of such approaches are rather commonly used in practice. But it is important to understand about them that generally they are inherently fragile, since they typically rely on some kind of dynamic mechanism to determine the head of the public function to use in the error handling code.
Early Binding
In this approach, the message head is supplied at the throw site (or derived there from already-known information). What the catch site eventually returns either does not depend on the public function’s head at all, depends only on that head as a value already fixed by the throw or is otherwise fully determined by the throw-side payload — the boundary handler does not have to make any per-error decision involving the head.
This is typical for single-layer designs that throw fully pre-built Failure[] objects, but it also applies whenever the throw site embeds a MessageName[head,tag] (or equivalent) into the payload (see CC.1 and CC.3 in Section 5).
The example below illustrates a typical early binding approach. The
helper inspects the catch result, emits the embedded Message[] via the public function’s head if the catch produced an early-bound Failure[] and returns the same value either way. The message emission is centralized at the catch boundary, but the message-name fragment was bound at the throw site.
(* Pseudocode *)
publicFunc::badarg = "Bad input ``1``.";
issueMessage[arg_] := Replace[
arg,
Failure[_, data : KeyValuePattern[{"MessageTemplate" :> mn_MessageName}]] :> (
Message[mn, Sequence @@ Lookup[data, "MessageParameters", {}]];
arg
)
]
compute[args___] := If[!validQ[args],
Throw[
Failure[
"MyPkg",
<|"MessageTemplate" :> publicFunc::badarg, "MessageParameters" :> {args}|>
],
$tag
],
(* else *)
doWork[args]
]
publicFunc[args___] := issueMessage @ Catch[compute[args], $tag, Function[{f, t}, f]]
This convention is convenient but has notable limitations. A careful reader would notice that the code in compute, an internal function, references the public function publicFunc, which can be much higher up the execution stack. There are more things that are wrong with it:
- It breaks the responsibility chain. The inner code (
here) has to know the head of the public function it is currently serving, but lower-level helpers are usually not in a position to decide how an error they raise should be presented to the end user.
- It is workable only when the inner function is genuinely owned by and called from exactly one public entry point. Any helper shared across two public functions cannot use early binding without ambiguity about which head to name.
- Two-layer designs (AX1) explicitly avoid this coupling by deferring the head to the boundary catch, where the public-API wrapper supplies it.
The Throw a Failure straight through helper in Section 5 (CC.1) makes early binding manageable in the cases where a single-layer package legitimately needs it. In any case, as explained above, the limitations of this approach make it less recommended than some of the alternatives. As soon as your project's complexity grows above certain point, it may be worth it to migrate to a more flexible scheme, such as a combined one discussed next or the scheme involving an explicit translation layer (see e.g. Recipe O4 (Section 6) )
Combinations
Some projects use a combination of early and explicit binding, which makes it more manageable and removes some of the issues that the early binding method has. In particular, the payload thrown by the internal function may contain a string that both identifies the error (not necessarily uniquely) and serves as a message name tag for the "future" message to be issued by the public function that catches the error.
Here is the version of the previous example that uses this mixed approach.
(* Pseudocode *)
publicFunc::badarg = "Bad input ``1``.";
issueMessage[head_] := Replace[
f: Failure[_, data : KeyValuePattern[{"MessageNameTag" -> mtag_String}]] :> (
Message[MessageName[head, mtag], Sequence @@ Lookup[data, "MessageParameters", {}]];
f
)
]
compute[args___] := If[!validQ[args],
Throw[
Failure[
"MyPkg",
<|"MessageNameTag" -> "badarg", "MessageParameters" -> {args}|>
],
$tag
],
(* else *)
doWork[args]
]
publicFunc[args___] := issueMessage[publicFunc] @ Catch[compute[args], $tag, Function[{f, t}, f]]
Such an approach is typical for single-layer projects. Whereas there still is a coupling here, since the message name tag for a public function is directly provided by the inner function/thrown payload, it is less pronounced and arguably less fragile than in the early binding scheme. Still, the two-layer architectures with explicit translation layers are architecturally more sound than either of these.
Framework Angle
CatchExceptions[pkgExceptionrebuild] @ body works identically whether
is supplied to
from the wrapper’s argument or from a stack walk in a Block scoped dynamic environment. The Exceptions framework is neutral on the explicit-vs.-late-vs.-early distinction.
However, depending on whether you want to simply implement the Exceptions framework-based drop-in replacements based on your current custom error-handling solution or also make more profound architectural changes and improvements, you may benefit from the use of the Exceptions framework to a varying degree. In that sense, understanding where your project currently stands in this aspect (head binding) is important, to be able to pick the best migration strategy. This is discussed more in the section on the AX7 axis.
AX5—Debug-Mode Integration
This axis is about how thoroughly the package’s debug switch is wired into error handling. Whereas debugging may look like a tangential and unrelated issue to the error handling proper, it perhaps is not as independent as it may appear, particularly for larger and more complex projects. If you plan a migration to the new set of error-handling tools, perhaps it is a good idea to also think about your debugging tools at the same time.
Debugging Approaches Classification
None/Ad hoc
No consistent debug switch. Debugging is done by ad hoc Print, Echo or by other custom project-specific means.
Point
A few specific helpers behave differently in debug — typically a global flag that turns API-misuse failures into Abort[] and stack dumps. Most errors are unaffected.
(* Pseudocode *)
$pkgDebug = False;
internalAssert[cond_, msg_] := If[
cond,
Null,
If[$pkgDebug,
Print[Stack[]]; Abort[],
throwFailure["assert", msg]
]
]
Project-Wide
A single switch alters every error’s behavior. Examples include enriching every user-facing Failure with internal details in debug mode or swapping the boundary handler for one that captures stack information.
(* Pseudocode *)
SetAttributes[withErrorHandling, HoldRest]
withErrorHandling[head_, body_] := If[
TrueQ[$pkgDebug],
debugCatch[head, body],
productionCatch[head, body]
]
Two Complementary Techniques Worth Knowing About
Two patterns extend the project-wide variant in ways that compose well with the framework’s selective-catching model. They are recommendations, not framework features — the framework supplies the primitives; the package wires them together. The conceptual sketch is here; the runnable wiring is in Recipe O5 (Section 6).
-Field Enrichment
- When debug mode is on, the throw helper attaches a
(or
) field to the exception’s payload, carrying additional diagnostic context — the immediate enclosing function, a stack snapshot, the values of nearby variables, the current scope path or whatever the package finds useful for debugging.
- The boundary handler reads the field if present and propagates it into the user-facing Failure[]’s data, where it is visible to the developer but does not disturb the normal payload structure.
- What it buys you: richer diagnostics on the failures that escape, with no change to the package’s recovery behavior.
Rerouting
- When debug mode is on, the throw helper raises the exception under an independent local root exception type —
(or any other suitable name), registered outside the package’s normal exception type tree — rather than under its normal exception type.
- In the hard mode described above, debug exceptions serve more like asserts, since they will not be caught by the project’s handlers and will propagate to the top level.
- As a softer variant, you may introduce DebugException as a direct child type of the project’s root type. This may make sense if your project has intermediate catches/nested propagation (AX3 axis).
- What it buys you: the developer sees the failure raw, in the form and at the location it actually occurred, rather than discovering it secondhand through a degraded result that recovery handlers produced.
The two compose. Enrichment decorates what the exception carries; rerouting controls which catches see it. The debug mode can use both; production mode pays no overhead because both indirections collapse to the production path under the If[$pkgDebug,…] switch.
Migration Guidance by Starting Position
The starting AX5 position suggests a specific way to wire debug into the migration:
- None/ad hoc → introduce both techniques at once. Since the project has nothing entrenched, the migration moment is the cheapest one to introduce a single global flag plus a single throw-side indirection (
in Recipe O5) that supports both
enrichment and
rerouting. Pair this with a one-time addition: when registering the exception type tree (Section 2), also register a top-level
outside the package’s tree (or as a direct child of the package root type). The cost is one symbol and one registration call; the benefit is that rerouting is available the day you need it for the first hard-to-reproduce bug, without revisiting every throw site.
- Point → keep the focused debug helpers; add
enrichment as the project-wide complement. The existing point-debug helpers (
and friends) are doing a different job — they trip on a small handful of well-known invariants and abort hard. Leave them alone. What they do not give you is diagnostic context on the throws elsewhere in the package; that is what
enrichment fills in.
rerouting is most useful here for projects that have also adopted the modular pattern of Section 4: turning the flag on retargets exceptions past the per-module recovery catches, so a developer sees the underlying error rather than the recovered fallback.
- Project-wide → simplify the existing alternate-handler scheme. A package that already swaps boundary handlers under
is paying for two parallel handler implementations. Replace both with a single handler that does only enrichment display and let
rerouting decide whether the handler runs at all (under rerouting, the framework’s outer catch sees
directly and the package boundary is bypassed). The end state is one handler, one switch, one rerouting indirection — and it pre-positions the package for the per-exception-type debug API (Section 10), where rerouting becomes a property assignment on a type rather than a switch in a throw helper.
Framework Angle
The Exceptions framework is moving towards a per-exception-type debug-mode mechanism — a property attached to a registered exception type, with subtype inheritance, so that debug mode can be enabled for one subtree of exception types without affecting the rest. The mechanism is real and works today, but its API is undocumented at the time of writing; until it is documented, packages that need a debug switch should keep their existing one or implement a small per-package one along the lines of Recipe O5 (Section 6). Section 10 (Looking ahead) sketches the direction.
When the per-exception-type API is documented, the throw-side indirection in Recipe O5 collapses into a property assignment on the relevant types and the package’s debug-mode wiring becomes considerably smaller.
AX6—Public Error-Return Convention
Independent of how errors propagate internally and how they are identified, there is a separate decision about what the public function actually returns once an error has been caught at its boundary. Three conventions are common:
Unevaluated Return + Message
The function emits a message and returns its own input expression unevaluated, typically via a guarded definition that fails its own pattern test.
(* Pseudocode *)
func[x_, y_] := "NeverHappens" /; Message[func::someerror, x, y]
or, when the error is detected mid-computation.
(* Pseudocode *)
func[x_, y_] := With[
{r = compute[x, y]},
{checked = condition[r]},
If[!TrueQ @ checked, Message[func::invalid, x, y]];
r /; checked
]
This convention has historical roots in symbolic computation: an unevaluated form gives downstream code another chance to simplify or rewrite the expression. It remains the right choice when the surrounding language requires it.
For example, predicates like MatchQ, EvenQ, IntegerQ evaluate to themselves on argument-count or type mismatches (typically issuing a Message[] along the way), and user-defined predicates should probably do the same since that’s what users typically expect.
It is also the appropriate choice for symbolic-transformation code that is meant to compose with Simplify and other symbolic manipulation functions and code relying on pattern matching and rewrite rules: returning unevaluated lets the surrounding rewrite system either find another rule that applies or leaves the expression untouched in the output, which is exactly the desired behavior in a symbolic context.
For most general-purpose programming tasks, however, this convention has fallen out of favor. It has a number of drawbacks:
- The unevaluated return is easy to mistake for a successful result; downstream code that does not specifically test for it will happily proceed as though the call had succeeded.
- The fact that an error occurred is conveyed only through the side effect of the emitted message — itself an unreliable channel (see the next bullet).
- When the unevaluated value flows into an enclosing function that has its own error-detection logic but cannot reliably recognize the inner failure, each enclosing layer adds its own message to the message list. The final result returned to the top level is then wrapped in several layers of accumulated complaint, and the true root cause is buried among the cascading messages. This defeats early error detection — one of the main reasons to use exception-style propagation in the first place.
$Failed + Message
The function emits a message and returns the symbol $Failed. Common in code that predates the introduction of Failure[] objects into the language. This method has the following shortcomings:
- Detail beyond "something failed" is conveyed entirely through the side-effect of the emitted message.
- Downstream callers that want to react programmatically end up parsing or intercepting messages, which is fragile: messages can be reformatted between language versions, disabled with Off[] or suppressed with Quiet[], so a programmatic consumer cannot rely on them being present in any particular form.
This style is considered obsolescent for new projects. It remains unavoidable in legacy code that callers already depend on, where the established $Failed return contract cannot be changed without breaking those callers.
Failure[] (with Optional Message)
The function returns a Failure[tag,assoc] carrying all the structured information about the error.
- The Failure object is the primary error-reporting channel. Emitting a Message[] becomes optional — a message is added only when there is a semantic reason for one (interactive-session diagnostics; preservation of an established message contract on a legacy public symbol).
- The modern view treats messages as unreliable for building programmatic error handling on top of. They can be reformatted, disabled with Off[] or suppressed with Quiet[], so they should not be the only error-reporting mechanism.
- There is a Quiet[] performance argument too. Even when the caller suppresses the message with Quiet[], the Wolfram Language evaluator still pays the message-formatting cost (template lookup, parameter substitution, MessageName resolution) before discarding the result.
Framework Angle
The Exceptions framework’s default boundary handler matches the third convention: CatchExceptions[pkgException] @ body returns a Failure[] constructed from the caught exception’s data, with no message emitted. To support either of the other two conventions, you wrap the boundary catch with a custom handler.
$Failed + Message
Supply a handler that emits the message and returns $Failed.
ClearAll[publicFunc]
publicFunc::bad = "Bad input `1`.";
impl[x_] := ThrowException[pkgException,
<|"MessageTagName" -> "bad", "MessageParameters" -> {x}|>];
publicFunc[args___] := CatchExceptions[
pkgException -> Replace[{
d : KeyValuePattern[{"MessageTagName" -> tag_String}] :> (
Message[
MessageName[publicFunc, tag],
Sequence @@ Lookup[d,"MessageParameters", {} ]
];
$Failed
)
,
_ :> $Failed
}]
] @ impl[args]
publicFunc[5]Recipe O2 (Section 6) already produces a handler of exactly this shape; only its return value would change from a Failure[] to $Failed.
Unevaluated Return +Message
There is no way for the boundary handler itself to "return unevaluated", because by the time control reaches the handler, the public function has already been entered. The conventional workaround is the conditional-definition pattern, with an inner implementation function whose return value (a sentinel on error) drives the condition.
ClearAll[publicFunc, publicFuncImpl]
publicFunc::bad = "Bad input `1`.";
impl[x_] := ThrowException[pkgException,
<|"MessageTagName" -> "bad", "MessageParameters" :> {x}|>];
publicFuncImpl[args___] := CatchExceptions[
pkgException -> Replace[{
d : KeyValuePattern[{"MessageTagName" -> tag_String}] :> (
Message[
MessageName[publicFunc, tag],
Sequence @@ Lookup[d,"MessageParameters", {} ]
];
$unevaluatedFlag
)
,
_ :> $unevaluatedFlag
}]
] @ impl[args]
publicFunc[args___] := With[{result = publicFuncImpl[args]},
result /; result =!= $unevaluatedFlag
]
publicFunc[7]The With[{result=…},result/;…] form is essential: it captures the publicFuncImpl[] call’s return value once and then tests the captured value, so the publicFuncImpl runs exactly once per
call. The Exceptions framework currently supplies no shortcut for this convention; maintaining it remains entirely the package’s responsibility.
Position in the Taxonomy
AX6 differs from AX1–AX5 in scope rather than rank. Where AX1–AX5 describe choices made inside the package’s error-handling machinery, AX6 describes the choice made at the package’s outer boundary about what callers see.
Migration along AX6 affects only the thin outer wrapper around the boundary catch; everything else in the recipes (Section 6) is unchanged. Also, the choices described by AX6 are made on a per-public-function basis (different public functions within the same package may use different such choices), whereas other axes generally describe more global choices affecting everything in the package.
You can pick any combination of AX1–AX5 first, then choose AX6 independently — that scope independence is one of the things that makes AX6 cleanly axis-like even though its concerns are different from those of AX1–AX5.
AX7—Evolution Stance: Drop-in vs. Incremental vs. Architectural
This axis classifies the intent with which a package adopts the Exceptions framework. It may not affect the first migration step (a drop-in replacement), but it instructs the next steps, which may or may not involve deeper architectural changes.
Type of Framework Adoption
Drop-in Replacement
The package’s existing helpers (functions like
,
,
,
, etc., introduced in the examples and recipes in subsequent sections) keep their names and call-site contracts; only their bodies are rewritten on top of ThrowException/CatchExceptions. The rest of the codebase does not know the migration happened. This method has the following features:
- Lowest cost; lowest immediate benefit. The package gains a registered-exception-type root and the Exceptions framework’s default boundary behavior, but its layering, error identity model and selective-catching choices are frozen at their pre-migration values.
- Right choice for packages with stable public-facing helpers and many internal call sites whose churn is not justifiable on its own.
Incremental Adoption
New code uses the Exceptions framework primitives directly; existing helpers keep working but are no longer the recommended path. Call sites migrate to direct ThrowException/CatchExceptions use opportunistically, as the surrounding code is touched for other reasons. This method has the following features:
- Mid-range cost; mid-range benefit. Eventually shifts the package onto the framework’s native idioms without requiring a coordinated rewrite and gives newer subsystems access to selective catching (Section 4) without forcing older ones to convert.
- Right choice for actively maintained packages where new development dominates over maintenance of stable code.
Architectural Migration
The migration is the occasion to rethink layering (AX1), error identity and selective catching (AX2), modular structure (Section 4) and propagation depth (AX3) together. The recipes in Section 6 are applied as a deliberate redesign, not a mechanical translation.
- Highest cost; highest benefit. Typically also the right time to introduce the Section 4 modular pattern.
- Right choice for packages that have outgrown their original error-handling design and where a coordinated rewrite is already on the roadmap.
Framework Angle
The Exceptions framework is designed to support all three stances:
- Drop-in replacements are easy. This is illustrated by the recipes in Section 6, where every recipe preserves existing helper names for the first/initial migration step. The required changes are typically rather minimal.
- Incremental adoption is supported because the framework’s primitives compose freely with existing Throw/Catch and Failure[] code — a partly-migrated package is not in a broken state, and a hand-rolled boundary catch can sit next to a CatchExceptions boundary in the same module without interference.
- Architectural migrations get the most leverage from the exception type hierarchy (Section 2) and the modular catching pattern (Section 4), neither of which is reachable from a purely mechanical translation.
Position in the Taxonomy
AX7 sits one level outside AX1–AX6: it is a property of the migration plan rather than of the migrated package. It still belongs in the axis set because it is the question that frames every other axis decision — a drop-in migration freezes AX1–AX4 at their pre-migration values, an incremental migration leaves them open, and an architectural migration treats them as design choices to be revisited. Once the migration is complete, AX7’s value is no longer visible in the runtime shape, but it determined which combinations of AX1–AX6 the package could reach.
Relationships between Axes
The axes are largely independent — but a few correlations show up regularly enough to be worth flagging when reading off your package’s position vector.
- AX1 (layering) ↔ AX4 (head binding). Single-layer designs frequently use early or late head binding (the throw site has all the information, or a stack walk finds it). Two-layer designs almost always use explicit binding — the boundary catch receives the head as an argument, and the translation registry uses it to build
.
- AX1 ↔ AX2 (error identity). Two-layer designs put pressure on a finer error identity space: internal tags need to be discriminable for the translation registry to dispatch on them. They typically end up with flat-string identity (or, in the Exceptions framework, hierarchical identity). Single-layer designs often get away with a single shared identity.
- AX3 ↔ AX2 (selective catching). Nested propagation is more meaningful when inner catches are selective. If every inner catch intercepts everything, "rethrow" reduces to "rebuild and throw", which increases the required boilerplate in internal functions and decreases the expressive power and usefulness of exceptions, by disabling more complex propagation chains. Direct propagation is the natural pairing for non-selective catching.
- AX5 ↔ AX2 (error identity). A per-exception-type debug switch — the Exceptions framework’s intended direction (Section 10) — requires hierarchical identity to inherit settings. Flat-identity designs cannot directly use it; they have to fall back to a project-wide switch.
- AX6 (return convention) is independent of all others. It only affects the outer wrapper around the boundary catch and does not interact with the internal-propagation axes.
- AX7 (evolution stance) frames every other axis. A drop-in migration freezes AX1–AX4 at their pre-migration values; an architectural migration treats them as design choices to be made afresh. AX7 itself does not appear in the runtime shape of the package.
These correlations are tendencies, not laws. The migration recipes in Section 6 still key off the package’s axis values regardless of which combinations are co-occurring.
4. Modular Architectures and Selective Catching
The Exceptions framework’s catch-by-exception-type-or-ancestor enables richer architectural patterns than a single boundary catch per package. A package with a clear module structure can have per-module inner catches plus a project-level outer catch, with the exception type tree controlling what each layer sees.
The specific shape developed in detail in this section — a single package-wide root with module roots and a direct-to-package subtree as siblings beneath it — is one concrete way to organize such an architecture. It is not the only way. Many other arrangements work, depending on the project’s existing structure and the way its modules need to interact: some projects share a thin "framework root" between two otherwise-independent subsystems, others register module roots as completely separate roots with no shared ancestor (see A separate-roots variant below), others give certain modules multiple roots for different audiences. The takeaways from this section are general; the specific scheme below is one instance of them.
Four points to take away from the section as a whole:
1. Aligning the exception type hierarchy with the module structure pays dividends that ad hoc boundary catches do not. A package whose error-exception type tree mirrors its module decomposition gets selective inner catches, per-module recovery and clean propagation almost for free; a package whose exception type tree ignores the module decomposition has to reimplement the same effects by hand at every boundary that needs them.
2. Modularizing the project — independently from any error-handling concern — also produces an infrastructure for better error handling. Once the package has a clear module decomposition, attaching per-module exception-type roots is a small step, and it makes individual modules more self-contained and individually testable than they would be if errors crossed module boundaries opaquely.
3. Error handling should be considered part of a module’s contract. Just as a function’s return type is part of its signature, the set of exception types a module raises (and the set it catches and handles internally) is part of the module’s specification. A consumer of the module needs to know both, and migration is the natural moment to write them down.
4. The specific scheme described in detail below is useful both on its own and as a blueprint. It is a runnable recipe — apply it as is to a new package or read it as a worked example of the general principles above and adapt it to your project’s existing layout.
The Pattern
The recommended shape is a single package-wide root with the catch-relevant subtrees as its direct children.
MyPackageException (the package root)
/ | \
directError fstModuleRoot sndModuleRoot (siblings under the root)
| | |
(descendants) (descendants) (descendants)
- Module roots (
,
, …). Each module owns its own root and registers all of its module-local error types as descendants of it. The module’s inner boundary catches that root, so it intercepts everything that module raised internally — and only that. Errors raised in other modules pass through unaffected because their types are not descendants of this module’s root.
- Direct-to-package errors (
and its descendants). A sibling subtree dedicated to errors that an inner function raises but does not want any module catch to handle. Because
is a sibling of every module root (not a descendant of any), the module catches do not match it. It propagates through the module boundaries and reaches the package-level boundary catch.
- The package root itself (
). The public-API boundary catches the package root, which is an ancestor of every error exception type the package raises. Errors of any module-root exception type or
exception type that reach the boundary (because no inner catch handled them) are intercepted there and translated to user-facing diagnostics.
Why This Works in the Exceptions Framework
CatchExceptions[ChildTypehandler] @ body only intercepts exceptions whose type is
or a registered descendant; an exception of an unrelated type is auto-rethrown to the next outer catch. So:
- An inner CatchExceptions[fstModuleRootrecover] @ ... catches its own module’s local errors and lets every other type — including descendants of
, descendants of
and any direct
payloads — pass through.
- The package’s outer CatchExceptions[MyPackageExceptiontranslate] @ ... catches everything that was not handled by an inner catch, because every package-raised type is a descendant of
.
This is, technically, a form of nested propagation — but the rethrow plumbing that ad hoc mini-frameworks based on Catch need to write by hand is built in. That is what makes the modular pattern practical in the Exceptions framework where it is awkward without it.
An Illustration
This example illustrates the scheme described above. It models some computation that uses cache, and where cache access may produce a normally fetched result, a cache miss (error from which the system recovers locally) and in some cases, a critical error that propagates to the project’s boundary.
ClearAll[publicAPI, recoverFromCacheError, implementation, cacheLookupOrFetch]
(* Package root. *)
RegisterExceptionType[MyPackageException];
(* Direct-to-package subtree — errors that bypass every module catch. *)
RegisterExceptionType[directError, MyPackageException];
RegisterExceptionType[criticalInvariantError, directError];
(* First module's subtree — caught locally by the cache module. *)
RegisterExceptionType[fstModuleRoot, MyPackageException];
RegisterExceptionType[cacheMissError, fstModuleRoot];
RegisterExceptionType[cacheStaleError, fstModuleRoot];
(* Second module's subtree — caught locally by the parser module. *)
RegisterExceptionType[sndModuleRoot, MyPackageException];
RegisterExceptionType[parserSyntaxError, sndModuleRoot];
(* The cache module's inner boundary. *)
computeWithCache[k_] := CatchExceptions[
fstModuleRoot -> recoverFromCacheError[k]
] @ cacheLookupOrFetch[k]
(* The package's outer boundary. *)
publicAPI[args___] := CatchExceptions[MyPackageException] @ implementation[args]
(* ⇒ "fallback for alpha" (handled inside the cache module, never reaches publicAPI) *)
recoverFromCacheError[k_] := Function[data, "fallback for " <> k];
implementation[k_] := computeWithCache[k];
cacheLookupOrFetch[k_] := Which[
k === "alpha",
ThrowException[cacheMissError, <|"Key" -> k|>],
k === "beta",
ThrowException[criticalInvariantError, <|"Key" -> k|>],
True,
"cache-fetched normal result"
]
The cache module’s inner catch deals with cache-local errors and nothing else. Errors of
-rooted type traverse the cache boundary unchanged because they are siblings, not descendants, of
. This is the same selectivity property you would get from completely separate roots — the common ancestor
does not affect what the inner catch sees.
publicAPI["gamma"]publicAPI["alpha"]publicAPI["beta"]Errors That Bypass Module Catches
The
subtree above is the canonical place for errors that should always reach the package boundary regardless of which module raised them — internal-invariant violations, out-of-memory conditions, configuration problems detected mid-computation, anything where local recovery would mask a real bug. The inner function raises a
-rooted type instead of a module-rooted one; the module boundary lets it through; the package boundary handles it (or, if it should also bypass the package boundary, see the next paragraph).
Note that the
-based approach suffers somewhat from the same architectural problem as early-bound single-layer architectures, since it puts some internal function in a position to decide which errors should or should not be captured at the module's boundary, potentially much higher up the execution stack. An alternative and more systematic method would be to catch all internal errors at the module's boundary and then possibly rethrow some of them to reach the package's boundary. Still, the
-based approach is often convenient. It will always be your judgement call for a particular use case, and there is no universally correct answer here.
If you want errors that bypass even the package boundary — for an outermost application-level catch to handle — register them outside the
subtree entirely and have the application-level catch use CatchExceptions[All] or a separate root. This is rarely needed; most "critical" errors should still surface as user-facing diagnostics, which is what the package boundary does. Note b.t.w. that using All in CatchExceptions is generally an anti-pattern that should be avoided. The right catchall type to use in the vast majority of cases is the root exception type of your package.
A Separate-Roots Variant
The common-root shape above is the recommended default because it gives you a single subtree to introspect, query or attach package-wide tooling to. A valid alternative is to register the catch-relevant subtrees as completely separate roots, with no shared ancestor.
(* Pseudocode *)
(* Project-level exception types. *)
RegisterExceptionType[MyProjectException]
(* Module-private exception types, NOT under MyProjectException. *)
RegisterExceptionType[MyCacheException]
RegisterExceptionType[MyCacheMissError, MyCacheException]
RegisterExceptionType[MyCacheStaleError, MyCacheException]
publicAPI[args___] := CatchExceptions[MyProjectException] @ implementation[args]
The selective-catching behavior is identical to the common-root form: what matters is that the catch-relevant subtrees are pairwise disjoint (no descendant relationship among them), not whether they share an ancestor. The separate-roots form is occasionally convenient for subsystems that started out as independent packages and were merged later — the original roots can stay where they are and need not be reparented under a common ancestor.
Three Rules of Thumb
1. Module root = scope of the module’s own catches. Each module that wants to handle some of its errors itself — whether to recover, retry, fall back, log or re-emit — gets its own root, registered as a child of the package root. The module’s inner boundary catches that root and only that root. All module-local error types are descendants.
2. Direct-to-package root = bypass module catches. Errors that should escape every module catch and reach the package boundary live under a
-style sibling subtree under the package root. Module catches do not see them; the package boundary does. Whether and how much to use this method are, of course, up to you and should be decided on a case-by-case basis.
3. Package root = boundary catch. The public-API boundary catches the package root itself, which is an ancestor of everything the package raises. Anything a module catch did not handle lands here.
The catch-relevant subtrees (each module root and the direct-to-package root) must be pairwise disjoint relative to one another. They can all be siblings under a single package root (recommended), or they can be completely separate roots (the variant above). The boundary catch’s type — package root in the recommended shape, an explicit project-level root in the variant — sees everything that lives below it and is auto-rethrowing anything that does not.
Mixed Designs Are Common
Subsystems inside a single package frequently sit at different values on AX1 (layering) and AX2 (error identity/payload shape/selective catching). A package may have a single-layer subsystem (Recipe O2 in Section 6 (Migration recipes)), a two-layer subsystem (Recipe O4 in Section 6 (Migration recipes)) and a third subsystem catching for local recovery (this section’s pattern). Each subsystem applies its own recipe; the recipes do not interfere as long as each subsystem owns its own module root. The migration strategies can be applied also per subsystem, not necessarily only per package.
Common Mistakes
These are some of the common mistakes one can make when adopting this kind of nested exception types architectures:
- Registering module-local exception types as descendants of the exception type the module’s own inner boundary catches and of the exception type the package boundary catches.
- This happens when you put module-local exception types under the package root and the module catch uses the package root as its catch type.
- Forgetting the direct-to-package subtree. Without it, every error a module raises goes through the module’s local handler — including invariant violations and other errors local recovery should not mask. Add a
-style sibling under the package root so inner code has a way to escape module handling when it needs to. Note that this is only relevant if you decide to use the direct-to-package exceptions in your project.
5. Helpers Shared by Every Recipe
The short helpers described below are common to most existing projects regardless of axis position, in one form or another. They are not part of any specific recipe but are useful to every recipe, so they are gathered here once. Each definition is self-contained: the only precondition is the exception-type-root registration from Section 2.
The applicability of these helpers to your particular case depends on the position of your project in the project axes taxonomy described in Section 3. The helpers CC.1 and CC.2 make more sense for single-layer projects with direct error propagation (axes AX1 and AX3), whereas CC.3 is universally applicable. Like in all recipes in this tutorial, these helpers should be considered blueprints and common patterns, rather than direct copy and paste material.
CC.1—Throw a Failure Straight Through
Many projects use some form of (re)throwing of an already built Failure[]. The helper can be called e.g.
:
ClearAll[checkFailure, failureHandler, packageHandler]
checkFailure[f_?FailureQ] := ThrowException[pkgException, <|"PrebuiltFailure" -> f|>]
checkFailure[expr_] := expr
failureHandler = Function[data,
If[KeyExistsQ[data, "PrebuiltFailure"],
data["PrebuiltFailure"],
Exception[data]["ExceptionFailure"]]
];
packageHandler = CatchExceptions[pkgException -> failureHandler];
The boundary catch checks for
first and returns it unmodified.
Why a custom helper rather than Confirm? Confirm[expr,info,pkgException] could, in principle, stand in for
here, but here are a few reasons why in some cases a custom helper may be more convenient:
- The
key is doing specific work: it is the discriminator the layer-1 boundary handler uses to recognize "return this Failure[] verbatim" and skip the normal rebuild step.
- The payload for Confirm, on the other hand, encodes the source value under "Expression" and adds a
field.
- The trigger set for Confirm is broader — it fires on Failure[], $Failed, Missing[] and a few others — which is sometimes what you want and sometimes too loose.
- Shoehorning that into a verbatim-passthrough path would mean special-casing the Failure[] subcase inside the handler. A small custom helper keyed on its own discriminator is clearer.
- If you settle on "Expression" as your universal discriminator instead and accept the broader trigger set of Confirm, Confirm can also do the job.
In the example of use below, the Failure[] provided via "PrebuiltFailure" key is returned directly, whereas all other exceptions are converted to the standard Failure object by the Exceptions framework:
packageHandler @ checkFailure @ 42packageHandler @ checkFailure @ Failure["myTag", <|"reason" -> "demo"|>]packageHandler @ ThrowException[pkgException, 42]This is the helper that makes early binding (AX4) manageable in single-layer designs that legitimately need to construct a Failure[] at the throw site. Use Confirm when its broader trigger set is exactly what you want and you are willing to commit to "Expression" as your universal discriminator at the catch site. Otherwise, the lightweight
shown above is usually clearer.
CC.2—Propagate Message as Exception
This helper allows you to directly convert a Message you want to issue to the equivalent propagating exception, thereby delaying message issuing until the exception is caught and also returning a Failure (in this case, standard one created by the Exceptions framework). It allows you to supply optional additional data to form that resulting Failure, via its third argument.
ClearAll[throwMessage, fail, failWithMessage]
SetAttributes[throwMessage, HoldFirst]
throwMessage[m_MessageName, params_List, extra : _Association?AssociationQ : <||>] :=
ThrowException[
pkgException,
<|"MessageTemplate" :> m, "MessageParameters" -> params, extra|>
]
fail[data_Association?AssociationQ] := Exception[data]["ExceptionFailure"]
failWithMessage = Replace[{
data: KeyValuePattern[{"MessageTemplate" :> tmpl_}] :> (
Message[tmpl, Sequence @@ Lookup[data, "MessageParameters", {}]];
fail @ data
),
data_Association?AssociationQ :> fail @ data
}];
The throw helper has the HoldFirst attribute, which keeps the MessageName[…] argument unevaluated; storing it under a RuleDelayed (
) key in the payload preserves that held form all the way to the handler, where it can be passed to Message directly:
MyOp::bad = "Bad input `1`.";
CatchExceptions[pkgException -> failWithMessage] @ throwMessage[MyOp::bad, {5}]CatchExceptions[pkgException -> failWithMessage] @
throwMessage[MyOp::bad, {5}, <|"ExceptionFailureTag" -> "CustomErrorTag"|>]Recipe O4’s
(Section 6) follows a related shape: emit the Message[], then return a Failure[]. Returning a Failure[] rather than $Failed keeps the public function’s return value structurally informative — the same convention every other recipe in this tutorial follows.
The single
key shown here is the early-bound form: the public function’s head is known at throw time and is baked into the MessageName[head,tag] template. There is also a late-bound form that stores only
and has the boundary catch supply the head — used for example by Recipe O2’s late-bound dispatch.
is not a framework-recognized key by itself; it is a per-recipe payload convention and is explained in detail in Recipe O2 (Section 6).
Another variant of this helper, which can be encountered in some projects but which is not the recommended one, wraps the entire Message[] call in a Hold[…].
ClearAll[throwHeldMessage, emitHeldMessageAndFail]
SetAttributes[throwHeldMessage, HoldFirst]
throwHeldMessage[msg_MessageName, args___] := ThrowException[
pkgException,
<|"HeldMessage" -> Hold[Message[msg, args]]|>
]
emitHeldMessageAndFail[data_Association] := (
ReleaseHold[data["HeldMessage"]];
Exception[KeyDrop[data, "HeldMessage"]]["ExceptionFailure"]
)
It is functionally equivalent for the single-message case, but it is inferior on two counts:
- First, it bakes one specific emission expression into the throw site, and the boundary handler must know about the
key.
- Second, the resulting Failure[] does not contain the canonical
and
fields — those are buried inside the held Hold[Message[…]] form, where downstream consumers cannot inspect them in the standard way.
Existing
-based code can be left as-is; new code should prefer the
form above, because it composes well with Failure objects. See Section 7 ("Held arguments inside an Association payload") for why
matters on the payload entries.
CC.3—Auto-Defined Fallthrough Rule
A common pattern: install a
fallthrough that throws a "badargs" failure when no rule matches.
ClearAll[catchallError]
catchallError[head_Symbol, errorTag_String : "badargs"] := (
head[args___] := ThrowException[pkgException,
<|"InternalTag" -> errorTag,
"Function" -> head,
"Args" :> {args}|>]
)
mySumOfTwo[a_, b_] := a + b;
catchallError[mySumOfTwo];CatchExceptions[pkgException] @ mySumOfTwo[1, 2, 3]Composes naturally with O4 (which already keys off
) and with the
-keyed variant used in O2.
6. Migration Recipes
Introduction
The part of the migration process described here generally only covers the first step (some recipes go a little further than that though), which is creating drop-in replacements, based on the Exceptions framework, for your custom exception-handling primitives. It is quite likely that you will get vastly larger benefits from leveraging the Exceptions framework more, but that would likely require more significant changes in your code. Some of those aspects have been described in Section 2 (designing the exception type hierarchy) and Section 4 (modular architectures and selective catching).
Still, the first step described here is the most important one, and the transition to deeper use of the Exceptions framework in your code base can happen gradually. As a practical rule, register a flat or shallow exception type tree at the start and add intermediate parent types only when an actual consumer needs to catch by an ancestor type — there is no benefit to introducing intermediate parents speculatively, and adding them later is monotonic (it does not invalidate existing catch sites that name the leaves directly).
Three improvements come for free with the migration but require deliberate adoption:
- Hierarchy design (Section 2). Plan the tree; refine lazily.
- Modular selective catching (Section 4). Layers on top of any recipe. The Exceptions framework’s structural improvement over single-boundary catch.
Recipe Selection
The recipe for the drop-in replacement is determined almost entirely by the package’s position on AX1, AX2 (the selective-catching sub-dimension) and AX4:
- Zero-depth (no Throw at all anywhere in the package; every error travels as a return value) → Recipe O1 as a bootstrap step, then one of O2/O3/O4 per migrated chain.
- Single-layer with non-selective catching → Recipe O2.
- Single-layer with selective catching by payload-test → Recipe O3.
The remaining axes affect peripheral aspects.
Mixed packages — those whose subsystems sit at different AX1/AX2 values — apply more than one recipe. The throw helpers and boundary catches do not interfere with each other as long as each subsystem owns its own exception-type root (see Section 4).
Recipe O1—Bootstrap from Return-Value Propagation to Throws
Axis position: AX3 = zero-depth (the package uses no Throw for error propagation).
In brief: this recipe is for projects that do not use any form of exception propagation at all. It shows how to incrementally upgrade the project to start using the Exceptions framework.
Notes
Applicable to packages whose internal error propagation today is purely return value based — every internal function returns $Failed, a Failure[], a Missing[] or some similar sentinel; every caller If-checks the return and either propagates it upward, transforms it or recovers locally. The goal of this recipe is not to wrap every helper in ThrowException. It is to identify the natural throw points in each call chain and convert each chain into a much shorter "throw at the natural point, catch at the public boundary" form.
The core analysis was given in Section 3 (AX3, Zero-depth). This recipe applies it to a small but fully runnable worked example with three independent call chains and shows the resulting code at every transformation step. The intermediate forms (legacy, partially migrated, fully migrated) all run on the same sample inputs.
The Example Domain—A
-Style Configuration Loader
The example below is a tiny configuration loader. It accepts a
-style text body, parses it into an association of dotted keys, extracts two required sections (
and
) and runs a small computation that uses two of the parsed values. It does not use any kind of exception to propagate its errors but rather uses a traditional return value analysis.
Three independent return-value error chains converge at the public boundary:
- Chain A — line-by-line parsing.
validates a single token (a key or a value);
parses one
line;
aggregates the lines into a flat association. Comments (
) and blank lines are skipped.
- Chain B — section extraction.
looks up a single key;
collects every key whose dotted prefix matches a section name;
requires that both
and
sections exist.
- Chain C — computation.
returns $Failed on division by zero;
extracts
/
from the loaded config and computes the quotient.
The Legacy Package (Step 0—Return-Value Propagation Throughout)
ClearAll[
parseToken, parseLine, parseInput, readKey, readSection, ensureSection,
loadConfig, divide, runStep, publicAPI
]
(* Chain A — input parsing. *)
parseToken[s_String] := Replace[
StringTrim[s],
{
"" :> $Failed,
t_String /; !StringFreeQ[t, Whitespace] :> $Failed
}
]
parseLine[ln_String] := Module[{trim = StringTrim[ln], parts, r},
Which[
trim === "" || StringStartsQ[trim, "#"],
Nothing,
parts = StringSplit[trim, "=", 2];
Length[parts] =!= 2
,
Failure["parseLine", <|"Line" -> ln, "Reason" -> "no '='"|>],
r = parseToken /@ parts;
MemberQ[r, $Failed]
,
Failure["parseLine",<|"Line" -> ln, "Bad" -> Position[r, $Failed]|>],
True,
r
]
]
parseInput[text_String] :=
With[
{pairs = parseLine /@ StringSplit[text, "\n"]},
If[AnyTrue[pairs, FailureQ],
FirstCase[pairs, _?FailureQ],
Association[Rule @@@ DeleteCases[pairs, Nothing] ]
]
]
(* Chain B — section extraction. *)
readKey[cfg_Association, k_String] := Lookup[cfg, k, Missing["NotFound"]]
readSection[cfg_Association, s_String] := With[
{keys = Select[Keys[cfg], StringStartsQ[#, s <> "."] &]},
If[keys === {}, Missing["NotFound"], KeyTake[cfg, keys]]
]
ensureSection[cfg_Association, s_String] :=
With[{r = readSection[cfg, s]},
If[MissingQ[r], Failure["config", <|"Missing" -> s|>], r]
]
loadConfig[cfg_Association] := With[
{a = ensureSection[cfg, "general"], b = ensureSection[cfg, "advanced"]},
If[FailureQ[a],
a,
If[FailureQ[b], b, <|"general" -> a, "advanced" -> b|>]
]
]
(* Chain C — computation. *)
divide[_, 0] := $Failed
divide[x_, y_] := x / y
runStep[conf_Association] := With[
{x = FromDigits @ conf["advanced", "advanced.dividend"],
y = FromDigits @ conf["advanced", "advanced.divisor"]},
With[{r = divide[x, y]},
If[r === $Failed, Failure["divzero", <|"x" -> x|>], r]]]
(* Public entry — three cascading FailureQ checks. *)
publicAPI[text_String] := Module[{parsed = parseInput[text], conf, result},
Which[
FailureQ[parsed],
parsed,
conf = loadConfig[parsed];
FailureQ[conf],
conf,
result = runStep[conf];
FailureQ[result],
result,
True,
<|"data" -> parsed, "cfg" -> conf, "out" -> result|>
]
]
A representative input and three deliberately broken variants (one per chain) drive every running example below.
sampleConfig = "# server config
general.host = localhost
general.port = 8080
advanced.dividend = 100
advanced.divisor = 4
";
badParse = "general.host = localhost\nthis line has no equals\n";
badConfig = "general.host = localhost\n"; (* "advanced" section missing *)
badDivide = "general.host = h\nadvanced.dividend = 1\nadvanced.divisor = 0\n";
Here are some examples of use:
publicAPI[sampleConfig]["out"]publicAPI[badParse]publicAPI[badConfig]publicAPI[badDivide]Three independent error chains, each accumulating context as it bubbles up, all funneled into a Which boundary. Migration now proceeds chain by chain.
Step 1—Design of the Exception Type Hierarchy
In this case, this step is straightforward: there is a package root exception type and three subtypes per three call chains.
RegisterExceptionType[pkgException];
RegisterExceptionType[parseError, pkgException];
RegisterExceptionType[configError, pkgException];
RegisterExceptionType[computeError, pkgException];
Step 2—Pick the Natural Throw Point in Each Chain
Apply the AX3 questions (Section 3):
- Chain A.
returns a bare $Failed;
adds the structured context ("Line", "
, "
);
passes the first failure through. The natural throw point is
, where the structured Failure[] first appears.
- Chain B.
returns Missing[];
returns Missing[] for an absent section;
converts that to a structured Failure[];
passes through. Natural throw point:
.
- Chain C.
returns $Failed,
adds context. Natural throw point:
.
Step 3—Convert Each Natural Throw Point
Replace the If returning Failure[] in each natural-throw-point function with a ThrowException. Leave the deeper layer (
,
,
) untouched — it still returns its sentinel; it just no longer participates in the propagation chain visibly.
After this step, the natural-throw-point functions throw, but the public API still uses cascading Ifs — so
is temporarily inconsistent: it would let throws escape unhandled. That is fixed in Step 3. To keep the intermediate snapshot runnable, wrap
as-is in a single broad catch.
ClearAll[parseLine, parseInput, ensureSection, loadConfig, divide, runStep]
parseLine[ln_String] := Module[{trim = StringTrim[ln], parts, r},
Which[
trim === "" || StringStartsQ[trim, "#"],
Nothing,
parts = StringSplit[trim, "=", 2];
Length[parts] =!= 2
,
ThrowException[parseError, <|"Line" -> ln, "Reason" -> "no '='"|>],
r = parseToken /@ parts;
MemberQ[r, $Failed]
,
ThrowException[
parseError,
<|"Line" -> ln, "Bad" -> Position[r, $Failed]|>
],
True,
r
]
]
parseInput[text_String] := Composition[
Association,
MapApply[Rule],
DeleteCases[Nothing],
Map[parseLine]
][StringSplit[text, "\n"]]
ensureSection[cfg_Association, s_String] :=
With[{r = readSection[cfg, s]},
If[MissingQ[r], ThrowException[configError, <|"Missing" -> s|>], r]
]
loadConfig[cfg_Association] :=
<|
"general" -> ensureSection[cfg, "general"],
"advanced" -> ensureSection[cfg, "advanced"]
|>
(* Chain C — computation. *)
divide[_, 0] := $Failed
divide[x_, y_] := x / y
runStep[conf_Association] := With[
{x = FromDigits @ conf["advanced", "advanced.dividend"],
y = FromDigits @ conf["advanced", "advanced.divisor"]},
With[{r = divide[x, y]},
If[r === $Failed, ThrowException[computeError, <|"x" -> x|>], r]]]
The same four inputs now exercise the throw-and-catch path on the failing chains. Since the catching part is not yet in place, errors manifest themselves as uncaught exceptions.
publicAPI[sampleConfig]["out"]publicAPI[badParse]publicAPI[badConfig]publicAPI[badDivide]Step 4—Collapse the Public Boundary
The checks for Failure in Which inside
are now dead code — none of
,
or
returns a Failure[] any more. The single CatchExceptions is the entire boundary
ClearAll[translateForUser, publicAPI]
translateForUser[data_Association] :=
Exception[Append[data, "ExceptionFailureTag" -> "MyPackage"]]["ExceptionFailure"];
(* Public entry — three cascading FailureQ checks. *)
publicAPI[text_String] := CatchExceptions[pkgException -> translateForUser] @ With[
{parsed = parseInput[text]},
{conf = loadConfig[parsed]},
<|"data" -> parsed, "cfg" -> conf, "out" -> runStep[conf]|>
]
where the custom handler is used to change the user-facing Failure tag to a custom one: "MyPackage".
Rerun the four inputs — identical results to Step 2 above. The public-API body has shrunk from a three-deep If cascade to a single nested With, and the only error-handling construct in the entire file is the boundary CatchExceptions.
publicAPI[sampleConfig]["out"]publicAPI[badParse]publicAPI[badConfig]publicAPI[badDivide]Step 5—Verify the Single-Error-Mode Invariant
Audit the converted layers —
,
,
— to ensure each one now has one error mode (throwing) and never returns a Failure[] from any code path. The deeper layers (
,
,
) still have one error mode (return-by-sentinel) and never throw. Every layer in the migrated package now belongs to exactly one channel; no caller of
has to handle both a thrown
and a returned Failure["parseLine",…].
Once the bootstrap is done, each migrated chain sits at AX3 = direct propagation (one hop from throw to boundary catch) and can adopt one of the recipes O2/O3/O4 below depending on the rest of its axis position. The boundary catch’s translation handler (
) is the obvious place to apply Recipe O4’s translation registry if the package has many error tags; for a small handful, the inline form above is enough.
- The deeper layers stayed put.
,
and
still return sentinels. Converting them to throw would add no information (the natural throw point upstream already added all the context worth adding) and would force every of their other callers — including the test suite — to add a CatchExceptions wrapper.
- Recovery sites still work. If some other part of the package recovers locally from a
, that recovery becomes a CatchExceptions[parseErrorrecover] @ … wrapper at the recovery site instead of an If[FailureQ[…],recover[…],…] check. The selective-catch behavior is exactly what was previously the explicit check for If/Which. For example, falling back to defaults when the
section is missing.
ClearAll[loadOrDefault]
loadOrDefault[text_String] := CatchExceptions[
configError -> Function[
data,
<|
"general" -> <|"general.host" -> "localhost"|>,
"advanced" -> <|"advanced.dividend" -> "0",
"advanced.divisor" -> "1"|>
|>
]
] @ loadConfig[parseInput[text]]
loadOrDefault[badConfig]- Single error mode per function is the discipline. Resist the temptation to leave
returning a Failure[] "for backward compatibility" while also throwing for new callers. Pick one channel and migrate every caller in the same pass.
Recipe O2—Single-Layer with Late-Bound Message Dispatch
Axis position: AX1 = single-layer · AX2 = non-selective · AX4 = explicit or late.
In brief: This recipe shows typical error-handling primitives for single-layer non-selective propagation of exceptions, implemented on top of the Exceptions framework. If your project belongs to this category, you may find the code of this recipe useful for your migration process. This recipe does not go beyond the drop-in replacements implementation for your error handling primitives. You can use other recipes for examples of such further steps, if you plan them.
Notes
Applicable to single-layer packages (AX1) using a single shared throw tag with non-selective catching (AX2) and either explicit or late head binding (AX4). The throw site captures only a message tag name (a string) plus message arguments, optionally also a pre-built Failure[] or other payload data; the catch site combines whatever was captured with the public function’s head to produce
and the user-facing return value.
A drop-in replacement for the typical custom helper pair
/
can be very short. But the form below — slightly longer — is recommended because it accommodates the full range of throw-side variants that single-layer packages encounter in practice (raise an error by tag and arguments, raise a pre-built Failure[], attach a custom Failure tag, attach arbitrary extra payload, supply the message head eagerly or leave it for the boundary catch to fill in), and because it routes every variant through one boundary catch with one consistent return contract.
Example Mini-Framework
The code below represents one version of a flexible mini-framework built on top of the Exceptions framework to serve most common needs for the projects belonging to this category (single-layer, non-selective catches, head binding explicit or late). If your project falls into that category, you will likely need only some part of what is here. Like other examples in this tutorial, consider it as a blueprint or source of ideas, rather than direct material to copy and paste.
ClearAll[
throwError, throwFailure, catchError, withMessage, msgNormalize,
$msgNamePattern, $msgPattern
]
(* === Message specification — what callers may pass to throwError/throwFailure. === *)
$msgNamePattern = _String | {_Symbol, _String};
$msgPattern = $msgNamePattern | (Rule | RuleDelayed)[$msgNamePattern, _];
(* Tag-only — head is supplied by the boundary catch (late binding). *)
msgNormalize[name_String] := <|"MessageTagName" -> name|>
(* Head + tag — message template is bound at throw time (early binding). *)
msgNormalize[{head_Symbol, name_String}] :=
<|"MessageTemplate" :> MessageName[head, name]|>
(* Rule/RuleDelayed — adds message parameters. *)
msgNormalize[(rh : Rule | RuleDelayed)[msg : $msgNamePattern, params_List]] :=
Append[msgNormalize[msg], <|rh["MessageParameters", params]|>]
(* Single parameter — promote to a one-element list. *)
msgNormalize[(rh : Rule | RuleDelayed)[msg_, param_]] :=
msgNormalize[rh[msg, {param}]]
(* === Throw helpers. === *)
throwError[data___Association?AssociationQ] :=
ThrowException[pkgException, Join @@ {data}]
throwError[m : $msgPattern, data___] := throwError[data, msgNormalize[m]]
throwError[payload_, data___] := throwError[data, <|"ExceptionPayload" -> payload|>]
throwFailure[m : $msgPattern, f_Failure, data___] := throwFailure[f, m, data]
throwFailure[Failure[t_, d_], m : $msgPattern, data___] :=
throwFailure[Failure[t, Append[d, msgNormalize[m]]], data]
throwFailure[Failure[t_, d_], data__Association?AssociationQ] :=
throwFailure @ Failure[t, Join[d, data]]
throwFailure[f_Failure] := throwError[<|"PrebuiltFailure" -> f|>]
(* === Message-emission helper. === *)
(* Reads message keys from an Association or Failure[] payload, emits the *)
(* corresponding Message[] using head as the public-function head when *)
(* the throw site supplied only the tag, and returns the original input. *)
withMessage[head_Symbol][a : _Association?AssociationQ] :=
withMessage[head][a, Identity]
withMessage[head_Symbol][f : Failure[_, _]] := withMessage[head][f, Last]
withMessage[head_Symbol][expr_] := expr
withMessage[head_Symbol][expr_, dataGetter_] := Replace[
dataGetter @ expr,
{
data : KeyValuePattern[{"MessageTemplate" :> m_MessageName}] :> (
Message[m, Sequence @@ Lookup[data, "MessageParameters", {}]];
expr),
data : KeyValuePattern[{"MessageTagName" -> tn_String}] :> (
Message[MessageName[head, tn],
Sequence @@ Lookup[data, "MessageParameters", {}]];
expr),
_ :> expr
}]
(* === Boundary catch. Operator-form only — call as catchError[head] @ body. === *)
catchError[head_Symbol] := CatchExceptions[
pkgException -> Replace[{
KeyValuePattern[{"PrebuiltFailure" -> f_}] :> withMessage[head] @ f,
data_ :> Exception[withMessage[head][data]]["ExceptionFailure"]
}]
]
How It Works/Examples of Use (Individual Functions)
Here is how the framework works/fits together:
accepts either a tag string (late binding —
will be the public function’s head, supplied by the catch) or a
pair (early binding — head bound at the throw site), optionally combined with Rule or RuleDelayed to attach message parameters. Every accepted form ends up as a small association with one or both of
(early) and
(late), plus
if any.
msgNormalize["badarg"]msgNormalize[{func, "badarg"}]msgNormalize["badarg" -> {1, 2}]msgNormalize["badarg" -> 7]msgNormalize[{func, "badarg"} :> {Now}]
is the general-purpose throw. Successive arguments fold into one association: a message-spec is normalized by
and merged in; a pre-built Failure[] or other value goes into
; an explicit association is merged verbatim. The result is a single ThrowException[pkgException,…] call.
throwError[<|"a" -> 1|>, <|"b" -> 2|>]throwError["badarg" -> 7]throwError["badarg" -> 7, <|"Site" -> "leaf"|>]throwError[Range[3]]
specializes the case where the throw site has a fully built Failure[] it wants to surface verbatim. The Failure[] ends up under the
key (the same discriminator CC.1); message-spec arguments and extra payload are merged into the Failure[]’s data so the boundary catch can still emit a message and the eventual returned Failure[] carries the combined information.
preBuilt = Failure["CustomTag", <|"Data" -> 42|>]throwFailure[preBuilt]throwFailure["badarg" -> 1, preBuilt]throwFailure["badarg" -> 1, preBuilt, <|"Site" -> "leaf"|>]
is the message-emission step. It reads either form of message key from an association or from Failure[] data, emits the message — using the supplied
if only the tag was captured or the embedded MessageName[…] if the head was bound early — and returns its input unchanged. It is a no-op on inputs that contain no message keys.
ClearAll[func]
func::badarg = "Invalid argument `` received";withMessage[func]@<|"MessageTagName" -> "badarg", "MessageParameters" -> {99}|>withMessage[func]@Failure["X", <|"MessageTemplate" :> func::badarg, "MessageParameters" -> {1}|>]withMessage[func] @ 42
withMessage[func] @ <|"a" -> 1|>
is the boundary catch. It uses the operator form of Replace to dispatch on the payload: if the payload carries a
, it returns that Failure[] (after running
); otherwise, it constructs the standard Exception[data]["ExceptionFailure"] Failure[], again after
. There is no separate
form — the operator form composes naturally with
, and the held
argument is taken care of by CatchExceptions’s own evaluation control.
catchError[func]@(1 + 2)catchError[func]@throwFailure[Failure["CustomTag", <|"Data" -> 42|>]]catchError[func]@throwError[Failure["CustomTag", <|"Data" -> 42|>]]catchError[func]@throwError["badarg" -> 1, <|"Data" -> 42|>]catchError[func]@throwError["badarg" -> 1, <|"Data" -> 42, "ExceptionFailureTag" -> "CustomTag"|>]Closing Notes
Migration from a typical existing
/
pair: the names match, only the call shapes change slightly (
is the operator form; if the existing code uses the prefix
form, add a thin wrapper catchFailure[head_Symbol, body_] := catchError[head] @ body with an appropriate HoldRest attribute — see Section 7).
As mentioned in the introduction for this section, the code/functions here do not represent the only or the definitive way but rather a representative variation to illustrate typical approaches used by projects of the types matching this recipe, and how one can convert those primitives to use the Exceptions framework effectively. These functions also do not suggest the final form of your error handling code but rather a first step to implement drop-in replacements for your existing error handling primitives. Whether or not you plan or need other steps depends entirely on your more global plans for the error handling in your project (your project's position on the AX7 axis).
Recipe O3—Single-Layer with Selective Catching by Payload-Test
Axis position: AX1 = single-layer · AX2 = selective by payload-test · AX4 = any.
In brief: this recipe shows how to implement exception catching/dispatch based on the arbitrary pattern-matching against the thrown payload shape, on top of the Exceptions framework. If your project does use pattern-matching against the payload you throw/propagate with your error-handling primitives, to catch your exceptions, then this recipe can be useful to migrate to a similar setup based on the Exceptions framework. It also illustrates how to migrate from the payload-based dispatch to exception-type-based dispatch, which is arguably a more architecturally robust solution.
Notes
Applicable to single-layer packages whose discrimination locus (AX2) is selective catching by payload-test rather than non-selective catching. All errors share a single throw tag in the original code; the kind of error lives in a discriminator field inside the payload, and catching is done by pattern-matching that field with auto-rethrow on mismatch.
The Eventual Goal — And Why
The end state for such a package is to dispatch errors by registered exception type rather than by payload pattern. Exception type-based dispatch is the Exceptions framework’s native model and brings several advantages that payload-pattern dispatch does not:
- Static checkability. A registered exception type is a symbol. Typos at the catch site are caught at registration time (an unregistered exception type is a clear error). Payload patterns are arbitrary expressions; a catch site that misspells a discriminator key silently fails to match and the error escapes.
- Auto-rethrow on miss. CatchExceptions[Typehandler] automatically rethrows whatever does not match. With payload-pattern dispatch, the catch site has to write the Throw[data,t] rebound by hand and remember to do it on every miss path.
- Hierarchical catches. Promoting a small group of related types to a common parent and catching the parent is one line; the equivalent "match an Alternatives[] of payload patterns" gets unwieldy as the group grows.
- Better tooling and introspection. Types are first-class symbols; the runtime can report which types are registered, attach properties to them (Section 10) and recognize exception-type-keyed catches without scanning pattern bodies.
Step 1—The Transitional Plan
A real codebase rarely converts every call site in one pass. The recommended sequence is:
1. Register a small flat exception type tree alongside the existing payload-test scheme — one registered exception type per current discriminator value. Register them under a common package root.
2. Drop in a compatibility wrapper (
below) that replays the package’s existing payload-pattern dispatch on top of the framework, so existing call sites keep working unchanged while the migration is in progress.
3. Migrate call sites incrementally — at each touched call site, replace the payload-pattern catch with a registered-exception-type catch and replace the corresponding throw with
.
4. Remove the wrapper when the last payload-pattern catch is gone.
This recipe covers steps 1, 2 and the final state reached at step 3. Step 4 is a one-line deletion when the time comes.
Step 2—The Compatibility Wrapper
The original semantics is payload-pattern dispatch (the catch site matches a pattern against the thrown payload and rethrows on miss), which has no one-line equivalent in CatchExceptions — since the latter dispatches by registered exception type, not by payload pattern. The wrapper below reimplements payload-pattern dispatch on top of the framework, with manual rethrow on miss.
It is named
(a rename from your existing
) to signal that it now lives on top of registered-exception type dispatch even though it still accepts a payload pattern. Accept a single rule or a list of rules so that several patterns can be tried in order. The implementation below assumes operator form/prefix use (which allows the code to be quite concise).
customCatchTyped[rule_RuleDelayed] := customCatchTyped[{rule}]
customCatchTyped[rules : {___RuleDelayed}] := CatchExceptions[
pkgException -> Replace @ Append[rules, data_ :> ThrowException[data]]
]
customCatchTyped[patt_] := customCatchTyped[p : patt :> p]
The wrapper matches
against the exception’s full data association — the same association passed as the second argument to ThrowException at the throw site.
Below are some examples of use. A pre-existing Throw[Association["kind"…],$tag] style throw migrates into this scheme by passing the same association straight through to ThrowException, with no restructuring. The trailing _ :> ThrowException[…] clause is the manual auto-rethrow: if no user-supplied rule matches, the exception is re-raised under the same
type for an outer catch to handle.
customCatchTyped[KeyValuePattern["kind" -> "badInput"] :> "caught"] @ intermediateFunc @ ThrowException[pkgException, <|"kind" -> "badInput", "Value" -> 42|>]customCatchTyped[KeyValuePattern["kind" -> "badInput"] :> "caught"] @ intermediateFunc @ ThrowException[pkgException, <|"kind" -> "overflow", "Value" -> $MaxMachineNumber * 2|>]Step 3—The Eventual Exception-Type-Based Form
Once you are ready for it, migrate it to exception type dispatch. The discriminator that used to live in a payload key becomes the registered exception type itself; the catch site names types instead of patterns
RegisterExceptionType[pkgException];
RegisterExceptionType[InvalidArgError, pkgException];
RegisterExceptionType[OverflowError, pkgException];
intermediateHandlerO3 =
Function[
code
,
customCatchTyped[{
KeyValuePattern["kind" -> "badInput"] :> "caught",
data_ :> Exception[Echo[data, "General error caught in old style"]]["ExceptionFailure"]
}] @ CatchExceptions[{
OverflowError -> Function[data, Exception[Echo[data, "Overflow"]]["ExceptionFailure"]]
}] @ code
,
HoldFirst
];
where some exception types have been moved to the exception-type-based dispatch inside CatchExceptions instead of the pattern-matching over the payload, whereas for others, you still use the temporary wrapper.
intermediateHandlerO3 @ intermediateFunc @ ThrowException[pkgException, <|"kind" -> "badInput", "Value" -> 42|>]intermediateHandlerO3 @ intermediateFunc @ ThrowException[pkgException, <|"kind" -> "internal", "Value" -> 100|>]intermediateHandlerO3 @ intermediateFunc @ ThrowException[OverflowError, <|"kind" -> "overflow", "Value" -> $MaxMachineNumber * 2|>]Auto-rethrow on miss is the framework’s default — the explicit Throw[data,t] rebound from the wrapper above is no longer needed. Once dispatch is done by exception type, handlers usually no longer need to read the discriminator at all: you attach a different handler to each exception type, and each handler then deals with one shape of payload only. That is usually clearer than a single handler that branches on a payload field.
Step 4—Drop the Wrapper
When the last
call site is gone, delete the helper and modify the payload to remove the old discriminator (the "kind" key in this example).
finalHandlerO3 = CatchExceptions[{
OverflowError -> Function[data, Exception[Echo[data, "Overflow"]]["ExceptionFailure"]],
InvalidArgError -> Function[data, Exception[Echo[data, "Invalid argument"]]["ExceptionFailure"]],
pkgException -> Function[data, Exception[Echo[data, "Unknown internal error"]]["ExceptionFailure"]]
}];
The package now lives entirely on the framework’s exception-type-dispatch primitives, with no compatibility shim.
finalHandlerO3 @ intermediateFunc @ ThrowException[InvalidArgError, <| "Value" -> 42|>]finalHandlerO3 @ intermediateFunc @ ThrowException[OverflowError, <| "Value" -> $MaxMachineNumber * 2|>]finalHandlerO3 @ intermediateFunc @ ThrowException[pkgException, <| "Value" -> 100|>]Recipe O4—Two-Layer with Internal Tag plus Boundary Translation
Axis position: AX1 = two-layer · AX2 = any · AX4 = explicit.
In brief: this recipe shows how to migrate a typical two-layer exception handling model/project to start using the Exceptions framework. It also shows how one can make architectural improvements along the way, making use of more features of the Exceptions framework — such as symbolic type hierarchy, built-in type dispatch, etc.
Notes
Applicable to two-layer packages (AX1) of any AX2 position. The package has an internal-error vocabulary with informative tags (
,
,
, …); a boundary catch in every public function consumes those internal-tagged failures and produces user-facing Message + Failure pairs via a translation registry.
This recipe is presented as a three-phase migration:
- Phase 1 is the pre-framework baseline using custom Catch/Throw primitives — code many packages actually have today.
- Phase 2 is the mechanical, minimum-effort swap to Exceptions framework primitives, which is essentially what every published version of this recipe up to now has shown.
- Phase 3 redesigns the translation layer around the framework’s symbolic-type tree, where the framework starts paying for itself.
The two-layer model is preserved during migration, not removed. The Exceptions framework handles the propagation half — the boundary catch — and your translation rules live in ordinary handler code. The recipe is fully self-contained: the translation registry, the boundary translator, the throw helper and the boundary wrapper are all defined below.
Throughout, the running example has two public functions,
and
, that overlap in the kinds of internal errors they can encounter — a "missing column", a "type mismatch" — but want to translate those errors into different user-facing messages. Both functions
and
are so primitive that they may seem borderline meaningless in what they are doing. That has been intentional to keep the mental load of this recipe and its code under some threshold and focus entirely on the error handling aspects of this recipe.
Phase 1—Custom Catch/Throw, Flat String Registry
The throw side is a Throw with a private tag; the catch side is a matching Catch; translation is driven by a global association keyed on a string
. The
association carries structured data that the per-tag rewrite function destructures into the
parameters.
ClearAll[raise, $rewrites, defineHandler, defaultRewrite, translateToUserFacing, withErrorHandling]
raise[tag_String, fields_: <||>] :=
Throw[<|"InternalTag" -> tag, "Fields" -> fields|>, $pkgThrowTag]
raise[f_?FailureQ] :=
Throw[<|"PrebuiltFailure" -> f|>, $pkgThrowTag]
(* Translation registry — flat, keyed on string InternalTag. *)
$rewrites = <||>;
defineHandler[tag_String, fn_] := AssociateTo[$rewrites, tag -> fn];
defaultRewrite[_] := {"interr", {}};
(*
** Translation function. Takes the public function (symbol), and the
** thrown data. Emits a proper message and returns the user-facing
** Failure object.
*)
translateToUserFacing[head_, data_Association] :=
With[{
tag = Lookup[data, "InternalTag", "default"],
fields = Lookup[data, "Fields", <||>]
},
{rw = Lookup[$rewrites, tag, defaultRewrite][fields]},
{name = rw[[1]], args = rw[[2]]}
,
Message[MessageName[head, name], Sequence @@ args];
Failure[
"MyPackageFailure",
<|"InternalTag" -> tag, "Fields" -> fields|>
]
]
(*
** Boundary handler. Distinguishes the case when the Failure object
** is thrown in the "direct return" mode, in which case it is simply
** extracted and returned as is, and the error translation process
** is bypassed/skipped.
*)
SetAttributes[withErrorHandling, HoldRest]
withErrorHandling[head_Symbol, body_] := Catch[
body,
$pkgThrowTag,
Function[
{data, t},
If[KeyExistsQ[data, "PrebuiltFailure"],
data["PrebuiltFailure"],
translateToUserFacing[head, data]
]
]
]
The way this code works is as follows:
- The raise[] function throws an exception with either a fully prebuilt Failure object or a unique sting tag (error identity) and an association of "fields": <|"Fields" -> <|"field1" -> ..., ...|>, extra |>.
- The registry is populated by handlers, for each unique error tag. Each handler is a function that takes the association <|"field1" -> ..., ...|> and returns a list of two things: the message name tag msgTag (a string) and a list of parameters for message publicfunc::msgTag.
- The public function name publicfunc is supplied at the catch site by the error handler. It passes it to the function translateToUserFacing, which takes that public function and the thrown data payload (an association), builds and emits the message using the registry and the mechanism just described and builds and returns the resulting Failure object).
- When the thrown payload is a "prebuilt" Failure object, it is returned directly, bypassing the registry and the translation process.
Phase 1—Example:
and 
Four entries are registered here — one per
pair — and define both successful and failure-raising public arities so that the boundary is exercised in both directions.
ClearAll[DataOp, MergeOp]
DataOp::missingcol = "Column `1` is missing from row `2`.";
DataOp::typemism = "Column `1`: expected `2`, got `3`.";
DataOp::interr = "Internal error.";
MergeOp::missingcol = "Column `1` is missing from the `2` side.";
MergeOp::typemism = "Column `1`: cannot merge `2` with `3`.";
MergeOp::interr = "Internal error.";
defineHandler["DataOp::missing_column",
Function[f, {"missingcol", {f["Column"], f["Row"]}}]];
defineHandler["DataOp::type_mismatch",
Function[f, {"typemism", {f["Column"], f["Expected"], f["Got"]}}]];
defineHandler["MergeOp::missing_column",
Function[f, {"missingcol", {f["Column"], f["Side"]}}]];
defineHandler["MergeOp::type_mismatch",
Function[f, {"typemism",
{f["Column"], Head[f["LeftValue"]], Head[f["RightValue"]]}}]];
(* Successful lookup arity — returns the requested value. *)
DataOp[data_Association, col_String] := withErrorHandling[DataOp,
If[KeyExistsQ[data, col], data[col],
raise["DataOp::missing_column", <|"Column" -> col, "Row" -> "-"|>]]]
DataOp[col_String, row_Integer] := withErrorHandling[DataOp,
raise["DataOp::missing_column", <|"Column" -> col, "Row" -> row|>]]
(* Successful merge — returns the two values; raises per side on absence. *)
MergeOp[left_Association, right_Association, col_String] :=
withErrorHandling[MergeOp,
{If[KeyExistsQ[left, col], left[col],
raise["MergeOp::missing_column",
<|"Column" -> col, "Side" -> "left"|>]],
If[KeyExistsQ[right, col], right[col],
raise["MergeOp::missing_column",
<|"Column" -> col, "Side" -> "right"|>]]}]
MergeOp[col_String, side : "left" | "right"] := withErrorHandling[MergeOp,
raise["MergeOp::missing_column", <|"Column" -> col, "Side" -> side|>]]
The examples below illustrate the various errors that can be encountered when using these functions.
DataOp[<|"price" -> 99, "qty" -> 3|>, "price"]MergeOp[<|"a" -> 1|>, <|"a" -> 2|>, "a"]DataOp["price", 17]MergeOp[<|"a" -> 1|>, <|"b" -> 2|>, "a"]MergeOp["price", "left"]One of the pain points with this code is that the string
names a concept that both functions share, but the registry only keys on opaque strings, so the throw site must encode the function identity into the tag (
vs
). The two handlers are independent entries in a single global namespace; adding a third public function multiplies entries again. There is no mechanism to say "both functions raise the same kind of error; only the translation differs" — the dispatch axis is just
.
Phase 2—Minimal Swap to Exceptions Framework Primitives
Replace Throw/Catch with ThrowException/CatchExceptions. Use a recipe-local root exception type
rather than the chapter-wide
, so this recipe’s type tree stays self-contained. The registry, the translator, both public functions and every example from Phase 1 are unchanged.
ClearAll[raise, withErrorHandling]
RegisterExceptionType[DataOpsException]
raise[tag_String, fields_: <||>] := ThrowException[DataOpsException,
<|"InternalTag" -> tag, "Fields" -> fields|>]
raise[f_?FailureQ] := ThrowException[DataOpsException,
<|"PrebuiltFailure" -> f|>] (* analogue of CC.1's checkFailure. *)
SetAttributes[withErrorHandling, HoldRest]
withErrorHandling[head_Symbol, body_] := CatchExceptions[
DataOpsException -> Function[data,
If[KeyExistsQ[data, "PrebuiltFailure"],
data["PrebuiltFailure"],
translateToUserFacing[head, data]]]
] @ body
You can check that all of the example code from Phase 1 continues to run identically with this change.
This step is typically always a necessary first step in the migration process, but if you stop at it, the benefits from the switch will only be very modest. You can gain a lot more from implementing deeper changes and using more features of the Exceptions framework. The next section illustrates one of the possible approaches along this line.
Phase 3—Symbolic Types, Per-Function Translation Registry
The redesign promotes the package’s error vocabulary from opaque strings to a symbolic exception-type tree. Each kind of error is registered as a type under the recipe-local root
, with
as a sibling that carries the prebuilt-failure passthrough path. Throw sites name the type symbol directly.
Design Notes
A note about the throw-site payload. Phases 1 and 2 wrap throw-site values inside a
association alongside the
they need to carry. In Phase 3 the framework’s Exception[…] already provides the data association — there is no second piece of metadata to keep next to it — so the wrapping disappears and
puts the values directly into the exception’s data association. Every registered function (the parameters builder, the optional data-transform function and any custom handler) takes that data association directly.
The translation registry is now an explicit two-level association:
is either
- A small specification record — the standard case, where the framework supplies the boilerplate that builds the user-facing Failure[]. The record’s three recognized keys are:
— the message tag, later used to form MessageName[func, tag];
— optional,
. When present, the data association is transformed first and the result is what
sees and what is carried into the resulting Failure[]. The transform is spliced into the generated handler body, so different
combinations produce handlers whose bodies contain genuinely different code rather than parameters to a generic dispatcher. When absent, the generated handler skips the transformation step entirely and is one line shorter.
- A bare
(or
) — a custom handler that becomes the entire CatchExceptions clause for that
pair, bypassing the standard pipeline entirely. The custom handler receives the exception’s data association directly — the same
argument that the standard pipeline would see. This lets a single
pair return a non Failure[] value, emit a warning instead of an error, recover with a default or do anything else its body specifies — without growing branches inside a single shared dispatcher.
New Generic Code
ClearAll[
raiseException, $registry, assignEntry, defineExceptionHandler, getMessageInfo,
exceptionDataToFailure, errorTranslate
]
(* Root exception type *)
RegisterExceptionType[DataOpsException]
(* Special exception type to propagate "direct" Failure object *)
RegisterExceptionType[DirectFailureType, DataOpsException]
(*
** No Phase-1 "Fields" wrapper is needed because the
** framework's Exception[…] already provides the data slot.
*)
raiseException[type_Symbol, data_: <||>] :=
ThrowException[type, data]
raiseException[f_?FailureQ] :=
ThrowException[DirectFailureType, <|"ExceptionPayload" -> f|>]
(* Two-level registry: functionName -> exceptionType -> translation-data *)
$registry = <||>;
assignEntry[func_, type_, entry_] := (
If[!KeyExistsQ[$registry, func], $registry[func] = <||>];
$registry[func, type] = entry;
)
(*
** Generic form — the spec is an Association whose recognised keys are
** "MessageNameTag", "MessageParametersFunction", and the optional
** "DataTransformFunction". Use this when the spec is built
** programmatically or carries additional metadata your code reads.
*)
defineExceptionHandler[func_Symbol, type_Symbol, spec_Association?AssociationQ] :=
assignEntry[func, type, spec]
(* Convenience form: msgTag + paramsFn (+ optional data-transform). *)
defineExceptionHandler[func_Symbol, type_Symbol, msgTag_String, paramsFn_, transformer_:None] :=
defineExceptionHandler[
func,
type,
Join[
<|
"MessageNameTag" -> msgTag,
"MessageParametersFunction" -> paramsFn
|>,
If[transformer === None, <||>, <|"DataTransformFunction" -> transformer|>]
]
]
(*
** Custom form: a bare Function (or Symbol) becomes the entire handler.
** It receives the exception's data association directly.
*)
defineExceptionHandler[func_Symbol, type_Symbol, customHandler : _Function | _Symbol] :=
assignEntry[func, type, customHandler]
(*
** Read the registered message tag and parameter-builder for {func, type}
** and produce the keys that withMessage[] consumes. Falls back to a
** "func::interr" message with no parameters when nothing is registered
** (or when a generic spec leaves these keys unset).
*)
getMessageInfo[func_, type_, data_Association] :=
With[
{entry = Lookup[Lookup[$registry, func, <||>], type, <||>]},
{name = Lookup[entry, "MessageNameTag", "interr"]},
<|
"MessageTemplate" :> MessageName[func, name],
"MessageParameters" -> Lookup[
entry, "MessageParametersFunction", {} &
][data]
|>
]
(* Build the handler for one {func, type} pair. *)
exceptionDataToFailure[func_, type_] := Replace[
Lookup[Lookup[$registry, func, <||>], type, <||>],
{
entry: _Function | _Symbol :> entry (* Custom handler *)
,
(* Handler with data preprocessing step *)
KeyValuePattern["DataTransformFunction" -> dt_] :> Function[
data,
With[{tData = dt[data]},
Exception[
Join[tData, getMessageInfo[func, type, tData] ]
]["ExceptionFailure"]
]
]
,
(* Standard handler *)
_ :> Function[
data,
Exception[
Join[data,getMessageInfo[func, type, data] ]
]["ExceptionFailure"]
]
}
]
(*
** Generaltes per-function handler list — explicit, "precompiled":
** - DirectFailureType passthrough first
** - One entry per {func, type} pair registered for this func
** - DataOpsException catchall last for anything raised under the
** package root with no registered handler.
*)
errorTranslate[func_Symbol] := Join[
{DirectFailureType -> Function[data, data["ExceptionPayload"]]},
KeyValueMap[
Function[{type, info}, type -> exceptionDataToFailure[func, type]],
Lookup[$registry, func, <||>]
],
{DataOpsException -> exceptionDataToFailure[func, DataOpsException]}
]
(* Operator-form boundary *)
withErrorHandling[func_Symbol] := Function[
code,
withMessage[func] @ CatchExceptions[errorTranslate[func]] @ code,
HoldFirst
]
Implementation Notes
is the message-emission helper from Recipe O2 — verbatim, no edits. It is a no-op on inputs that contain no message keys, which is exactly what makes the custom-handler path work cleanly: a custom handler that emits its own warning and returns a plain non Failure[] value passes through
untouched.
The two paths — standard and custom — share one dispatch shape and one boundary helper. The standard path itself accepts two spellings (a generic spec Association and the
/
convenience overload), but both spellings store the same kind of entry in the registry. The catchall —
— handles any exception raised under the package root that has no registered
entry. The lookup falls back to the
message tag and an empty parameter list.
What changes by virtue of the swap alone: the package’s exceptions now flow through the framework’s tagged tree, so they can be caught selectively under
by outer wrappers; the open-coded
throw replaces an inline Throw[..., PrebuiltFailure] from Phase 1 (and could be factored into the chapter-wide
from CC.1 if you accept that helper’s
root); Recipe O5’s debug-mode wiring becomes available without further edits.
What does not change: the registry is still a flat string namespace, the dispatch axis is still
, and the two public functions still need disambiguated tag names. The win is plumbing, not translation. This is roughly where prior versions of this recipe stopped — useful as a drop-in step, but the framework’s type tree is doing no real work.
Phase 3 Example: Exception Types and the Registry
Now continue with the running example and register one entry of each kind: a plain standard entry, a standard entry with a nontrivial
and a custom handler that recovers rather than fails.
ClearAll[DataOp, MergeOp]
(* Recipe-local leaves registered alongside the root. *)
RegisterExceptionType[MissingColumnError, DataOpsException]
RegisterExceptionType[OutOfRangeError, DataOpsException]
DataOp::missingcol = "Column `1` is missing from row `2`.";
DataOp::outofrange = "Value `1` for `2` is out of range [`3`, `4`] (distance `5`).";
DataOp::interr = "Internal error.";
MergeOp::missingsoft = "Column `1` is absent from the `2` side; substituting Missing[].";
MergeOp::interr = "Internal error.";
(*
** Plain standard entry — no DataTransformFunction. The registered
** parameters function reads the throw-site keys directly off the
** exception's data association.
*)
defineExceptionHandler[
DataOp, MissingColumnError, "missingcol", Function[d, {d["Column"], d["Row"]}]
]
(*
** Standard entry with DataTransformFunction. The transform is
** data → data; here it appends a computed "DistanceFromRange" key
** that the parameters function then reads in slot 5 of the message.
*)
defineExceptionHandler[
DataOp, OutOfRangeError, "outofrange",
Function[
d,
{d["Value"], d["Field"], d["Range"][[1]], d["Range"][[2]], d["DistanceFromRange"]}
],
Function[
d,
Join[d, <|"DistanceFromRange" -> Min[Abs[d["Value"] - d["Range"]]]|>]
]
]
(*
** Custom handler — emits a warning Message and returns Missing[],
** bypassing Exception[…]["ExceptionFailure"] and the entire standard
** Failure-building pipeline. The handler receives the exception's
** data association directly, with no "Fields" unwrapping needed.
*)
defineExceptionHandler[
MergeOp,
MissingColumnError,
Function[d,
Message[MergeOp::missingsoft, d["Column"], d["Side"]];
Missing["Column", d["Column"]]
]
]
errorTranslate[DataOp]errorTranslate[MergeOp]You can see that the error-handling spec for CatchExceptions that errorTranslate[] generates is very explicit. Many things are directly inlined, and the handlers generated for individual exception types are fully self-contained.
What this design buys that a single dispatcher cannot easily match:
- Per-pair code is part of the handler. The
handler’s body literally contains
; the
handler’s body literally contains a
and a
return. There is no
or
indirection on the hot path — what each handler does is fixed and inspectable at generation time.
- Custom handlers escape the standard shape. A single dispatcher can vary the data that flows through one body, but it cannot make different
pairs return values of fundamentally different shapes — Failure[…] for most, Missing[…] for one, a recovered value for another — without growing branches inside that one body. The registry simply substitutes a different Function for each pair.
- Each handler is debuggable in isolation. Because the per-pair handlers are plain
s in an inspectable list, you can grab one and apply it to a handcrafted data association in the REPL:
mergeHandler = MissingColumnError /. errorTranslate[MergeOp]mergeHandler[<|"Column" -> "qty", "Side" -> "right"|>]- No CatchExceptions boundary, no test scaffolding, no need to construct a throw site. Standard-path handlers can be debugged the same way provided the synthetic data carries the framework-injected
key that Exception[…]["ExceptionFailure"] consults; the custom path has no such dependency at all.
Phase 3 Example: Main Implementation
The public functions thread their internal exceptions through
. Each one is given a successful-execution path alongside the failure paths so that both directions of the boundary are exercised.
(* Successful lookup — returns the requested value. *)
DataOp[data_Association, col_String] := withErrorHandling[DataOp][
If[KeyExistsQ[data, col],
data[col],
raiseException[MissingColumnError, <|"Column" -> col, "Row" -> "-"|>]
]
]
(* Pure failure form — every call raises. *)
DataOp[col_String, row_Integer] := withErrorHandling[DataOp][
raiseException[MissingColumnError, <|"Column" -> col, "Row" -> row|>]
]
(* Range check — succeeds if in range; raises otherwise. *)
DataOp[val_?NumericQ, fld_String, range_List] :=
withErrorHandling[DataOp][
If[range[[1]] <= val <= range[[2]],
val,
raiseException[
OutOfRangeError,
<|"Value" -> val, "Field" -> fld, "Range" -> range|>
]
]
]
(* DirectFailureType passthrough — pre-built failure carried verbatim. *)
DataOp[passthrough_Failure] := withErrorHandling[DataOp][raiseException[passthrough]]
(*
** MergeOp returns a two-element list — value from each side. The custom
** handler for MergeOp x MissingColumnError replaces the missing side's
** element with Missing[].
*)
MergeOp[left_Association, right_Association, col_String] :=
withErrorHandling[MergeOp][
{
If[KeyExistsQ[left, col],
left[col],
raiseException[MissingColumnError, <|"Column" -> col, "Side" -> "left"|>]
],
If[KeyExistsQ[right, col],
right[col],
raiseException[MissingColumnError, <|"Column" -> col, "Side" -> "right"|>]
]
}
]
MergeOp[col_String, side : "left" | "right"] := withErrorHandling[MergeOp][
raiseException[MissingColumnError, <|"Column" -> col, "Side" -> side|>]
]
Note: to run the examples below, make sure to execute code not only of this recipe but also of the code from Recipe O2, since Phase 3 code does use the function
(the message-emission helper) from there.
The following examples show normal execution:
DataOp[<|"price" -> 99, "qty" -> 3|>, "price"]DataOp[5, "score", {0, 10}]MergeOp[<|"a" -> 1|>, <|"a" -> 2|>, "a"]The following examples show various errors:
DataOp["price", 17]DataOp[15, "score", {0, 10}]MergeOp[<|"a" -> 1|>, <|"b" -> 2|>, "a"]MergeOp["price", "left"]DataOp[Failure["upstream", <|"Reason" -> "bad parse"|>]]What Phase 3 Buys over Phase 2
- One type, many translations. Both functions raise
; the registry knows them apart by
. No tag-name mangling. A third public function can reuse the same shared types by registering its own
entries against them.
- Selective catching higher up. An outer wrapper can use
to handle missing-column errors from any function uniformly, while letting other types propagate. The string-tag registry of Phases 1 and 2 cannot express that — the tag string is opaque to outer catches.
as a real type, not a key. The "pass this Failure[] through verbatim" path is a sibling subtype caught by its own handler rule, not an If[KeyExistsQ[…, "PrebuiltFailure"]] branch inside the main handler.
- Message emission factored out. Standard-path handlers attach
/
to the data they return;
emits the side effect. Custom handlers that emit their own messages and return non Failure values are passed through unchanged.
- Standard and custom paths, one dispatch shape. Standard entries (the common case) stay declarative — either as the
/
convenience overload or as a full spec Association. Custom entries (the rare case) are full
s that can do whatever the case requires — without changing the framework, the boundary or the throw side.
The cost is one registration per error kind and one
entry per
pair where translation differs. In return, the translation layer scales linearly with the combinations you actually care about, instead of with the number of public functions × number of error kinds string cross-product of Phases 1 and 2.
Migration Advice
It is not necessary to migrate the entire code base at once. You might have noticed that the new functionality of Phase 3 has different names for a number of functions, w.r.t. phases 1 and 2 (raiseException instead of raise, defineExceptionHandler instead of defineHandler, etc.). Since Phase 2 transforms your code to use the Exceptions framework and the same exception root type for your project, as in Phase 3, you may transform your code type by type and function by function, having both older and newer code/registries/etc. running at the same time, until you have moved the entire code base to use the new error handling code of Phase 3.
Summary
This recipe illustrates a possible migration process for a typical two-layer project containing explicit translation registry, where internal errors are propagated through exceptions, caught at the project's boundary (public functions) and translated into user-facing errors and messages. The actual example "project" functionality (functions DataOp and MergeOp) was deliberately chosen to be very simplistic, to focus entirely on the error handling aspect.
The code in Phase 2 shows that it is usually fairly easy and straightforward to implement the Exceptions framework-based drop-in replacements for your current exception handling primitives.
The code in Phase 3 shows one possible way out of many to leverage the Exceptions framework to a fuller extent and convert your exception-handling code to more idiomatic, more strongly typed and potentially more powerful and testable version. As usual for this tutorial, the suggested code should be viewed more as a blueprint and possible source of ideas than necessarily a direct recipe to copy and paste the code verbatim into your project (although you can, of course, also do that, this sample code was not intended to be production-quality final version).
Recipe O5—Debug-Mode Wiring
Axis position: orthogonal to
AX1
/
AX2
/
AX4
. Applicable to any package but most useful when
AX5
is to be moved off
.
In brief: this recipe outlines how the debug mode switch can be naturally included into your error handling code based on the Exceptions framework — whether you already have such debug mode switch in your code or not.
Notes
This recipe wires a per-package debug switch into the throw side using the two techniques sketched in Section 3 (AX5):
-field enrichment and
rerouting. It is independent of the recipe used for the rest of the package’s error handling — apply it on top of O1, O2, O3 or O4.
The wiring is structured around a single throw-side indirection (
) and a single global flag (
). Every ThrowException call site in the package routes through the indirection, so flipping the flag changes throw-side behavior without touching individual call sites.
(* Pseudocode *)
$pkgDebug = False;
(* DebugException is registered outside the package's normal exception type tree so that
selective catches under MyPackageException do not match it. *)
RegisterExceptionType[DebugException]
could equally well be registered under
— for example, as a direct child of the package root or as part of the
sibling subtree of Section 4. In that placement, a CatchExceptions[MyPackageException] boundary at the package edge would still see debug-mode exceptions (which is sometimes desired, e.g. when the developer wants the production handler to format them).
The placement chosen here — a separate root, outside the package tree — is the more aggressive one: rerouted exceptions bypass not only inner module catches but the package boundary itself, surfacing raw at the outermost call site (typically the developer’s REPL session). That choice matches the most common debugging use case ("show me the failure exactly where it happened, with no handler interference"); pick the under-package placement instead if your debug workflow expects the package boundary to remain in the loop.
-Field Enrichment Alone
(* Pseudocode *)
throwException[type_, payload_Association] := If[TrueQ[$pkgDebug],
ThrowException[
type,
<|
payload,
"Cause" -> <|
"EnclosingFunction" -> First[Cases[Stack[_], h_Symbol[___] :> h, {1}, 1], None],
"StackSnapshot" -> Stack[]
|>
|>
],
ThrowException[type, payload]
]
The boundary handler does not need to know about
— it reads the data association whole, and the
key just rides along into the resulting Failure[]. Production builds ($pkgDebug = False) pay no overhead because the enrichment branch is taken only under the If[].
Rerouting Alone
(* Pseudocode *)
throwException[type_, payload_Association] :=
ThrowException[
If[TrueQ[$pkgDebug], DebugException, type],
payload
]
Every package throw routes through
. With $pkgDebug = True, the exception type is rewritten to
. Inner catches like CatchExceptions[fstModuleRootrecover] no longer match because
is not a descendant of
. The error reaches whatever catch is highest — typically the developer’s REPL session if no top-level catch is in place, or a CatchExceptions[DebugExceptiondiagnostic] wrapper installed for debug runs.
Both Techniques Combined
(* Pseudocode *)
throwException[type_, payload_Association] := If[TrueQ[$pkgDebug],
ThrowException[DebugException, <|
"OriginalType" -> type,
payload,
"Cause" -> <|
"EnclosingFunction" -> First[Cases[Stack[_], h_Symbol[___] :> h, {1}, 1], None],
"StackSnapshot" -> Stack[]
|>
|>],
ThrowException[type, payload]
]
A debug-mode catch picks up both — the original type via the
key in the data and the diagnostic snapshot via
.
(* Pseudocode *)
CatchExceptions[DebugException -> Function[data,
Print["debug: ", data["OriginalType"], " — ", data["Cause"]];
Failure["debug", data]]
] @ runMyCode[]
Boundary Handler—Unchanged or One Line Longer
The package’s normal boundary handler (e.g.
from Recipe O4 or
from Recipe O2) stays as is. Rerouting requires no boundary change at all:
simply does not arrive at the normal boundary. Enrichment requires at most one Append if you want to project the cause out for diagnostic display.
(* Pseudocode *)
SetAttributes[withErrorHandling, HoldRest]
withErrorHandling[head_, body_] := CatchExceptions[
pkgException -> Function[data,
Module[{enriched = If[
TrueQ[$pkgDebug] && KeyExistsQ[data, "Cause"],
Append[data, "DebugStack" -> data["Cause", "StackSnapshot"]],
data]
},
translateToUserFacing[head, enriched]
]
]
] @ body
The switch is one global; debug-only enrichment is one Append; the production path is the same code with
left at False. When the per-exception-type debug mechanism (Section 10) is documented, the throw-side indirection collapses into a property assignment on the relevant types.
How the Recipes Affect and Are Affected by the Remaining Axes
The recipe choice (O2/O3/O4) is set by AX1 and the AX2 selective-catching sub-dimension, as the recipe-selection table above shows. The remaining axes affect peripheral aspects only:
- AX3 (propagation depth) — determines whether you also adopt Section 4 (Modular architectures and selective catching).
- AX2 (error identity sub-dimension) — the design decision Section 2 walks through, independent of recipe choice.
- AX5 (debug-mode integration) — orthogonal to recipe choice; if the package has any debug-mode wiring beyond ad hoc Print, apply Recipe O5 alongside whichever main recipe is in use and revisit when the per-exception-type debug API is documented (Section 10 (Looking ahead)).
- AX6 (public error-return convention) — independent; affects only the return value of the boundary handler in each recipe. The recipes above return Failure[]; the AX6 framework angle in Section 3 (The classification axes) spells out the wrapper changes needed for $Failed plus Message or unevaluated returns.
- AX7 (evolution stance) — frames the recipe choice itself. A drop-in migration applies the recipe verbatim with no other changes; an incremental migration leaves room for newer call sites to use ThrowException/CatchExceptions directly; an architectural migration may also restructure the exception type hierarchy (Section 2 (Designing the exception type hierarchy)) and adopt the modular catching pattern (Section 4 (Modular architectures and selective catching)) in the same pass.
7. Implementation Notes for Custom Solutions
Several of the examples in this tutorial need particular Attributes, particular evaluation-control idioms or particular pattern shapes to behave correctly. The notes here explain why, so that the recipes in Section 6 and the snippets in Section 3 do not have to re-justify the same decisions repeatedly. Skip this section on first reading and come back when you encounter a SetAttributes[…] you want to understand or adapt.
Much of this section describes topics that are more general Wolfram Language knowledge than something specific to the error handling or the Exceptions framework. The reason they are covered here is that they are frequently encountered when implementing custom error-handling primitives. You may safely skip the subsections below if topics such as held code, nonstandard evaluation and evaluation control are familiar to you, but it may still be a good idea to at least skim through these subsections.
When Wrappers around Catch/CatchExceptions Need a Hold* Attribute
Catch and CatchExceptions are themselves HoldFirst (or equivalently set up to defer their first argument): they receive their body unevaluated and decide when to evaluate it inside the catch scope. A user-defined wrapper around them does not inherit that behavior automatically. Without an explicit Hold* attribute, the wrapper’s body argument evaluates before the wrapper’s right-hand side runs, which means any Throw inside the body fires outside the catch and propagates uncaught.
Two attribute choices are common:
- HoldFirst — when the body is the first argument:
. Examples in this tutorial:
,
,
, the CC.2 throw helpers
and
.
- HoldRest — when the body is not the first argument; typically because the wrapper takes a head or some other configuration first:
. Example:
(Recipe O4).
Either way, the rule is: if the wrapper exists to put a Catch/CatchExceptions around something, the something needs to be held. Forgetting this attribute is the single most common bug in custom error-handling helpers, and it is silent — the wrapper still type-checks and runs; the throw simply never gets caught.
A corollary: helpers that do not wrap a catch — pure constructors like
,
— do not need a Hold* attribute. Adding one defensively is not free: it changes the call-site semantics in ways that can interact badly with downstream code (most notably, it disables literal-pattern matches like name_String on the held argument; see below).
Held Arguments inside an Association Payload
When a
or
helper captures call-site arguments and stuffs them into the exception’s data for later use in a Message[], the arguments must not evaluate during the throw. The standard idiom is
(RuleDelayed) instead of
(Rule) inside the association literal.
(* Pseudocode *)
ThrowException[pkgException,
<|"MessageTemplate" :> func::badargs, "MessageArguments" -> {args}|>]
would evaluate
at throw time, which will cause the problems downstream, when passing it to Message for example. The use of RuleDelayed (
defers the evaluation until the catch handler can be pass the MessageName[…] to Message[] as intended. In general, for values that need to be handed unevaluated to the catch handler, use RuleDelayed.
Predicate Patterns vs. Structural Patterns under Hold*
Combining HoldRest/HoldFirst with a structural type pattern like name_String is a subtle landmine. With the argument held, name_String matches only when the caller writes a literal string at the call site. A caller passing an expression that would evaluate to a string —
,
, etc. — produces a held form whose head is not String, and the rule silently does not match. The function call falls through with no error, just a leftover unevaluated
expression.
The fix is to use a predicate test, which forces the argument to evaluate before the test is applied.
(* Pseudocode *)
SetAttributes[makeFailure, HoldRest]
makeFailure[head_Symbol, name_?StringQ, args___] := …
(* succeeds for both makeFailure[F, "x", …] and makeFailure[F, getName[], …] *)
The general lesson: when combining HoldRest/HoldAll with any kind of type discrimination on the held arguments, prefer predicate-test patterns (_?StringQ, _?IntegerQ, _?AssociationQ, …) over structural head-patterns (_String, _Integer, _Association, …). Structural patterns only inspect the held shape; predicate tests force evaluation and check the result. Of course, you also should define functions unambiguously, so that e.g. makeFailure should have no definitions of the type makeFailure[head_Symbol, args___], where args are supposed to be held unevaluated, since patterns involving PatternTest (?) will leak evaluation (which in the above example is safe for passed string for name but not safe for args).
Operator Forms: SubValuesHoldAll vs. Returned Function
A wrapper that supports both prefix
and operator
forms needs to keep the
argument unevaluated in the operator form too. The naive subvalue rule
evaluates
before the rule’s right-hand side fires. Two ways to do it correctly:
- Modern (Wolfram Language 15.0+). Set the
attribute and write the operator-form rule as a thin re-dispatch.
(* Pseudocode *)
SetAttributes[wrapper, {HoldRest, SubValuesHoldAll}]
wrapper[head_Symbol][body_] := wrapper[head, body]
- Older versions (works in all 14.x and earlier). Have the one-argument form return a Function carrying the appropriate Hold* attribute.
(* Pseudocode *)
wrapper[head_Symbol] := Function[body, wrapper[head, body], HoldFirst]
Keeping MessageName Unevaluated until the Right Moment
This has been touched on earlier, but there are additional subtleties in handling MessageName and, in particular, passing it to Message properly, thus this subsection.
MessageName[head,tag] is eager: as soon as it sees a head and a tag, it looks up the message in
’s Messages[] table and returns the template string, e.g "Bad input `1`". That is the right behavior for ordinary use, but it is the wrong behavior for a MessageName[head,tag], particularly when passed to another function that expects the unevaluated form. The classic such consumer is Message[] itself: Message[] takes a MessageName[…] as its first argument (held by Message's own HoldFirst) and looks up the template internally. Hand it the already evaluated template string instead and it complains.
Two idioms keep MessageName[…] from evaluating prematurely:
- Build it inside Message[] directly, via Replace substitution. The right-hand side of a RuleDelayed is held until the rule fires; Replace[{h,m},{h_,m_}Message[MessageName[h,m],…]] puts MessageName[h,m] directly inside the held first slot of Message[], where the HoldFirst of Message keeps it intact until the internal lookup runs. This is the idiom used by Recipe O4’s
(Section 6).
- Store its components separately. Decompose the MessageName[head,tag] at the throw site (where
and
are still recoverable as separate parts) and reassemble at the catch site — the throw site never holds a MessageName[…] form to begin with, so eager evaluation has nothing to fire on. This is what CC.3’s
does, with the catch-side reassembly handled by
(Section 5).
8. Performance Considerations
The Exceptions framework’s primitives (Exception[] construction, ThrowException/CatchExceptions dispatch, exception-type-pattern matching, Failure[] rebuild in the default handler) are designed for correctness and composability, not for the inner loop of a hot computational kernel. For most error sites, this overhead is negligible — errors are exceptional by definition, and the work done at the throw/catch boundary is dwarfed by the work the function would have done on success. A handful of situations are different and deserve mention.
When the Overhead Matters
- Tight loops where errors are routine, not exceptional. Numerical search, parser backtracking and constraint-satisfaction code sometimes use throw/catch as part of the algorithm rather than as an error channel. Per-iteration Exception[] construction can dominate the iteration cost.
- Inner helpers called millions of times whose only error is "assertion violated". When the helper is short and the assertion essentially never fires, a bare Throw[…] is faster than the framework path — and the framework’s diagnostic value is largely wasted, since the error is by construction either an internal bug or a fatal precondition violation.
- Error sites inside performance-critical functions where a Failure[] rebuild on every miss would be wasteful even though the rest of the package uses the framework happily.
Mixed Pattern: Ad Hoc Catch in Hot Paths, Framework Boundary Outside
The practical need for a mixed approach arises when one needs to use non-local control flow inside inner tight loops, for example, to report errors, some of which might be recoverable, or even to simply bail out of the inner loop(s) for cases that may not even be categorized as errors or failures.
The recommended approach is to use a bare Catch/Throw pair locally inside the hot region and re-emit the result through the Exceptions framework at the surrounding module boundary, in cases when the caught result thrown in an inner loop does represent a failure that needs to propagate further.
Here is a simple example of such a mixed case.
$hotLoopFailTag = "PkgHotLoopTag";
(*
** hotLoopFailure is a private head used only as a Throw token inside this
** hot path; it is never registered and never seen outside hotLoopHelper.
*)
SetAttributes[hotLoopHelper, HoldFirst]
hotLoopHelper[body_] := Replace[
Catch[body, $hotLoopFailTag],
res_hotLoopFailure :> ThrowException[
pkgInternalError,
<|"Cause" -> res, "Reason" -> "hot-loop failure"|>
]
]
(* Inside the hot path — bare Throw/Catch only. *)
innerKernel[xs_] :=
Map[
If[!validQ[#],
Throw[hotLoopFailure[#], $hotLoopFailTag],
compute[#]
] &,
xs
]
validQ[x_] := x >= 0
compute[x_] := x^2
(* Outer boundary — framework catch. *)
hotLoopPublicAPI[xs_] := CatchExceptions[pkgException] @ hotLoopHelper @ innerKernel[xs]
This example propagates the inner failure unconditionally, but in more complex scenarios, some of the inner Throw events could either be recoverable failures (so that the computation is resumed in some form) or represent some payload that is not even an error at all but simply needs some nonlocal internal propagation.
hotLoopPublicAPI[{1, 2, 3}]hotLoopPublicAPI[{1, -2, 3}]The boundary CatchExceptions sees a normal
and produces a normal user-facing Failure[]. Consumers cannot tell that the inner loop used a bare Throw token.
Rules of Thumb
- Default to the framework primitives. They are not measurably slower than a hand-rolled Throw/Catch pair for any but the most extreme call frequencies, and the loss of structured payload, exception type dispatch and selective catching is rarely worth the constant-factor speedup.
- Reach for the mixed pattern only when profiling has shown the throw/catch path to be a real bottleneck and the surrounding algorithm is genuinely throw heavy.
- Resist the temptation to optimize preemptively by using bare Throw/Catch in code that throws once or twice per public call. The framework’s diagnostic and composability advantages dwarf any small constant-factor speedup, and the local optimization will need to be undone the moment the surrounding module wants to participate in selective catching.
- If you do introduce the mixed pattern, isolate it behind a helper like
above. The helper becomes the documented seam where the bare-throw idiom is permitted; the rest of the package retains the uniform framework view.
9. Summary
This section steps back from the recipe mechanics and collects what the migration buys you regardless of which recipe you adopt, a practical caution about naming, and the next architectural moves that the axis classification of Section 3 unlocks once the drop-in replacement is in place.
What the Migration Buys You
The single most important benefit of migrating to the Exceptions framework is that your error-handling code can be made strongly typed in the sense of error/exception type system. As with many other aspects of programming, strong typing helps make the code more robust, discovers the errors early and improves code testability.
The second largest benefit of such a migration is the ability to use more advanced architectural patterns to decouple various aspects of error handling from each other and better align your error-handling code with the general structure of your project. This pays more for larger projects with more complex and possibly modular structure.
In every recipe (O2, O3, O4) the same baseline holds:
- Existing public helper names can be preserved at the first migration step; only their bodies change.
- The default CatchExceptions handler turns an exception into a sensible Failure[], so trivial boundary catches can omit the handler entirely. (If you do not pass a handler to CatchExceptions, it converts the exception into Failure[failureTag,dataAssoc], where
is the
key of the data, defaulting to a string form of the exception’s type. The recipes in Section 6 all show explicit handlers because they want to emit a Message[], return $Failed or restructure the data — but the default-handler shortcut is always available where those concerns do not apply.)
The consistent benefit, beyond these mechanical conveniences, is conceptual: error propagation becomes a first-class concern with a dedicated primitive (Exception[]), a dedicated catch mechanism (CatchExceptions) and an exception type system. Translation, message-emission and Failure construction logic remain useful — they just stop having to also worry about how the propagation itself works.
A Practical Caution: Public-Symbol Collisions
Pre-existing paclet symbols may collide with Exceptions framework symbols. In case your code does use any of the new symbols introduced by the Exceptions framework, this will be a problem for you, regardless of whether or not you actually use the Exceptions framework, since the new symbols live in the "System`" context. Simple symbol renaming for your internal symbols should suffice to fix the problem.
Beyond the Drop-in: Using the Axes for Further Architectural Change
The drop-in replacement covered in Section 6 is the first step. Once the boundary helpers are running on the Exceptions framework, deeper architectural changes become available — changing AX1 from single-layer to two-layer, switching AX2 from non-selective to selective dispatch, adopting nested propagation along AX3 and so on. These are larger changes than a drop-in, but they are precisely the changes the axis classification of Section 3 was set up to describe. Some of the recipes in Section 6 went deeper than just drop-in replacements and illustrated possible such next steps.
The axis classification is just as useful for planning such changes as it was for detecting the package’s starting position. Each architectural change is naturally framed as movement along one axis with the others held fixed. Plan such changes one axis at a time:
- Single-layer → two-layer (AX1): introduce an internal-tag vocabulary and a translation registry; the boundary catch’s handler grows a translation step, but the throw sites simply switch from message-name fragments to internal tags.
- Non-selective → selective dispatch (AX2): split the single throw tag into a handful of registered exception types and let the CatchExceptions[…] type pattern do the discrimination at the boundary.
- Direct propagation → nested propagation (AX3): register a separate type root for the subsystem whose errors should be caught and recovered before reaching the package boundary, then add the inner catch (Section 4).
Keeping changes one axis at a time keeps the diff reviewable/easy to understand and manage, lets the test suite isolate regressions and — importantly — keeps each intermediate state a runnable, type-checked configuration of the same package. The axis classification is the planning tool, and the recipes are starting positions.
10. Looking Ahead
Two framework directions are in development and will affect how packages migrated by the recipes in this tutorial evolve over time. Everything cited in this section is undocumented at the time of writing; recipes built on the surface described here will need to be revisited once the corresponding APIs are documented.
Type Properties
Note: this section discusses highly experimental functionality, which may go away in future versions. The purpose of this subsection is to illustrate the possible future functionality of the Exceptions framework, not to encourage you to use it now.
The Exceptions framework supports attaching properties to a registered exception type, with subtype inheritance.
RegisterExceptionType[pkgIOError, pkgException]
pkgIOError["Severity"] = "Recoverable"
pkgIOError["MessageTemplate"] := "IO error in `1`"RegisterExceptionType[FileReadError, pkgIOError]
FileReadError["Severity"]
FileReadError["MessageTemplate"]FileReadError["MessageTemplate"] := "Error reading file `1`";
FileReadError["MessageTemplate"]What is also not currently documented is which property names the Exceptions framework recognizes and what each one does.
FileReadError[All]Useful applications include per-exception-type message templates (replacing the per-package message-name lookup that Recipe O4’s translation registry implements by hand), per-exception-type recoverability metadata for generic retry helpers and per-exception-type debug overrides (next subsection).
The tutorial does not yet recommend specific property names because the documented set is currently empty. When the Exceptions framework documents a baseline set, this section will be updated with concrete recipes.
Per-Exception-Type Debug-Mode Mechanism
The Exceptions framework provides a property-based per-exception-type debug mode, with subtype inheritance, accessed via a small API surface that is undocumented at the time of writing. When debug mode is on for a type, the Exceptions framework automatically enriches that type’s exceptions with at least the immediate enclosing function (discovered by stack walk) and, optionally, a full rendered stack trace. Subtype inheritance means a project can debug-enable just one module without touching the rest.
This is the Exceptions framework’s intended replacement for the ad hoc per-package debug switches described in AX5. Until the API is documented, packages that need a debug switch should keep their existing one (or implement a small per-package one with a similar shape).
The Error-Translation Layer
An in-progress Exceptions framework component — undocumented at the time of writing — registers per-function error specifications. Each spec is keyed by the public function symbol and a low-level tag and tells the Exceptions framework how to assemble the user-facing Message[] and Failure[] from a thrown low-level exception.
When this layer ships, the per-function translation registry that two-layer packages currently build by hand (Recipe O4, the
/$registry associations) becomes a framework-managed lookup. The head-binding step from AX4 — currently per-package boilerplate — becomes a configuration step rather than a control-flow one. Recipe O4 will shrink correspondingly.
For now, the recipes in Section 6 do not depend on this layer. Packages migrating today should write the translation registry by hand as shown in O4; once the Exceptions framework layer is documented, the registry can be rewritten as a sequence of registration calls without touching call sites.