Your application almost certainly already has a localization stack —
Gettext for prose, and
ex_cldr (or the newer localize suite of
packages built on top of it) for dates, numbers, and currency. Don't
rebuild that stack inside your templates: keep Elixir as the source of
truth and inject resolved translations as data, the same way you inject
records (see the Templating guide for the general data
boundary).
Translating with Gettext
Resolve the strings for the requested locale at render time and encode them as a dictionary the template imports:
import MyApp.Gettext
defp translations(locale) do
Gettext.with_locale(MyApp.Gettext, locale, fn ->
%{
invoice: gettext("Invoice"),
date: gettext("Date"),
total: gettext("Total")
}
end)
end
def render_invoice(ctx, locale) do
data = """
#let locale = #{AshTypst.Code.encode(locale, %{})}
#let t = #{AshTypst.Code.encode(translations(locale), %{})}
"""
:ok = AshTypst.Context.set_virtual_file(ctx, "i18n.typ", data)
# ... inject records, compile, export
end#import "i18n.typ": locale, t
// `lang` improves hyphenation, smart quotes, and accessibility metadata.
// The font list falls back to a CJK font for characters the primary
// font lacks.
#set text(
lang: locale,
font: ("Libertinus Serif", "Noto Serif CJK SC"),
)
= #t.invoiceEverything Gettext gives you keeps working — .po files, translator
workflows, plurals, interpolation — because translation happens in Elixir
where the domain data lives.
Localizing data with calculations
For Ash resources, strings derived from record data — status labels, pluralized counts, formatted amounts — belong on the resource itself as public calculations. A module calculation can use Gettext (or CLDR) directly:
defmodule MyApp.Invoice.StatusLabel do
use Ash.Resource.Calculation
@impl true
def load(_query, _opts, _context), do: [:status]
@impl true
def calculate(records, _opts, _context) do
Enum.map(records, fn record ->
Gettext.gettext(MyApp.Gettext, "invoice.status.#{record.status}")
end)
end
endcalculations do
calculate :status_label, :string, MyApp.Invoice.StatusLabel, public?: true
endGettext's locale is process-scoped, so set it before running the action
(as a Phoenix plug typically already does) and every calculation resolves
in the caller's locale — no locale threading required. Calculations can
also take an explicit argument when ambient locale isn't appropriate.
This composes with everything else in these guides: the localized strings
are loaded by the query (load [:status_label] in the render action's
read block), encoded onto the record like any other field, and subject
to the same query-level compaction — templates that don't need them
simply don't load them.
Translated content with AshTranslation
When the content itself is stored in multiple languages — product names,
descriptions, any authored text — writing a calculation per field gets
tedious. ash_translation is a
simpler option: it stores per-locale values in an embedded translations
attribute on the resource and swaps them in on demand:
use Ash.Resource,
extensions: [AshTranslation.Resource]
translations do
public? true
fields [:name, :description]
locales [:it, :zh]
endrecord = Ash.get!(MyApp.Product, product_id)
translated = AshTranslation.translate(record, :zh)
# `translated.name` and `translated.description` now hold the zh values —
# encode it like any other record. Single fields:
# AshTranslation.translate_field(record, :name, :zh)Formatting dates, numbers, and currency
Locale-sensitive formatting follows the same rule: format in Elixir with
ex_cldr or
localize, exposed as calculations. Both
resolve against a process-scoped locale (Cldr.put_locale/1 /
Localize.put_locale/1), so the same plug that sets your Gettext locale
covers formatting too.
Currency with ex_cldr (using your MyApp.Cldr backend):
defmodule MyApp.Invoice.FormattedTotal do
use Ash.Resource.Calculation
@impl true
def load(_query, _opts, _context), do: [:total, :currency]
@impl true
def calculate(records, _opts, _context) do
Enum.map(records, fn record ->
# Uses the process locale: "$1,234.56" for en, "¥1,234.56" for zh/CNY
Cldr.Number.to_string!(record.total, MyApp.Cldr, currency: record.currency)
end)
end
endA humanized date with localize (no backend module needed):
defmodule MyApp.Invoice.IssuedOn do
use Ash.Resource.Calculation
@impl true
def load(_query, _opts, _context), do: [:issued_at]
@impl true
def calculate(records, _opts, _context) do
Enum.map(records, fn record ->
# "March 22, 2025" for en, "2025年3月22日" for zh
{:ok, formatted} = Localize.Date.to_string(record.issued_at, format: :long)
formatted
end)
end
endcalculations do
calculate :formatted_total, :string, MyApp.Invoice.FormattedTotal, public?: true
calculate :issued_on, :string, MyApp.Invoice.IssuedOn, public?: true
endThe template then treats record.formatted_total and record.issued_on
like any other string — no locale logic in Typst at all.
Dates encode as real Typst datetime values (timezone-shifted when the
encoding context sets timezone) — keep them that way when the template
needs an actual date to work with. When you want a humanized, localized
string instead, use a calculation as above.
Template-owned translations
The alternative is keeping a translations dictionary in the template layer:
// lib/i18n.typ
#let translations = (
en: (invoice: "Invoice", total: "Total"),
zh: (invoice: "发票", total: "总计"),
)
#let t(locale, key) = translations.at(locale).at(key)This trades away the Gettext toolchain, so reserve it for cases where it
earns its keep: designer-owned templates where translations are part of
the template artifact, or templates that must compile standalone with the
CLI (typst watch invoice.typ --root priv/typst --input locale=zh, read
via sys.inputs.at("locale", default: "en")).
Locale selection per pipeline
- Render actions — localized data comes from calculations loaded in
the
readblock, resolved in the caller's process locale. For the template's own chrome (headings, column labels), pass the locale as an action argument — it reaches the template asargs.locale— and use a template-owned dictionary keyed by it. (Render actions inject onlyrecord/records/argsintodata.typ; templateinputsare static DSL configuration.) - Direct
AshTypst.Context/ pool usage — same calculation-loaded data, plus full control of the boundary: inject a resolved translations file for chrome (Gettext approach) or set the locale withset_inputs(ctx, %{"locale" => locale})(template-owned approach).
Fonts for CJK output
Ship the fonts with your app rather than depending on the host system, and point the context at them:
typst do
root {:my_app, "priv/typst"}
font_paths [{:my_app, "priv/fonts"}]
end