Feedback: My Experience Using Typst

I recently used Typst to write my dissertation.

Before this, I had only used LaTeX to write scientific texts, and using Typst was a nice experience overall. I enjoyed the more imperative approach for declaring custom commands, that it often worked just as expected, and the overall compile speed (<12 seconds for an unoptimized long document seems fair). However, it wasn’t all perfect. In the following, I’ll outline hurdles I encountered and hope that some of them are addressed.

I am unsure whether this is the best way to give feedback. I still chose to do one long forum post rather than many smaller posts or issues, because it is condensed into one unit and does not clutter the issue tracker. On the downside, this way it is a mess to discuss single points. Feel free to branch this post into multiple posts or issues where you (as a reader wanting to discuss something) see fit.

Other than that, thanks for developing Typst.

Layouting

Inside/Outside Margins

Typst supports book-style pages by specifying inside and outside margins.

This then lays out pages so that a left page has the outside margins on the left, and a right page has the outside margins on the right.

Now I also want the page numbers on the outside of the page; however, determining the correct alignment isn’t trivial: on a left page, it should be on the left, and on a right page, it should be on the right. But Typst does not expose which pages it considers “left” and “right”. This is determined by the page.binding property. If it is auto, then it is determined by text.dir. If that is auto, it is determined by text.lang. I did not find a mapping from text.lang to text.dir.

I would like to see the following changes:

  • Add an inside and outside alignment (if margins are set to left/right, a fallback should be used; e.g. inside is right, this should be settable somehow)
  • Expose properties embedded in different text languages
  • Expose what auto translates to. It would be nice if I could resolve page.binding to a concrete value, even if is set to auto.

Parsing Margins

Page margins can take many forms. Similarly, the value of page.margin may have many forms, and not all code can properly handle all:

  • auto
  • 0% + 1pt (all four margins are the same)
  • (left: 0% + 1pt)
  • (left: 0% + 1pt, right: 0% + 1pt)
  • (inside: 0% + 1pt, outside: 0% + 1pt)

I would like to see a function to normalize the forms into a common explicit form which always contains top, bottom, left/inside, right/outside.

Preventing Line/Pagebreaks

I did not find a good way to prevent line breaks or page breaks at specific places in the text.

  • I tried using box to prevent linebreaks, but this sometimes caused weird spacing at the beginning of lines after a box.
    • This mostly causes issues for me at places where I could not replace the space with a non-breaking space (~), for example in the native bibliography.
  • I tried using block to prevent page breaks, but this causes a line break before and after (as it causes a new paragraph).

I would like to see some command like #nolinebreak[body] or #nopagebreak[body].

Widow/Orphan Lines not Adjustable

While widow/oprhan lines are technically just a single line, I also try to avoid having just two or three lines on a single page. I would like to be able to specify that I want to avoid N lines on a single page. (cf LaTeX nowidow)

Positioning of Floating Figures

Floats are mostly placed on the same page. This often caused them to be placed above the heading they were specified under, which I often found weird.

It also caused multiple figures to be placed directly under each other, instead of moving the second one to the next page.

I would like to see improvements in the automatic placement, and something like a “float barrier” (as in LaTeX placeins) could be introduced.

Coding

Accessing internal functions

Sometimes, I wanted to access internal functions of imported modules. I had to resort to copy+pasting them. It would be nice if I could import even non-exposed functions from other modules.

I understand that this is rather hacky and probably should not be endorsed as a use case. Still, I feel like it could allow for more powerful usages of existing libraries.

I would like to see an import syntax where I can specify a file from the package source to import an arbitrary symbol from.

Conditional Show/Set Rules

Show/Set rules are limited to the current code scope.

#if cond {
  set text(red)
}
text

Does not work.
Instead, I have to write

#let args = (black,)
#if cond {
  args = (red,)
}
#set text(..args)
text

Which is way more verbose.

Edit: Seems I missed conditional set rules (set ... if cond). However, show rules are still non-conditional, other than including the if inside the body, which feels a bit odd.

I understand why rules are limited to the current scope, but sometimes it would be useful to escape the current scope. I don’t know what the better approach would be here.

Parameters

Parameters seem quite limited: Either they are positional and required, or optional and keyword. (Apart from parsing …args manually)

