Typst has no typst eval subcommand, but typst query is one in practice. It runs the
full compiler and emits JSON instead of a document, which is enough to use the language
as a scripting runtime — map/filter, regex, dictionaries, recursion, date formatting.
Everything below is verified against Typst 0.14.0 on Linux.
Wrap any value in #metadata(...), give it a label, and query the label:
echo '#metadata(range(15).map(x => x*x)) <o>' | typst query - '<o>' --field value --one
# [0,1,4,9,16,25,36,49,64,81,100,121,144,169,196]The flags that matter:
| flag | effect |
|---|---|
- |
read the source from stdin |
--field value |
unwrap the metadata element down to its payload |
--one |
drop the surrounding array (expect exactly one match) |
--format yaml |
YAML instead of JSON — those are the only two; there is no txt |
--input k=v |
pass arguments in, readable as sys.inputs.k (always a string) |
A slightly bigger example, exercising recursion, closures, regex and dates:
#let fib(n) = if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
#metadata((
fibs: range(15).map(fib),
sorted: ("pear", "apple", "fig").sorted(key: s => s.len()),
regex: "a1b22c333".matches(regex("\d+")).map(m => m.text),
date: datetime(year: 2026, month: 7, day: 28).display("[year]-[month]-[day]"),
)) <out>$ typst query eval.typ '<out>' --field value --one
{"fibs":[0,1,1,2,3,5,8,13,21,34,55,89,144,233,377],
"sorted":["fig","pear","apple"],
"regex":["1","22","333"],
"date":"2026-07-28"}echo '#metadata(int(sys.inputs.n) * 2) <o>' | typst query - '<o>' --field value --one --input n=21
# 42Match the element rather than a label and you get every #metadata in document order —
handy for instrumenting a real document instead of a scratch file:
printf '#metadata(1+1) <a>\n#metadata("two") <b>\n#metadata(range(3)) <a>\n' > multi.typ
typst query multi.typ '<a>' --field value # [2,[0,1,2]]
typst query multi.typ 'metadata' --field value # [2,"two",[0,1,2]]HTML export — real DOM output, still no PDF:
$ echo 'Hello #strong[world] and #calc.sqrt(2)' | typst compile --features html --format html - -
<p>Hello <strong>world</strong> and 1.4142135623730951</p>#panic(x) as a print statement. The value's repr lands in the error message and
compilation aborts, so nothing is ever written — though the CLI still wants an output
format to get that far:
$ echo '#let x = (1,2,3).map(v => v*10)
#panic(x)' | typst compile - /dev/null -f pdf
error: panicked with: (10, 20, 30)repr() serializes what JSON cannot hold — lengths, colors, functions, content:
$ echo '#metadata(repr((1pt, red, calc.pow, [hi *there*]))) <o>' \
| typst query - '<o>' --field value --one
"(1pt, rgb(\"#ff4136\"), pow, sequence([hi], [ ], strong(body: [there])))"tsr (in this gist) wraps the above into something usable:
tsr 'range(1,11).sum()' # → 55
tsr -r '(1pt, red, calc.pow)' # repr mode, for non-JSON values
tsr -p lib.typ 'range(1,8).map(fact)' # load a prelude of definitions first
tsr # interactive; :r toggles repr, :q quits$ tsr '{
let out = (:)
for w in ("apple","fig","pear","kiwi") {
let k = str(w.len())
out.insert(k, out.at(k, default: ()) + (w,))
}
out
}'
{
"5": ["apple"],
"3": ["fig"],
"4": ["pear", "kiwi"]
}Errors keep Typst's normal diagnostics, with the caret pointing into your expression:
$ tsr 'calc.sqrt(-1)'
error: cannot take square root of negative number
┌─ <stdin>:2:20
│
2 │ #metadata(calc.sqrt(-1)) <tsr>
│ ^^- No range-literal syntax.
(1..10)is a parse error; writerange(1, 10). let/forare statements, not expressions. Anything imperative needs a code block:{ let f(x) = x*x; range(5).map(f) }.queryruns layout, not just evaluation. A document that fails to lay out fails even when your expression is fine. A file containing nothing but#metadataprobes sidesteps this entirely.- Warnings share the output stream. Deprecations (
json.decodein 0.14, for instance) print alongside the result, so take the first line when parsing. sys.inputsvalues are always strings — cast withint()/float().