Exception Handling
Any exception that surfaces during TemplateEngine.Merge is wrapped in a MergeException. The wrapper preserves the underlying failure and adds template position information — line, column, character index, and the most recently parsed tag — so the error message points at the spot in the template that triggered it, not just the .NET-level cause.
What gets wrapped
| Source of failure | What you get |
|---|---|
| A bind expression hits a missing member | MergeException wrapping the reflection exception |
A {#each} is applied to a non-IEnumerable value | MergeException wrapping an InvalidOperationException |
| A lambda throws | MergeException wrapping the lambda’s exception |
| A registered lambda name is not in the repository | MergeException wrapping a KeyNotFoundException |
| A wrong number of arguments is passed to a lambda | MergeException wrapping an InvalidOperationException |
| Reassignment of an undeclared variable | MergeException wrapping an InvalidOperationException |
| Parser cannot make sense of a tag | MergeException wrapping the parse failure |
Block or partial nesting exceeds MaxStack | MergeException wrapping TemplateStackDepthException |
| Compound bind expression exceeds 16 levels | MergeException wrapping RecursionStackDepthException |
The original exception is always available on MergeException.InnerException. The MergeException.Context property carries the template position(s).
MergeException shape
| Member | Type | Description |
|---|---|---|
InnerException | Exception | The underlying failure thrown during merge |
Context | MergeExceptionContextStack (a Stack<MergeExceptionContext>) | One frame per engine instance that participated in the failing merge |
MergeExceptionContext exposes:
| Property | Type | Description |
|---|---|---|
Line | int | 1-based line number in the template |
Column | int | 1-based column on that line |
CharIndex | int | 0-based character offset into the template string |
LastTag | string | The most recently parsed tag, when available |
MergeExceptionContext.ToString() is preformatted for display:
Ln: 4 Col: 12 Char Index: 87 LastTag: {Foo.Bar}Catching and inspecting
try
{
string output = ngin.Merge(data);
}
catch (MergeException mex)
{
foreach (var frame in mex.Context)
Console.WriteLine(frame);
Console.WriteLine(mex.InnerException);
}Unlike a .NET stack trace, the throw site is not on top. Frames enumerate outermost-first: the root engine sits at the top of the stack and the innermost engine — where the failure occurred — sits at the bottom. Each enclosing engine pushes its frame as the exception unwinds outward, so the last frame pushed (the root) is the first one read.
Why Context is a stack
The engine spawns a sub-engine for each {#each} iteration, {#with} block, {#if} block, and partial invocation. When a failure occurs deep inside nested blocks, each enclosing engine pushes its own frame onto the context as the exception propagates outward. The resulting stack pinpoints both the immediate failure site and the chain of nested constructs that led to it.
Without the stack, you would see only the innermost line/column — enough to locate the failing tag, but not enough to understand the iteration or partial chain that brought the engine there.
Reading a multi-frame context
The root template is an engine, and every {#each}, {#with}, {#if}, and partial invocation nests a sub-engine. The failure adds one frame for each engine it unwinds through, so a fault three constructs deep yields four frames.
This template nests two {#each} blocks around a partial:
{#each Departments} <-- outer engine
{#each Reps} <-- inner engine
{>..\..\$.BadgeFormat} <-- partial engine; fails inside the bound template
{/each}
{/each}The <-- ... arrows are literal text labeling each engine. The partial walks up two scopes to the root and merges BadgeFormat, whose body dereferences {$.Position}:
var data = new
{
Departments = new object[]
{
new { Reps = new object[] { "Jim", "Jane", "Sam" } },
new { Reps = new object[] { "Sue", "Dave", "Stan" } },
},
BadgeFormat = "Yo {$} !!! What up with the new {$.Position} Job?"
};
try
{
new TemplateEngine(template).Merge(data);
}
catch (MergeException mex)
{
Console.WriteLine(mex.Context); // MergeExceptionContextStack.ToString()
}Reps holds strings, so {$.Position} binds against a string and throws. Four frames print, top to bottom:
Ln: 1 Col: 20 Char Index: 19 LastTag: {#eachDepartments}
Ln: 2 Col: 15 Char Index: 37 LastTag: {#eachReps}
Ln: 2 Col: 27 Char Index: 54 LastTag: {>..\..\$.BadgeFormat}
Ln: 1 Col: 45 Char Index: 44 LastTag: {$.Position}Reading LastTag top-down traces the path from the outermost construct inward: the root on {#each Departments}, its body engine on {#each Reps}, that engine on the partial tag, and the partial’s engine on {$.Position} — the failure, at the bottom.
Two things run counter to .NET instincts:
- The throw site is the last frame, not the first. Innermost pushes first (bottom); each enclosing engine pushes as the exception unwinds, so the root ends up on top and reads first.
Line,Column, andCharIndexare segment-relative. A sub-engine merges a slice of its parent, so the numbers are local to each engine. The{#each Reps}and partial frames both readLn: 2, but in different bodies — the partial tag sits on file line 3, line 2 of the{#each Reps}slice.
Here every frame is pushed as the exception unwinds outward, so each lands just past its own tag: the two {#each} frames just past their opening tags (not their {/each} closes), the partial frame just past its tag, the failure just past {$.Position}. (Whitespace inside a tag is insignificant and dropped on read, so {#each Departments} surfaces as {#eachDepartments}.)
Identifying depth violations
TemplateStackDepthException and RecursionStackDepthException are both wrapped in a MergeException. Use a when filter on InnerException to distinguish them from other failures:
catch (MergeException mex) when (mex.InnerException is TemplateStackDepthException)
{
// block/partial nesting exceeded MaxStack
}
catch (MergeException mex) when (mex.InnerException is RecursionStackDepthException)
{
// compound bind expression exceeded 16 levels
}
catch (MergeException mex)
{
// all other merge failures
}See Stack Depth Limit.
Recovery patterns
MergeException is non-fatal at the .NET level — the engine instance remains usable after catching one. You can:
- Log the context stack and
InnerException, return an error to the caller, and continue using the engine for other merges - Pre-flight templates against test data and capture the context to feed back to template authors
The engine does not preserve partial output up to the point of failure. A Merge call either returns a complete result or throws.
Next steps
- Stack Depth Limit — the two depth caps and the threat model they address
- Lambda Expressions — how exceptions in user-supplied functions surface
- Debugging — use
{@}to confirm intermediate values when the exception context isn’t enough on its own