Template helper reference
Everything a template can write, with the output each example actually produces. Every example on
this page is asserted in HelperReferenceTests — a reference that drifts from the code is worse
than no reference, because someone will follow it.
The data used throughout:
{
"customer": { "name": "Acme Ltd", "vatNumber": null },
"invoice": {
"number": "INV-1001",
"issuedOn": "2026-08-12T00:00:00",
"total": 1875.5,
"paid": false,
"lines": [
{ "description": "Consulting", "amount": 1500, "hours": 10 },
{ "description": "Support", "amount": 375.5, "hours": 3 }
]
}
}
Values
| Written | Renders |
|---|---|
{{customer.name}} |
Acme Ltd |
{{invoice.lines.[0].description}} |
Consulting |
{{customer.nickname}} |
(nothing) |
{{invoice.lines.Count}} |
2 |
An unknown path renders as nothing. There is no input schema by design, so a field the caller did not send is empty rather than an error — a typo costs you a blank, not a failed report.
An unknown helper is not so forgiving. {{t "invoice.title"}} fails the whole report with
TemplateBindingException naming the helper. Paths are lenient; helper names are not. Note
Count, not length — the model is bound as .NET objects, and length returns nothing.
{{ }} escapes. {{{ }}} does not — see Security before reaching for it.
Conditionals
| Written | Renders |
|---|---|
{{#if invoice.paid}}paid{{else}}due{{/if}} |
due |
{{#unless invoice.paid}}due{{/unless}} |
due |
{{#if customer.vatNumber}}vat{{else}}none{{/if}} |
none |
null, false, 0, an empty string and an empty array are all falsy.
Because an attribute value is just text, this is also how conditional styling works:
<tr class="{{#if invoice.paid}}paid{{else}}unpaid{{/if}}">
renders <tr class="unpaid">. The same trick works for an inline style, a colspan, anything.
Comparisons and logic
These return values rather than writing, so they work as subexpressions inside a condition:
| Helper | Example | Renders |
|---|---|---|
gt gte lt lte |
{{#if (gt invoice.total 1000)}}large{{else}}small{{/if}} |
large |
eq ne |
{{#if (eq customer.name "Acme Ltd")}}yes{{/if}} |
yes |
lte |
{{#if (lte invoice.total 1875.5)}}within{{/if}} |
within |
and not |
{{#if (and (gt invoice.total 1000) (not invoice.paid))}}chase{{/if}} |
chase |
or |
{{#if (or invoice.paid (gt invoice.total 5000))}}quiet{{else}}chase{{/if}} |
chase |
Nest them freely — the brackets are what make it a subexpression rather than an argument.
Iteration
| Written | Renders |
|---|---|
{{#each invoice.lines}}{{description}};{{/each}} |
Consulting;Support; |
{{#each invoice.lines}}{{@index}}{{/each}} |
01 |
{{#each invoice.lines}}{{#unless @last}}{{description}}, {{/unless}}{{/each}} |
Consulting, |
{{#with customer}}{{name}}{{/with}} |
Acme Ltd |
Inside #each: @index (from 0), @first, @last, @key for objects, and this for the current
item. @last is what stops a trailing separator — or a trailing page break, which is a blank final
page.
Numbers, money and dates
| Written | Renders |
|---|---|
{{number invoice.total "N2" "en-US"}} |
1,875.50 |
{{money invoice.total "en-US"}} |
$1,875.50 |
{{date invoice.issuedOn "dd MMM yyyy" "en-GB"}} |
12 Aug 2026 |
{{date invoice.issuedOn "d" "en-US"}} |
8/12/2026 |
The second argument is a culture if it contains a hyphen, and a format otherwise — so
{{money x "en-US"}} and {{money x "C2" "en-US"}} both work.
Three traps, all covered in Pagination and right-to-left:
{{number}}without a format does not group. The default isG, the round-trip format:1234567.5renders as1234567.5. Ask for"N2".ar-SAreturns Hijri dates. Usear-EGorar-AEfor Gregorian output in Arabic.- No culture produces Arabic-Indic digits. They change separators only.
Formats are standard .NET format strings, so anything valid in ToString(format, culture) works.
Getting data into JavaScript
<script id="report-data" type="application/json">{{json}}</script>
Put this in the template, not in a page. The data belongs to the report, and the template is
merged into every page — so writing it once puts a copy in each, already bound. A new report is
created with it. {{json invoice.lines}} writes a slice instead of the whole payload; if a page
ever needs its own, give that island a different id, because getElementById answers with the
first of two.
renders the model as JSON — [{"description":"Consulting","amount":1500,…}] — for chart code to
read:
const model = JSON.parse(document.getElementById('report-data').textContent);
This is the sanctioned route. Building JavaScript string literals out of {{ }} is not, and the
exporter flags it: the encoder used here is what stops a value containing </script> from ending
the island early, and string concatenation has no such protection.
{{{json .}}} writes the whole model.
Page breaks
{{pageBreak}}
renders <div style="break-after: page"></div>. Use it when the break comes from the data:
{{#each invoices}}
<h1>{{number}}</h1>
{{#unless @last}}{{pageBreak}}{{/unless}}
{{/each}}
When the break is structural, prefer CSS — section { break-before: page } — and see
Pagination and right-to-left for the rest of it.
What is not here
- No
t/ localization helper. Reports needing two languages currently need two templates. - No arithmetic. Compute totals in the data rather than in the template.
- No partials or template inclusion. One report is one file.
- No date arithmetic. Send the dates you intend to print.
The engine binds what it is given. Anything the report has to work out belongs in the application that calls it, where it can be tested.