This guide covers how to structure and write Typst templates the AshTypst way: templates are pure Typst, and Elixir data crosses into them as encoded values — never by building Typst source with string interpolation.
Philosophy: data in, documents out
A common first instinct is to reach for EEx and interpolate values into Typst markup the way you would build HTML. AshTypst deliberately does not work that way, for a few reasons:
- Typst already is a template language. It has variables, functions,
loops, conditionals, imports, and a styling system (
set/showrules). Layering EEx on top duplicates all of that, poorly, in a second language with a second escaping discipline. - String interpolation is code injection. Typst markup is code. A
customer named
#panic()— or more realistically, a name containing#,",\, or]— interpolated raw into markup will at best break the compile and at worst execute template logic you didn't write. - Escaping cannot fix that, because it is context-dependent. Typst is
several syntaxes in one: markup mode, code mode, math mode, and string
literals each have different special characters and different escape
rules. Which characters are dangerous depends on where in the template
the value lands —
"is harmless in markup but terminates a string in code mode;$is harmless in a string but switches markup into math mode. Escaping correctly would mean parsing the surrounding Typst at every insertion point, which is impractical. TheAshTypst.Codeprotocol sidesteps the problem instead of solving it: values are never spliced into an unknown syntactic context — they are emitted as complete, well-formed Typst values (a properly quotedstr, adatetime(...), a dictionary) that mean the same thing everywhere an expression is valid. - Stable templates compile faster. Typst memoizes compilation process-wide. When the template is a fixed artifact and only the data changes, repeated renders reuse most of the compilation work. A template regenerated per render by EEx defeats that cache.
- Pure Typst files keep the tooling. Templates that are plain
.typfiles work with the Typst CLI, the LSP, and typst.app — your designers don't need Elixir running to iterate on layout (more on this below).
The mental model: treat a template like a function and your Elixir data like its arguments. The template's job is presentation; the query's job is providing exactly the data the presentation needs.
The data boundary
There are three channels for getting information into a template:
Encoded virtual files — the main channel for structured data. Encode any supported Elixir value (or stream a large dataset) into an in-memory
.typfile the template imports:data = "#let record = #{AshTypst.Code.encode(record, %{})}\n" :ok = AshTypst.Context.set_virtual_file(ctx, "data.typ", data)#import "data.typ": record = Invoice for #record.customer_nameThe Ash resource extension does this for you: render actions inject
record(single result),records(list), and/orargs(action arguments) intodata.typ.sys.inputs— for small string parameters that select behavior rather than carry content: a locale, a theme name, a document id. Values are always strings:AshTypst.Context.set_inputs(ctx, %{"locale" => "zh", "theme" => "print"})#let locale = sys.inputs.at("locale", default: "en")Files under
:root— for the static side of templating: shared template libraries, images, data files that ship with your app. See the Get Started guide for the sandbox rules.
Assets
Static images live under
:rootand are referenced relatively:#image("assets/logo.png").Dynamic images (per-tenant logos, generated charts) are injected as binary virtual files and read explicitly:
:ok = AshTypst.Context.set_virtual_file_binary(ctx, "logo.png", png_binary)#image(read("logo.png", encoding: none))Packages from Typst Universe import as usual (
#import "@preview/cetz:0.3.1"); they are downloaded once per machine and cached on disk.
Rendering pipelines
How you hold the context depends on the workflow:
- Render actions (the common case): declare templates on the resource and let the extension manage contexts — they are pooled and scrubbed between renders automatically.
- Request-scoped rendering without Ash: wrap each render in
AshTypst.ContextPool.with_context/2. - Long-lived sessions (an editor holding state across keystrokes): keep a dedicated context and re-set only what changed between compiles.
See the Get Started guide for examples of each.
Project layout
A workable layout for anything beyond a single template:
priv/typst/
├── invoice.typ # entry template, one per document kind
├── statement.typ
├── lib/
│ ├── theme.typ # colors, set/show rules, page setup
│ ├── components.typ # reusable pieces: address blocks, totals tables
│ └── i18n.typ # translations (see below)
├── assets/
│ └── logo.png
└── data.typ # fixture data for standalone developmentEntry templates stay thin — import the pieces, import the data, apply them:
// invoice.typ
#import "lib/theme.typ": theme
#import "lib/components.typ": address-block, totals
#import "data.typ": record, args
#show: theme
= Invoice #args.invoice_number
#address-block(record.customer)
#totals(record.line_items)Two properties of this layout do a lot of work:
Virtual files shadow disk files. The
data.typon disk holds fixture data, so the template compiles standalone. At render time your app injects the realdata.typas a virtual file, which takes precedence. Same template, no modification, real data.The Typst CLI sees the same world. Because everything under
priv/typstis plain Typst, layout work needs no Elixir at all:typst watch invoice.typ --root priv/typst --input locale=zh
Writing template components
Use Typst functions for anything that repeats, and keep styling in
set/show rules rather than sprinkled through content:
// lib/components.typ
#let address-block(party) = block[
*#party.name* \
#party.street \
#party.city, #party.postal_code
]
#let totals(items) = {
let total = items.map(i => i.amount).sum()
table(
columns: (1fr, auto, auto),
..items.map(i => (i.description, str(i.quantity), [#i.amount])).flatten(),
[*Total*], [], [*#total*],
)
}// lib/theme.typ
#let theme(doc) = {
set page(margin: 2cm)
set text(10pt, font: ("Libertinus Serif",))
show heading.where(level: 1): set text(16pt, weight: "bold")
doc
}Optional and varying data
The built-in encoding is compacted to the query (see AshTypst.Code):
fields the query didn't select or load are absent from the encoded
dictionary, not none. Ideally each render action's query provides exactly
what its template references, and this never comes up. When one template
serves actions with different loads, read the varying fields with a
default:
#let notes = record.at("notes", default: none)
#if notes != none [ == Notes \ #notes ]Values that were queried but are empty (a belongs_to that resolved to
nothing, a nullable attribute) come through as none — so record.at(...)
with a none default handles both cases with one idiom.
Large documents
For documents rendering thousands of records, three levers matter:
- Stream the data.
AshTypst.Context.stream_virtual_file/4encodes an enumerable into the virtual file in batches, so Elixir memory stays flat regardless of dataset size. Render actions with:manycardinality stream automatically (batch_sizeis configurable on thereadblock). - De-select what the template doesn't use. Encoded output is Typst
source the compiler parses and evaluates on every compile — unused
columns are pure overhead, multiplied by row count. Use
selectin the render action'sreadblock (orAsh.Query.select/2) to keep the payload minimal. - Let the template do the aggregation it can't avoid, and the query do the rest. Sums and counts your data layer can compute (Ash aggregates) are cheaper there than in template code iterating a huge array.