I like Python’s approach of allowing parameters to be

  • positional only (any parameters before the / “parameter”)
  • keyword only (any parameters after the * “parameter”)
  • required (no default value given)
  • optional (default value given)

For me, it was quite frustrating to trial-and-error which parameters are positional, and which are keywords.
I would like to see that all parameters can be used as keyword parameters.

Data Types

No Data Structures, Just Dicts and Arrays

Dynamic typing is all great and stuff, but can be a pain to debug.

I would like to see

  • sets (instead of having to rely on array.dedup)

  • custom types (at least structs)

  • type annotations (see issue 317)

Dictionaries are limited

I found dictionaries to be limited. I found myself regularly typing

  • d["foo"], oh no, we don’t have a bracket operator
  • (sometimes) d.get("foo") oh no, it is at
  • d.at("foo"), I want to specify a default
  • d.at("foo", 42), wait this breaks
  • d.at("foo", default: 42), finally

I also had trouble with nested subdictionaries, and that they are not directly mutable (see below).

I would like to see

  • operator overloading (or at least specifying [] for dicts)
  • support for keys other than strings
    • numbers are useful
    • sometimes even tuples/arrays
  • mutable subdictionaries
    • in python I can do d1["a"]["b"] = 3. In typst I have to do d2 = d1.at("a"); d2.insert("b", 3); d1.insert("a", d2)
    • this is annoying when nesting dicts (maybe custom types would be the solution to this)
    • Edit My troubles seem to stem from storing the subdictionary in a variable, which then seems to store a copy instead of a reference. This seems a bit odd to me.

Misc

Collapsing Multiple References

When writing multiple cites after another, typst can properly collapse them @foo@bar can turn into [1, 2] (ACM style).

However, I did not figure out how to do this manually. This would be interesting when writing a custom bibliography.

This could also be interesting for referencing figures, sections, … As discussed in @secA@secB / shown in @figA@figB could turn into As discussed in sections A and B / shown in figures 1 and 2.

I would like to see a way to implement this behavior.

Figure Numbering

Figure numbering is rather limited.

  • There is no native support for subfigures. The subpar package does a decent job, but also seems rather hacky in how the counter is implemented. It would be nice if the figure counter could be used for this (but stepping a level deeper, such that a figure could have the counter value (2, 3)).
  • I did not find a way to consistently add the chapter number to the figure counter. Edit used the wrong functionality to format the counter/numbering.
    • For example, a figure in the Appendix A might be Figure A1. Or in Chapter 3 it could be Figure 3.1. Accessing the chapter counter within the numbering function does not work properly. While the figure itself gets the correct number prepended, any references from another chapter use the wrong chapter then (as the function is reevaluated at the position of the reference).

I would like to see the following changes:

  • Support subfigures natively
    • Either add a “counter-level” property to figures
    • Or add a “subcounter” function to a counter that returns a subcounter: figure(kind: image, counter: counter(figure.where(kind:image)).subcounter(offset: 1))
      • The subcounter would only step based on the specified offset
    • This would also require that the caption does not display the full supplement+numbering (cf. subpar)
  • Support more complex numberings
    • Either evaluate the numbering function always at the position of the figure, not at the position of the reference
    • Or allow a show heading rule to override the numbering for figures contained in this chapter (set rule within show heading would need to affect the whole section…)

“Suplement Space Numbering”

I used custom figures to implement a research question box. I wanted them to be referenced as “RQ1” without any space between the supplement and counter. I had to overwrite this using a show ref rule. It would be nice if this was possible by specifying a property on the figure to remove/replace the space.

Bibliography

I feel like the current state of the bibliography is too limited:

  • As outlined in a previous forum post, I wanted multiple bibliographies to have distinct prefixes (own publications [O1], websites [W2], …). This is achievable with custom CSLs, but cumbersome.

  • As outlined in a previous forum post, I wanted cites of multiple bibliographies to be properly joined [1, 2] in ACM style ([O1, W2] with prefixes), but the native bibliography as of now would render them separately [1][2]. Covered by issue 8501 (and re-reported by me in 8582).

  • Backrefs are not possible (I noticed that in the bibliography, the label on the left actually links to the first usage. Still, it might be interesting to link to all usages)

  • #cite(form: "author") gives a mediocre result. I guess this is due to the underlying CSL, but I would prefer it if it was more adjustable. I would expect it to show up to two last names or 1 last name + “et al.” Instead, I got the full list of authors without specifying a custom CSL.

