Skip to content

Instantly share code, notes, and snippets.

@Kreijstal
Created July 29, 2026 03:15
Show Gist options
  • Select an option

  • Save Kreijstal/b685a82e291d6b7876d6c7a2647201ee to your computer and use it in GitHub Desktop.

Select an option

Save Kreijstal/b685a82e291d6b7876d6c7a2647201ee to your computer and use it in GitHub Desktop.
Using Typst as a general-purpose evaluator — running functions with no PDF in mind (typst query, HTML export, and a small REPL). Verified on Typst 0.14.

Using Typst as a general-purpose evaluator (no PDF involved)

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.

The core trick

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"}

Passing arguments in

echo '#metadata(int(sys.inputs.n) * 2) <o>' | typst query - '<o>' --field value --one --input n=21
# 42

Several probes in one run

Match 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]]

Other ways out of the compiler

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])))"

A REPL

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>
  │                     ^^

Gotchas

  • No range-literal syntax. (1..10) is a parse error; write range(1, 10).
  • let / for are statements, not expressions. Anything imperative needs a code block: { let f(x) = x*x; range(5).map(f) }.
  • query runs layout, not just evaluation. A document that fails to lay out fails even when your expression is fine. A file containing nothing but #metadata probes sidesteps this entirely.
  • Warnings share the output stream. Deprecations (json.decode in 0.14, for instance) print alongside the result, so take the first line when parsing.
  • sys.inputs values are always strings — cast with int() / float().
#!/usr/bin/env bash
# tsr — evaluate Typst expressions, no PDF involved.
# tsr '1 + 1' evaluate one expression
# tsr -r 'red' show repr() instead of JSON-encoding
# tsr -p lib.typ 'myfn(3)' load a prelude file first
# tsr interactive REPL (multiline via trailing \)
set -uo pipefail
PRELUDE=""; MODE=json
while getopts "rp:" o; do
case $o in
r) MODE=repr ;;
p) PRELUDE=$(<"$OPTARG") ;;
esac
done
shift $((OPTIND - 1))
run() {
local expr=$1 wrap
if [ "$MODE" = repr ]; then wrap="#metadata(repr($expr)) <tsr>"
else wrap="#metadata($expr) <tsr>"; fi
local out
out=$(printf '%s\n%s\n' "$PRELUDE" "$wrap" \
| typst query - '<tsr>' --field value --one 2>&1)
if [ $? -ne 0 ]; then printf '%s\n' "$out" >&2; return 1; fi
# strip warnings (they go to the same stream), keep the JSON line
local json; json=$(printf '%s\n' "$out" | head -1)
if [ "$MODE" = repr ]; then
python3 -c 'import json,sys; print(json.loads(sys.stdin.read()))' <<<"$json"
else
python3 -c 'import json,sys; print(json.dumps(json.loads(sys.stdin.read()),indent=2))' <<<"$json" \
|| printf '%s\n' "$json"
fi
}
if [ $# -gt 0 ]; then run "$*"; exit $?; fi
echo "typst repl — :r toggles repr mode, :q quits, trailing \\ continues"
buf=""
while IFS= read -r -e -p "$([ -n "$buf" ] && echo '... ' || echo 'typ> ')" line; do
case "$line" in
:q) break ;;
:r) MODE=$([ "$MODE" = repr ] && echo json || echo repr); echo "mode: $MODE"; continue ;;
esac
if [ "${line: -1}" = '\' ]; then buf+="${line%\\}"$'\n'; continue; fi
buf+="$line"
[ -n "${buf// }" ] && run "$buf"
buf=""
done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment