Skip to Content
HelpDocuments & TemplatesExpression & Filter Reference

Template Expression Reference

This article covers everything the V2 template engine can evaluate inside \{...\} tags: operators, filters, conditionals, deduplication, and practical recipes for common document structures.

If you are new to tag templates, read How Tag Templates Work first.


What the expression engine supports

ScopeStack templates use the docxtemplater Angular expression parser. It supports a specific subset of JavaScript — not all of JavaScript. Understanding the boundary prevents a common class of silent failures where a tag appears in the template but is never replaced in the output.

Supported operators

Arithmetic

{quantity + 10} {total_hours * 1} {project_pricing.total_contract_value * 1 | toFixed:2}

Comparison (used inside conditional blocks)

{#locations.length > 1} {#locations.length < 2} {#locations.length == 1} {#locations.length != 0} {#project_payments.pricing_model == "fixed_fee"} {#resource_slug == "project_total"}

Ternary

{unique ? "First occurrence" : "Duplicate"} {total_hours > 0 ? total_hours : "TBD"}

Logical (for inline expressions, not block conditionals)

{is_active && is_billable} {name || "Unnamed"}

What is NOT supported

The Angular parser does not support JavaScript array methods with callbacks, arrow functions, or most standard JavaScript methods. These fail silently — the tag is left unreplaced in the output without any error message.

What you might tryWhy it fails
\{services.filter(s => s.type == "managed")\}Arrow functions not supported
\{services.map(s => s.name)\}Arrow functions not supported
\{services.reduce((acc, s) => acc + s.hours, 0)\}Arrow functions not supported
\{name.indexOf("Cisco")\}String instance methods not supported
\{name.includes("Cisco")\}String instance methods not supported
\{name.replace("old", "new")\}String instance methods not supported
\{$index\}Requires custom evaluateIdentifier configuration; not enabled
\{services.length\} from inside \{#services\} loopCannot access parent scope arrays from within a loop

Use the available filters and block conditionals to accomplish these goals instead.


Pipe filters

Filters transform a value before it is rendered. The syntax is \{field | filterName\} or \{field | filterName:argument\}.

Filters are applied with a pipe (|) inside the tag, not with a dot. Do not confuse pipe filters (| toFixed:2) with the include-formatting dot accessors (.to_currency) — they are different systems with different syntax.

Math filters

FilterSyntaxOutput
toFixed`{valuetoFixed:2}`
abs`{valueabs}`
round`{valueround}`
ceil`{valueceil}`
floor`{valuefloor}`

toFixed is the most commonly used math filter, typically for displaying numeric pricing fields:

Total: {project_pricing.total_contract_revenue | toFixed:2} Hours: {total_hours | toFixed:1}

If a numeric field returns [object Object] instead of a number, it is a formatted object (see include-formatting below). Multiply by 1 to extract the raw number:

{project_pricing.total_contract_value * 1 | toFixed:2}

String filters

FilterSyntaxOutput
upperCase`{nameupperCase}`
lowerCase`{namelowerCase}`
trim`{nametrim}`
trimStart`{nametrimStart}`
trimEnd`{nametrimEnd}`
split`{tagssplit:”,”}`
join`{itemsjoin:”, ”}`

upper and lower are aliases for upperCase and lowerCase and can be used interchangeably.

Statistics filters (for arrays)

These filters operate on an array and return a single value.

FilterSyntaxDescription
sum`{amountssum}`
sumBy`{servicessumBy:“total_hours”}`
min`{valuesmin}`
max`{valuesmax}`
mean`{valuesmean}`
meanBy`{servicesmeanBy:“total_hours”}`

Array filters

FilterSyntaxDescription
sortBy`{servicessortBy:“name”}`
orderBy`{servicesorderBy:“name”:“asc”}`
where`{serviceswhere:“type == ‘managed’”}`
slice`{itemsslice:0:5}`
take`{itemstake:5}`
chunk`{itemschunk:3}`
chunkBy`{itemschunkBy:“category”}`
subloop`{itemssubloop:“groupKey”}`
recur`{itemsrecur}`

Date filters

FilterSyntaxDescription
formatDate`{dateformatDate:“MM/DD/YYYY”}`
addDays`{dateaddDays:30}`
subtractDays`{datesubtractDays:7}`
addMonths`{dateaddMonths:3}`
subtractMonths`{datesubtractMonths:3}`
addYears`{dateaddYears:1}`
subtractYears`{datesubtractYears:1}`

The special identifier _now_ returns the current date and time:

{_now_ | formatDate:"MM/DD/YYYY"}

Important: formatDate uses token format strings, not the same format as to_short_date. Known tokens: Y (year), MM (zero-padded month), M (month), DD (zero-padded day), D (day), HH (hours), mm (minutes), ss (seconds).

Also note: formatDate can return NaN when Include-Formatting is enabled and the field is a formatted date object rather than a raw string. When Include-Formatting is on, use .to_short_date instead (see next section).

Array join filters

These filters combine two arrays by matching keys, similar to SQL JOIN operations.

FilterSyntaxDescription
leftJoin`{arrayAleftJoin}`
rightJoin`{arrayArightJoin}`
innerJoin`{arrayAinnerJoin}`
fullJoin`{arrayAfullJoin}`

Image filters

Image filters control the dimensions of images rendered in the document. They are applied to fields that contain an image URL or image object.

FilterSyntaxDescription
scale`{imagescale:0.5}`
minHeight`{imageminHeight:100}`
maxHeight`{imagemaxHeight:200}`
minWidth`{imageminWidth:100}`
maxWidth`{imagemaxWidth:300}`
minSize`{imageminSize:100:100}`
size`{imagesize:200:100}`
maxSize`{imagemaxSize:300:200}`

Miscellaneous filters

FilterSyntaxDescription
link`{urllink:“Click here”}`
contain`{imagecontain}`
to2d`{itemsto2d}`

The link filter renders an HTML anchor tag. Only useful when the output format supports HTML links.


Include-Formatting dot accessors

When Include-Formatting is enabled on the template, currency and date fields become objects rather than raw values. To display them, you must access a property on the object using dot notation — not a pipe filter.

AccessorApplied toOutput
.to_currencyAny currency field$2,000.00 (account currency)
.to_short_dateAny date fieldMM/DD/YYYY formatted
.to_long_dateAny date fieldLong format (e.g., January 15, 2025)
.to_formatted_timeAny date/time fieldAccount-formatted time
.valueAny formatted objectRaw numeric or string value

Without Include-Formatting enabled, currency fields are plain numbers and toFixed:2 is the right approach. With Include-Formatting enabled, .to_currency is required — calling toFixed:2 on a currency object will fail.

{total.to_currency} {project.printed_on.to_short_date} {amount_due.to_currency} {hourly_rate.to_currency} {project_pricing.professional_services.net_revenue.to_currency}

The .value accessor extracts the raw number from a formatted currency object, which you can then use in further math:

{amount_due.value | toFixed:0}

Conditional logic

Truthy block: \{#field\}

Renders content when the field exists and is not empty, zero, false, or null.

{#project.executive_summary} Content to show when summary exists. {/project.executive_summary}

Falsy (inverted) block: \{^field\}

Renders content when the field is empty, missing, false, zero, or null.

{^project.msa_date} The client has no MSA on file. This project is subject to standard terms. {/project.msa_date}

Truthy and falsy together

Use \{#field\} and \{^field\} on the same field to produce if/else behavior. Both blocks close with the same \{/field\} tag.

{#project.msa_date} The client signed an MSA on {project.msa_date}. {/project.msa_date} {^project.msa_date} The client does not have an MSA on file. {/project.msa_date}

String equality: \{#field=="value"\}

Evaluates content only when the field exactly equals the given string. The closing tag must include the full expression.

{#project_payments.pricing_model=="fixed_fee"} This is a fixed-fee engagement. {/project_payments.pricing_model=="fixed_fee"} {#project_payments.pricing_model=="time_and_materials"} This is a time-and-materials engagement. {/project_payments.pricing_model=="time_and_materials"}

Common mistake: Using curly (typographic) quotes instead of straight quotes. Word sometimes auto-corrects quotes inside tags. Straight quotes (") are required — curly quotes (" ") will break the conditional.

Negated string equality: \{^field=="value"\}

Renders when the field does NOT equal the value.

{^resource_slug=="project_total"} {resource_name} | {quantity} | {total.to_currency} {/resource_slug=="project_total"}

This is the standard pattern for excluding the project total row from a resource pricing table.

Array length comparisons: \{#array.length > N\}

Use .length with a comparison operator to branch based on how many items are in an array.

{#locations.length > 0} Service locations are defined for this project. {/locations.length > 0} {^locations.length > 0} No service locations are defined. {/locations.length > 0}

Supported comparison operators: >, <, >=, <=, ==, !=.

The closing tag must exactly match the opening expression, including spaces and the operator:

{#project_payments.schedule.length > 0} ... {/project_payments.schedule.length > 0}

Note: .length checks on a field work at the top-level scope. You cannot check \{services.length\} from inside a \{#services\} loop — the parent scope is inaccessible from within a loop iteration.


Deduplication with \{#unique\}

When a project has services with the same name appearing in multiple locations or phases, the merge data includes a unique boolean field on each service. The first occurrence of a service name (globally, across all locations) has unique: true; subsequent occurrences have unique: false.

Use \{#unique\} inside a services loop to display each service name only once:

{#services} {#unique} {name} {/unique} {/services}

How unique works

  • Scope is global, not per-location or per-phase. A service named “Network Setup” that appears in three locations will have unique: true only on its first global occurrence.
  • unique_count is available on each service and holds the total number of times that service name appears across the project.
  • The unique flag is available in V2 merge data. Do not use \{#unique?\} — the ? suffix crashes the V2 Angular parser because ? is interpreted as the start of a ternary expression.

Showing a service with a count

{#services} {#unique} {name} (x{unique_count}) {/unique} {/services}

Dedup and service descriptions

Inside a \{#unique\} block, only the first instance’s fields are accessible — including service_description. If multiple services share the same name but have different descriptions, the later occurrences’ descriptions are dropped when using \{#unique\} alone.

If you need to surface descriptions from all occurrences of a deduplicated name, check whether unique_descriptions is available on your account (it returns a concatenated list of all descriptions for that service name). If it is not available, the workaround is to collect descriptions before the dedup pass or to use a non-deduplicated loop and handle display logic separately.

When NOT to use \{#unique\}

If your template uses Location-First structure (loop starts with \{#locations\}), each location already shows only that location’s services. Using \{#unique\} inside a per-location loop will suppress services that share names with services in other locations, which is probably not what you want.

Use \{#unique\} in Phase-First templates when you want a flat, deduplicated list of services across the whole project.


Branching on location count

A common template challenge: single-location projects benefit from a simpler layout, while multi-location projects need location-by-location structure. Use .length comparisons on locations to branch.

{#locations.length < 2} Single-location layout: {#project_pricing.professional_services.phases} {#services} {#unique} {name} {~~formatted_service_description} {/unique} {/services} {/project_pricing.professional_services.phases} {/locations.length < 2} {^locations.length < 2} Multi-location layout: {#locations} Location: {name} {#phases} {#services} {name} {~~formatted_service_description} {/services} {/phases} {/locations} {/locations.length < 2}

\{^expr\} fires when the expression is falsy — that is, when locations.length < 2 is false, meaning there are 2 or more locations.

The multi-location branch loops directly over \{#locations\}, which provides a phases array in context. The single-location branch uses the top-level project_pricing.professional_services.phases path since no location loop is active. One template handles both project types without any changes between single-location and multi-location engagements.


Language field fallback text

Language fields (deliverables, assumptions, customer responsibilities, out of scope) aggregate sentences from all services. When no services have content for a given language field type, the sentences array is empty.

The has_language? field that existed in V1 is not usable in V2 templates — the ? suffix crashes the parser. Use the top-level sentences array on each language field as the guard instead:

{#language_fields} {#slug=="deliverable"} {#sentences} Deliverables {#phases} {#sentences} {.} {/sentences} {/phases} {/sentences} {^sentences} No specific deliverables are defined for this project. {/sentences} {/slug=="deliverable"} {/language_fields}

The outer \{#sentences\} block gates the entire section, including the heading. The inner \{#phases\}\{#sentences\} iterates per-phase sentences. The \{^sentences\} block provides fallback text when the language field has no content at all.

Some language fields include an optional introduction block. Render it conditionally so it only appears when content exists:

{#language_fields} {#slug=="deliverable"} {#formatted_introduction} {~~formatted_introduction} {/formatted_introduction} {#sentences} {#phases} {#sentences} * {.} {/sentences} {/phases} {/sentences} {^sentences} * There are no specific deliverables for this project. {/sentences} {/slug=="deliverable"} {/language_fields}

Why not use \{^phases\}? The phases array is never empty — it always has an entry for each phase in the project, even when no services have language content. The sentences array at the language field level is the correct guard: it is empty when no content exists.

Per-service language field fallback

When looping over services directly (rather than through language_fields), each service exposes its language field content as a nested object. The sentences array on that object uses the same guard pattern:

{#services} {#deliverables} {#sentences} {sentences} {/sentences} {^sentences} No deliverables defined for this service. {/sentences} {/deliverables} {/services}

The sentences array holds the individual text blocks for that field on that service. When the service has no content for that language field type, sentences is empty and \{^sentences\} fires. This is the V2-safe replacement for has_language? (which crashes the parser — see the ? field table above).


Conditional Fallbacks: The Array Emptiness Trap

The \{^field\} inverted block fires when a field is falsy: null, undefined, empty string, the number zero, or false. Arrays follow different rules.

An array that contains items is truthy, even if those items have no useful content. This is standard JavaScript truthiness behavior, and it catches nearly every author who tries to write language-field fallback text.

The symptom: you write \{^phases\}No content defined.\{/phases\} expecting it to appear when services have no language. It never appears. The project always has phase entries, so phases is always a non-empty array, which is always truthy. The \{^phases\} block never fires regardless of whether any sentences exist.

The correct pattern is to test sentences, not phases. The sentences array at the top of a language field is empty when no services have contributed content to that field type. It is the right guard for fallback text.

{#language_fields} {#slug=="deliverable"} {#sentences} Deliverables {#phases}{#sentences}{.}{/sentences}{/phases} {/sentences} {^sentences} No deliverables are defined for this project. {/sentences} {/slug=="deliverable"} {/language_fields}

The same trap applies to any nested array. Testing \{^locations\} to detect a project with no locations will work, but testing \{^locations\} to detect that locations have no services will not — the locations array has items even when its services are empty.

Fields ending in ? that crash the parser

The V2 Angular parser treats ? as the start of a ternary expression. Any tag containing a field name that ends in ? causes a hard crash — all tags in the document come through unreplaced.

The following fields from the raw merge data end in ? and are not usable in V2 templates:

FieldWhat to use instead
has_language?\{#sentences\} on the language field object
deliverables?\{#sentences\} inside the deliverable language field
out?\{#sentences\} inside the out language field
assumptions?\{#sentences\} inside the assumptions language field
customer?\{#sentences\} inside the customer language field
unique?\{#unique\} (no ?)

If all tags in a document come through unreplaced, check whether any tag in the template contains a ? character.


Common recipes

List services by phase

Phase-First structure — use when you do not need to group by location.

{#project_pricing.professional_services.phases} {^slug=="no_phase"} Phase: {name} {/slug=="no_phase"} {#services} {name} {~~formatted_service_description} {#subservices} ({quantity}) {name} {#service_description}{.}{/service_description} {/subservices} {/services} {/project_pricing.professional_services.phases}

The \{^slug=="no_phase"\} block hides the phase heading for services that have no assigned phase. Phases have a slug field — common slugs are no_phase, inhouse_prep_language, onsite_implement_language, remote_implement_language, and post_support_langauge (note: the trailing “langauge” typo is in the data model and must be spelled that way).

Deduplicated service list with counts

{#project_pricing.professional_services.phases} {#services} {#unique} {name} — {unique_count} location(s) {~~formatted_service_description} {/unique} {/services} {/project_pricing.professional_services.phases}

Pricing resource table

In a Word table, place the loop tags at the start and end of the data row. The entire row repeats per item.

{#project_pricing.resources} {^resource_slug=="project_total"} {resource_name} | {quantity} | {hourly_rate.to_currency} | {total.to_currency} {/resource_slug=="project_total"} {/project_pricing.resources} Total Professional Services: {project_pricing.professional_services.net_revenue.to_currency}

Payment schedule (fixed-fee only)

{#project_payments.pricing_model=="fixed_fee"} Payment Schedule: {#project_payments.schedule.length>0} {#project_payments.schedule} {description} — {amount_due.to_currency} {/project_payments.schedule} {/project_payments.schedule.length>0} {^project_payments.schedule.length>0} Payment terms to be agreed upon contract execution. {/project_payments.schedule.length>0} {/project_payments.pricing_model=="fixed_fee"}

Conditional text when a field is empty

Use \{#field\} and \{^field\} together to show alternate content when a field has no value.

{#project.executive_summary.length>0} Executive Summary {#project.executive_summary} {.} {/project.executive_summary} {/project.executive_summary.length>0} {^project.executive_summary.length>0} No executive summary has been entered for this project. {/project.executive_summary.length>0}

Place headings inside the guard block so they do not appear when the section is empty.

Language field fallback (per type)

{#language_fields} {#slug=="deliverable"} Deliverables {#sentences} {#phases} {#sentences}{.}{/sentences} {/phases} {/sentences} {^sentences} No deliverables are defined for this project. {/sentences} {/slug=="deliverable"} {#slug=="assumptions"} Key Assumptions {#sentences} {#phases} {#sentences}{.}{/sentences} {/phases} {/sentences} {^sentences} No specific assumptions are documented for this project. {/sentences} {/slug=="assumptions"} {#slug=="customer"} Customer Responsibilities {#sentences} {#phases} {#sentences}{.}{/sentences} {/phases} {/sentences} {^sentences} No customer responsibilities are defined for this project. {/sentences} {/slug=="customer"} {#slug=="out"} Out of Scope {#sentences} {#phases} {#sentences}{.}{/sentences} {/phases} {/sentences} {^sentences} No out-of-scope items are documented for this project. {/sentences} {/slug=="out"} {/language_fields}

Formatting a currency value

With Include-Formatting enabled on the template:

{total.to_currency} {hourly_rate.to_currency} {project_pricing.total_contract_revenue.to_currency}

Without Include-Formatting (numeric fields only):

{total | toFixed:2} {project_pricing.total_contract_revenue | toFixed:2}

If a numeric field returns [object Object], Include-Formatting is on and the field needs .to_currency, not toFixed. If a currency field returns NaN, Include-Formatting may be off and the field is already a number — use toFixed:2 instead.

Conditional on a custom variable

{#project.user_defined_fields.msa_on_file.value=="yes"} This project is covered by an existing Master Services Agreement. {/project.user_defined_fields.msa_on_file.value=="yes"} {^project.user_defined_fields.msa_on_file.value=="yes"} This project is subject to our standard terms and conditions. {/project.user_defined_fields.msa_on_file.value=="yes"}

Failure modes and silent errors

The most important thing to understand about template expressions is how they fail.

Unsupported expressions fail silently. If you write \{services.filter(s => s.type == "managed")\}, the document will render as “finished” but the tag will appear unreplaced in the output — the literal text \{services.filter(s => s.type == "managed")\} shows up in the document. There is no error message.

One crash kills the entire template. If any expression in the template causes a hard crash (as opposed to a silent failure), all tags in the document come through unreplaced. This commonly happens when:

  • A tag contains a ? suffix (e.g., \{#unique?\}) — the ? is parsed as the start of a ternary expression, crashing the parser
  • Curly (typographic) quotes are used in a string equality check
  • A closing tag does not exactly match its opening tag

Fields ending in ? are not usable in V2 templates. This affects several fields from the raw merge data, including has_language? on language fields. The V2-safe alternatives are documented in the sections above.

To diagnose template issues, use the validate_template_tags tool against a real project. It checks every tag against live merge data and flags tags that will not resolve.

Last updated on