All of this caused me to write my own bibliography wrapper (where I ran into some of the other limitations) with the following features:

  • Each bibliography can specify a prefix/suffix for the label

  • Multiple cites are co-joined (in a custom cite command), even if they stem from different bibliographies

  • Backreferences are printed in the bibliography as "Referenced on pages X, Y`

  • Can cite using the custom cite command or using @ references

  • The @ syntax supports several macros: @foo:y cites the year of foo, @foo:a cites the authors of foo

  • Aliases: Biblatex supports specifying ids, which can be used as aliases when citing. I also support these aliases.

I used alexandria.hayagriva to render entries and citegeist.load-bibliography to parse entries for internal use (I did not want o put too much effort into this, so the code is not the cleanest :S ). hayagriva does add a hyperlink to the prefix of a DOI (which ACM specifies as https://doi.org/), so I patched that, such that the whole visible link is clickable.

I would like to see some of these features be native to Typst.

Strict Limit on 5 compile iterations

I understand that allowing the iterations to increase will just cause people to write worse code, but sometimes I don’t want to spend the time to write good code (esp. when I am under time pressure).

I would like to see a compiler flag that allows increasing the iterations. To limit this from being requested by published packages, I’d consider that packages uploaded to the universe must work properly without changing the iterations (however, that is enforceable; maybe the documentation must provide demos and those have to compile in the default limits).

No Plaintext Export for Spellchecking

I used Grammarly for my spell checking. To get copy-and-pasteable text, I extracted it from the PDF, which was okay, but annoying. It would be nice to have a plaintext export (issue 2401).

My solution to this was to set the page height to auto, width to 5 meters, and use mutool to convert from PDF to text. This left me with one paragraph per line, which I could paste into Grammarly without worrying about linebreaks/pagebreaks. I was pleasantly surprised that it compiled this well.

Bugs

Referencing Floating Figures Links to Wrong Place

This is already covered by issue 4359 from June 2024. Referencing a floating figure causes the link to point to the wrong location: The reference points to the location in the text instead of the location where the figure is placed. This also sometimes causes the outline to contain incorrect page numbers.

I am surprised that this bug causing wrong output to be generated (in the outline) still persists after two years.

Memory Usage

When using typst watch and/or tinymist for a long time, RAM usage increases, sometimes causing me to run out of memory (on a 32G machine with 32G swap).

This only occurred when running these tools for a day or longer. While I see that restarting these tools is not hard, it is annoying that I have to keep track of it.

11 Likes

Some quick answers:

float barrier

place.flush is available. However, it’s quite low-level compared with LaTeX placeins.

Conditional Show/Set Rules

Use set-if rules.

#set text(red) if cond

Alternatively:
#set text(if cond { red } else { black })

Could you elaborate? The following just work

#{
  let d = (a: (b: 0))
  d.a.b = 3
  d
}

图片

This is already possible in Typst v0.15 with counter.display(at: location).
The show ref example in Reference - Typst Documentation has already been updated, but many packages have not adopted it yet.

Example
#set page(height: auto, width: 20em, margin: 1em)

// I add a no-op show-ref-function rule here,
// because the native ref just works.
#show ref: it => {
  let el = it.element
  if el != none and el.func() == figure {
    link(el.location(), {
      [Figure~]
      el.counter.display(at: el.location())
    })
  } else {
    it
  }
}

#set figure(numbering: n => numbering("1.1", counter(heading).get().first(), n))
#show heading.where(level: 1): it => {
  counter(figure.where(kind: image)).update(0)
  counter(figure.where(kind: table)).update(0)
  it
}
#set heading(numbering: "1.1")
= Chapter 1
@fig:a, @fig:b
== Section 1.1
#figure(rect[Image], caption: [A]) <fig:a>

= Chapter 2
@fig:a, @fig:b

#set heading(numbering: "A.1")
#counter(heading).update(0)
#set figure(numbering: n => numbering("A1", counter(heading).get().first(), n))
= Appendix A
@fig:a, @fig:b
= Appendix B
@fig:a, @fig:b
#figure(rect[Image], caption: [B]) <fig:b>

1 Like

For preventing paragraph to break you can use the block function. You can prevent the line break by setting the spacing of the block as the same as the leading of your paragraphs.

#context(block(spacing: par.leading, breakable: false)[Unbreakable text])

I think the better way to write conditional set rules is with the show command. It depends on the condition of course, and I’m not sure all the conditions do work. What kind of conditions do you want it to work with?

I do agree with your statement about the parameters being either positional and required or optional and keyworded though. I’m not sure if it’s intentional design by the devs.

1 Like

it is simply d.foo.

#set page(
  footer: context {
    // get the current page number
    let page-num = counter(page).get()
    if calc.even(page-num) {
      numbering("1", page-num)
      h(1fr)
    } else {
      h(1fr)
      numbering("1", page-num)
    }
  }
)

I know, but floats can still go above this border.

How did I miss this. Thanks.

I had these problems when using the “at” syntax and storing the resulting dict in a variable.
I guess storing a dict in a variable stores a copy and not the reference

#{
  let d = (
    a: (
      b: 1,
      c: 1,
    )
  )

  let x = d.at("a")
  x.insert("b",2)
  d.at("a").insert("c",2)
  
  d
}

results in (a: (b: 1, c: 2)), i.e. c is updated, while b is not. This feels weird to me coming from Python.

This is already possible in Typst v0.15 with counter.display(at: location).

Seems I ran exactly into that issue of using the numbering instead of display at.

1 Like

But block starts a new paragraph. I want to prevent line breaks in certain locations, which I can do in text I control with a non-breaking space (~).
However, this does not work for text I don’t control (e.g., cite(form: "author")).
And I cannot easily tell Typst “please do not do a page break at this position”, for example in parentheses.

#set page(width: 5cm, margin: 5pt, height: 2cm)
#set par(justify: true)

This is some text where I do not want a line break to occur here.

Also, no page break should happen in parentheses, as that would look weird.
(like here).
But how do I avoid that?

One use case I had is the conditional styling of text

#let f(..args, norm-size) = {
  //...
  let style = x => x
  let txt-args = (:)
  if not norm-size {
    style = smallcaps
    txt-args = (size: 0.80em, weight: "medium")
  }
  set text(..txt-args)
  show text: style
  //...
}

I know the show rule could also be written as show text: it => {if norm-size {it} else {smallcaps(it)}}, but that is still rather verbose.

That does not work if the key is a variable (and does not allow me to specify a default, but brackets also wouldn’t allow this).

The proposed solution does not support different page bindings. Typst should make these internal variables (what is a left/right page, and where is the inside/outside edge) available directly rather than having to recompute them manually.

1 Like

Yeah. If you know the key before writing it then it would work, otherwise you’d have to use the .at syntax.

I checked the docs and it doesn’t seem like that there is a fix for it. I guess if you know exactly where each kind of binding starts and ends you can check if a page num is in a certain range or place different show rules for the footer.

1 Like

Agreed! There’s an issue for it; afaik the question is mostly with API design, it should not be hard to actually provide the relevant values. Sometimes it’s valuable to know that something is auto, but it’s also important to know what auto resolved to.

The programmer in me says this is very dirty, but Typst is not a general purpose programming language, so maybe the conclusion should be different here. Maybe we can just encourage patterns let packages give both a curated API and access to plumbing without too much hassle.

In addition to set ... if, I want to mention this pattern:

show: it => {
  if x {
    set ...
    show ...
    it
  } else {
    it
  }
}
// or with early return
show: it => {
  if not x { return it }
  set ...
  show ...
  it
}

In short: a “show everything rule” pulls the following content into a variable it, making it so that the it can be put into the required scope for styling.

I do like / and * in parameter lists about Python (not so much that the default is hybrid positional/named parameters). In Typst it’s more common to have named arguments before positional ones (think of table cells, for example) so I’m not sure it would perfectly – but the current “has default = named” equivalence is definitely not ideal.

Dicts do serve double duty for structs; with dot field access I think that’s fine. Proper custom types would have many other upsides though and I look forward to them. Right now, I often write “constructor” functions for my “types”, e.g.

let point(x, y) = (x: x, y: y)

let person(first: none, last: none) = {
  assert.ne(first, none, message: "first is required")
  assert.ne(last, none, message: "last is required")
  (first: first, last: last)
}

(Type checks would also be possible, of course.) It’s a bit boilerplatey, but it gets the job done. There’s also packages like Valkyrie, but I haven’t looked into that one yet.

