Tips on debugging Typst code, including the dark magic

To help navigation, sections are put into collapsible blocks. Click to toggle them.

Hover to see the value

Hover to see the value

You can hover a variable to see its value. This is supported by both Typst.app in browsers and VS Code with Tinymist. In Helix with Tinymist, you can use Space+K to show the value of the variable under the cursor. The same keymap applies to de facto LSP configuration in Neovim.

Copyable code
#let f(x) = {
  x
}

#f[1]
#for _ in range(5) { f("X") }
#f[= Heading]

If the variable is a function, you have to make sure the function is actually called somewhere. Otherwise, typst can’t capture its value.

Put variables in the document

Put variables in the document

You can put variables directly in the document.
However, you might meet Error: Cannot join content with … occasionally:

Code
#set text(font: ("Libertinus Serif", "Noto Serif CJK SC"))

#show heading: it => {
  it
  text.font
}

= Heading

This is because your function returns multiples things, but they’re not compatible to be joined.

A minimal example of that error
#(
  [= This is a content],
  ("This", "is", "an", "array"),
).join() // Error: Cannot join content with array

Docs: Defining functions
If the function body is a code block, the return value is the result of joining the values of each expression in the block.

Solution A: Convert it to the content type by wrapping with […] and switching to the markup mode.

#show heading: it => {
  it
  [#text.font] // 👈
}

  • Pros: It’s highlighted.
  • Cons:
    • If the return value will be processed by other functions, then the content type might be problematic.
    • none is omitted.

Solution B: Convert it to the str type with the repr function.

#show heading: it => {
  it
  repr(text.font) // 👈
}

  • Pros: The str type is simpler and sometimes safer to other functions.
  • Cons:
    • No syntax highlighting.
    • The result of repr is too verbose for heading and other contents.

Inspect simple data with type()

Refer to Type Type – Typst Documentation for details.

Inspect document elements with it.fields()

Inspect document elements with it.fields()

it.fields() and it.func() is available.

- A
- B
  - C
  - D

Calling them in show list: it => { … } for the above example results in the following

Full code
#show list: it => {
  set raw(lang:"typc")
  [`it.func() =` #it.func()]
  linebreak()
  [`it.fields() =` #it.fields()]
}

- A
- B
  - C
  - D

With that said, auto completion / tab menu would suffice most needs.

Inspect any type or data structure with repr + assert

Inspect any type or data structure with repr + assert

Since .fields() is only available for content types, repr can do the same thing, but for any type. Its formatted output can be put in the document, or it can be paired with panic and assert. The main difference is that panic isn’t suited for multi-line strings (which repr returns), but assert.message can handle it just fine, though you’d need to pass false as condition to always show the error. This way the document can stay clean, and the important output can stay separate, to be able to see it at a glance.

Note that because of the contextual nature, panic/assert for some context code might not be triggered.

= Heading

#context {
  let heading = query(heading).first(default: none)
  // repr(heading)
  // panic(repr(heading))
  assert(false, message: repr(heading))
}
Example output with repr + assert
error: assertion failed: heading(
  level: 1,
  depth: 1,
  offset: 0,
  numbering: none,
  supplement: [Section],
  outlined: true,
  bookmarked: auto,
  hanging-indent: auto,
  body: [Heading],
)

Pack complex content with data loading functions

Pack complex content with data loading functions

You can use data loading functions to encode anything.
They are handier than repr at times

#yaml.encode($ 1/2 x^2 $)
func: equation
block: true
body:
  func: sequence
  children:
  - func: frac
    num:
      func: text
      text: '1'
    denom:
      func: text
      text: '2'
  - func: space
  - func: attach
    base:
      func: symbol
      text: x
    t:
      func: text
      text: '2'

Pass things to stderr as errors or warnings

Pass things to stderr with errors or warnings

If you are using the Typst CLI or debugging long documents, you might be interested in this.

Solution A: Use panic function.

#show list: it => {
  panic(it) // 👈
}
- A
error: panicked with: list(
  tight: true,
  marker: ([•], [‣], [–]),
  indent: 0pt,
  body-indent: 0.5em,
  spacing: auto,
  children: (item(body: [A]),),
)
  ┌─ <stdin>:2:2
  │
2 │   panic(it) // 👈
  │   ^^^^^^^^^

help: error occurred while applying show rule to this list
  ┌─ <stdin>:4:0
  │
4 │ - A
  │ ^^^
  • Pros:
    • Show the value with a full backtrace.
    • The value can be of any type.
  • Cons: Only the first call can be captured.
    Usually this can be circumvented with if condition { panic(it) }, but there exist cases that the condition is too compilcated to write or just impossible to express within typst.

Docs: Note on function purity
In Typst, all functions are pure. This means that for the same arguments, they always return the same result. They cannot “remember” things to produce another value when they are called a second time.

Solution B: Exploit the font not found warning.

#show list: it => {
  text(font: "[WARN]\n" + repr(it), "") // 👈
}
- A
warning: unknown font family: [warn]
list(
  tight: true,
  marker: ([•], [‣], [–]),
  indent: 0pt,
  body-indent: 0.5em,
  spacing: auto,
  children: (item(body: [a]),),
)
  ┌─ <stdin>:2:13
  │
2 │   text(font: "[warn]" + repr(it), "") // 👈
  │              ^^^^^^^^^^^^^^^^^^^
  • Pros: Can be used any number of times at any place.

  • Cons:

    • The backtrace only contains the call itself. No nested calls.
      This implies that it’s not worth encapsulating it as a function (unless you combine it with a eval(generated-code) hack).

    • The font name can only be strings. You may have to pack the value with repr or yaml.encode. (See the above sections.)

    • The font name is case-insensitve.
      You can circumvent this with the Label is not attached to anything warning, but that one will escape your strings, which means that you can’t wrap lines.

      warning: label `label("[WARN] list(\n  tight: true,\n  marker: ([•], [‣], [–]),\n  indent: 0pt,\n  body-indent: 0.5em,\n  spacing: auto,\n  children: (item(body: [A]),),\n)")` is not attached to anything
      

Limit divergent layout with layout-ltd

Limit divergent layout with layout-ltd

Use the layout-ltd package to debug Warning: Layout did not converge within 5 attempts.

layout-ltd – Typst Universe

#import "@preview/layout-ltd:0.1.0": layout-limiter
#show: layout-limiter.with(max-iterations: 2)

Finally, you can edit this post!

This is a wiki post that any user can edit.
By editing, you agree to dual-licensing under CC BY 4.0 and MIT.
This will conform with the forum guideline and reserve the possibility of submitting to the Typst Examples Book.
You can leave your name below after editing.

Signature area
  • @y.d.x Y.D.X. <73375426+YDX-2147483647@users.noreply.github.com>
  • @Andrew first contributed on 2025-11-06
10 Likes