Exception Handling

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 failureWhat you get
A bind expression hits a missing memberMergeException wrapping the reflection exception
A {#each} is applied to a non-IEnumerable valueMergeException wrapping an InvalidOperationException
A lambda throwsMergeException wrapping the lambda’s exception
A registered lambda name is not in the repositoryMergeException wrapping a KeyNotFoundException
A wrong number of arguments is passed to a lambdaMergeException wrapping an InvalidOperationException
Reassignment of an undeclared variableMergeException wrapping an InvalidOperationException
Parser cannot make sense of a tagMergeException wrapping the parse failure
Block or partial nesting exceeds MaxStackMergeException wrapping TemplateStackDepthException
Compound bind expression exceeds 16 levelsMergeException wrapping RecursionStackDepthException

The original exception is always available on MergeException.InnerException. The MergeException.Context property carries the template position(s).

MergeException shape

MemberTypeDescription
InnerExceptionExceptionThe underlying failure thrown during merge
ContextMergeExceptionContextStack (a Stack<MergeExceptionContext>)One frame per engine instance that participated in the failing merge

MergeExceptionContext exposes:

PropertyTypeDescription
Lineint1-based line number in the template
Columnint1-based column on that line
CharIndexint0-based character offset into the template string
LastTagstringThe 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, and CharIndex are 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 read Ln: 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