that won’t happen, as [] already has an important role for content. The role of [] would become too overloaded and confusing ambiguous: foo[x] is a function call with a content parameter. That .at() is comparatively long (and necessary for variable keys) is unfortunate, but imo ultimately acceptable given the circumstances.

You started out saying that you “enjoyed the more imperative approach”, but here it becomes apparent that Typst is actually functional. There are no references anywhere in Typst, and 99% of the time this is definitely what you want. You can work freely with arrays and dicts you get as parameters, and not worry that you are messing with your caller’s data.

I doubt the enforcement for packages would work and overall don’t see this happening. I can see a future where we maybe managed to make the case for 6 hardcoded iterations, but I wouldn’t hold my breath. The escape hatch for power users here is compiling from source…

Crazy actually :pensive: thanks for adding your workaround to the issue!


Many insightful observations, thanks for writing them up!

4 Likes

I totally get that. Most languages have a concept of public and private for a good reason. Yet the “hacker” inside me likes to be able to adjust and patch stuff under the hood, and in programming this is also not uncommon:

  • in Java you can circumvent “private” if you really want using reflections, and I feel like this is (unfortunately) not uncommon.
  • in Python there is no private, which allows for very powerful modifications and hooks.
  • in LaTeX everything is global (or at least everything I ever needed was), and therefore you can hook into anything. Having everything clutter the global namespace is annoying and I am glad typst has proper namespacing. Still this is quite powerful.

I could envision a syntax like

#import "@preview/package:version/file.typ": function

and hope that packages do not advertise this use, but stick to the current approach.

(disclaimer: I have not worked much with functional programming)
It still feels weird to me, that let x = d.at("a"); x.insert(...) is different from d.at("a").insert(...):

Maybe you could consider something like Go’s approach with pointers (though I recall that there were some types that were always passed by reference, but I cannot remember that right now).

I guess more aid for developers into why some state is propagating slow would be helpful. 0.15 already improved this a lot by printing non-convergent state values (thx), but I still could not get my custom bibliography to compile in just two iterations that I think it should need. (tbf, a lot of that code could be improved)

I actually considered this, but wanted my document to be “standard compliant” and did not want to search for the limit in Rust, compile it, and then also somehow patch my git ci to use a custom Typst compiler.

2 Likes

I know, it takes some time and experience getting used to if you are used to languages like C# and Java where objects are automatically accessed through references. In my experience teaching programming, getting into that mindset also takes effort, so I don’t think one is inherently more intuitive than the other.

Your example is a bit small; there’s no real reason to declare the x variable. I assume that comes from actually having a loop or function in the mix (rewriting with field access syntax because I think that’s the more readable way, and we’re ultimately talking about how easy to understand Typst code is/isn’t):

Extended code example
#{
  let d = (
    a: (
      b: 1,
      c: 1,
    )
  )

  let modify-b(x) = {
    x.b = 2
  }

  modify-b(d.a)
  d.a.c = 2
  
  d
}

#{
  let d = (
    a: (
      b: 1,
      c: 1,
    )
  )

  for x in d.values() {
    x.b = 2
  }
  
  d
}

There are patterns to handle this fairly cleanly. The modify-b function can’t have a side effect (modifying a parameter by reference), so instead it can return a result, and that result can be used at the call site:

  let modify-b(x) = {
    x.b = 2
+   x
  }

- modify-b(d.a)
+ d.a = modify-b(d.a)

For the loop, there is (newly also for dicts) a map method that lets you transform values. That method returns a new dict, and you assign that to the variable:

- for x in d.values() {
-   x.b = 2
- }
+ d = d.map(x => {
+   x.b = 2
+   x
+ })
Full suggested code
#{
  let d = (
    a: (
      b: 1,
      c: 1,
    )
  )

  let modify-b(x) = {
    x.b = 2
    x
  }

  d.a = modify-b(d.a)
  d.a.c = 2
  
  d
}

#{
  let d = (
    a: (
      b: 1,
      c: 1,
    )
  )

  d = d.map(x => {
    x.b = 2
    x
  })
  
  d
}

There are tradeoffs to functional programming – modifying state while doing a computation gets you into territory where monads would be useful – but usually the tradeoffs aren’t so bad, and worth the upsides. Avoiding mutable (and global) state makes it easier to keep data flows straight and independent pieces of code composable.

1 Like