Lambda Expressions

Lambda Expressions

A lambda call invokes a registered Func<…> delegate from any tag. Use lambdas for formatting, encoding, sorting, arithmetic, and any other logic the tag syntax does not handle directly.

Call syntax

{ (arg1, arg2, …) => funcName }

An argument list in parentheses, the lambda operator =>, then the registered function name. Whitespace inside the tag is insignificant; the parentheses delimit the argument list.

A zero-argument call uses empty parentheses:

{ () => now }

Where lambda calls are valid

Lambda calls can appear inside any of the following tags:

  • Simple tags — {(FirstName, LastName) => formatName}
  • Conditional tags — {#if (User) => isAdmin}
  • Iteration tags — {#each (Items) => sort}
  • With tags — {#with (Customer) => primaryAddress}
  • Variable declaration / reassignment — {?var:n = (Name) => upper}
  • Partial tags — {>(Kind) => selectTemplate}
  • Debug tags — {@ (Items) => describe}

Anywhere the engine expects an expression, a lambda call is acceptable.

Registering a function

Use LambdaRepo.Register(name, delegate). The function can be any Func<…> (or any other delegate type with a matching signature).

var person = new
{
    Certifications = new[] { "mcse", "mcitp", "mcts" },
    Name = new { First = "John", Last = "Doe" }
};

string template = "Hello {Name.First} {Name.Last} — certs: {(', ', Certifications) => join}.";

Func<string, object[], string> join = (delim, values) => string.Join(delim, values);

var ngin = new TemplateEngine(template);
ngin.LambdaRepo.Register(nameof(join), join);

string result = ngin.Merge(person);
// Hello John Doe — certs: mcse, mcitp, mcts.
Result:
Hello John Doe — certs: mcse, mcitp, mcts.

The repository is keyed by name (case-sensitive, ordinal). Registering the same name twice throws. Use LambdaRepo.Deregister(name) to remove a registration.

LambdaRepo is lazily created — accessing it the first time allocates an empty repository. Register functions before calling Merge; the engine resolves lambda calls during merge by name lookup.

Argument forms

Each argument in the call list can be any of:

FormExample
Bound expression{(Address.City) => upper}
$ local-scope reference{($) => stringify}
Scope-walk expression{(..\Title) => upper}
Declared variable{(:count) => format}
String literal{("hello") => upper} or {('hello') => upper}
Numeric literal{(42) => format} or {(3.14) => format}
Boolean literal{(true) => negate}
Char literal{(',') => splitOn} — quoted literal of length 1
DateTime literal{("2025-01-01") => formatDate} — any quoted string parseable by DateTime.TryParse

Literal coercion is driven by the target parameter’s type. A numeric literal is parsed against the parameter’s exact type (int, decimal, double, short, byte, etc.) when the function is invoked — no f/d/m suffixes are needed. A quoted literal becomes a string, char, or DateTime depending on the parameter type. Bound expression arguments must already match the parameter type or be convertible to it.

A lambda call result cannot appear directly as an argument to another lambda call. Assign the first result to a variable and pass the variable:

{?var:result = (Name) => firstFunc}
{(:result, OtherArg) => secondFunc}

String literal quoting

String literal arguments accept either single or double quotes:

LiteralTag form
Plain string{("hello") => f} or {('hello') => f}
Contains a double quoteUse single quotes: {('She said "hi"') => f}
Contains a single quoteUse double quotes: {("It's easy") => f}
Contains bothEscape with backslash: {("It's \"easy\"") => f}

Choose the outer quote style that avoids escaping; reach for backslash only when both quote types appear in the same literal.

Passing null explicitly

A bare null literal cannot appear in an argument list. To pass null to a function, declare an unassigned variable in scope and pass the variable:

{?var:nothing}
{(:nothing) => MyFunc}

A declared variable with no initial value is null. The same :nothing variable can be reused for additional null arguments. See Variables.

Return values

The function’s return value becomes the tag’s result:

  • In a simple tag, it is rendered into the output (converted via ToString()).
  • In an {#if}, the return value is evaluated against the falsy rules.
  • In an {#each}, the return value must be IEnumerable; it is iterated.
  • In a {#with}, the return value becomes the new local scope.
  • In a {?var:} or {?:}, the return value is stored in the variable.
  • In a {>partial}, the return value must be a non-null string template, which is then parsed and merged. Returning null (or a non-string) throws InvalidOperationException, surfaced as a MergeException.
  • In a {@}, the return value is written to trace listeners (see Debugging).

Returning null is permitted in most contexts: a null simple-tag value renders as empty string; a null if-condition is falsy; a null each-target skips the block; a null variable assignment stores null. The one exception is {>partial}, which requires a string.

Exception behavior

Exceptions thrown inside a lambda propagate as the InnerException of a MergeException, with the surrounding template position attached via MergeException.Context. See Exception Handling.

Composition with variables

Variables and lambdas compose naturally for counters, accumulators, and conditional formatting. The running-total example in Variables shows the pattern: declare a variable, reassign it on each iteration via a lambda that does the arithmetic.

Next steps

  • Variables — capture lambda return values for reuse later in the template
  • Debugging{@ (…) => fn} runs a lambda for its side-effecting trace output
  • Exception Handling — pinpoint the template position when a lambda throws