Why can't I access an array member or dictionary entry in this situation?

typst 0.15.1 / Linux

Please consider the following code:

#context {
  let hits = query(
    selector(<body-row-marker>).within(here())
  )

  let first = (:)

  for hit in hits {
    let pos = hit.location().position()
    let page = str(pos.page)

    if page not in first or pos.y < first.at(page).y {
      first.insert(page, (
        row: hit.value,
        y: pos.y,
      ))
    }
  }

  table(
    columns: 1,

    ..range(1, 3).map(i => [
      #metadata(i) <body-row-marker>
      Row #i
    ]),
  )

  [
  #first.len()\
  #first\
  #first.values()\
  #first.values().enumerate()\
//  #first.at("1")\
//  #first.values().at(0)
  ]
}


Just for explanation, the code basically determines the number of the first table row / cell on the page. But the purpose of the code is not relevant for the question. To understand the question. we first need to have a look at the output:



As expected, we see two table rows. The interesting part comes below the table. Again as expected, #first is a dictionary that has one entry (first line). The entry consists of the key "1" and a value that is itself a dictionary (second line). Consequently, the values of the dictionary are represented by an array that consists of one element that is a dictionary (line 3). Finally, we see that this array has its only element at index 0.

So far, so good, everything is as expected here.

However, as soon as I un-comment the last line in the code (before the closing square bracket), the compiler throws the following error:

error: array index out of bounds (index: 0, len: 0) and no default value was specified
   ┌─ test2.typ:35:3
   │
35 │   #first.values().at(0)
   │    ^^^^^^^^^^^^^^^^^^^^


Likewise, if I comment out that line again and un-comment the line above it, the compiler throws the following error:

error: dictionary does not contain key "1" and no default value was specified
   ┌─ test2.typ:34:3
   │
34 │   #first.at("1")\
   │    ^^^^^^^^^^^^^


Could somebody please help me make sense of that?

The array definitely has length 1 (not 0), and the first element is definitely at index 0 (so index 0 can’t be out of bounds). Similarly, the dictionary definitely contains an entry under key “1”.

For the record, it’s just the .at() function that does not work. As we have seen in the output, the internal representation of the variables is correct, and even operations that are a bit more complex behave as expected.

For example, I have replaced the lines that use the .at() function by the following lines:

  #(1 in first.values().map(it => it.row)) \
  #(2 in first.values().map(it => it.row))

This did not lead to any compiler errors and did produce the expected result (two additional lines in the output with content “true” and “false”, in that order).

So I seemingly can use several element functions (including non-trivial ones) on this dictionary and this array, but not the .at() function.

Why is this the case, and how can I work around it?

Thank you very much in advance!

Hello @Binarus,

You can add a default value to return if the key is not part of the dictionary.

  #first.at("1", default: none)\
  #first.values().at(0, default: none)
2 Likes

Relevant issue #7625
tl;dr: I think this is a bug, the error should have been “delayed” by the Typst compiler and automatically handled in later layout iterations.

1 Like

Just to add a bit of explanation to this: the default is needed because the query result is not necessarily populated on the first layout iteration.

On an early pass, first can still be empty, so these would effectively be evaluated as:

(:).at("1")
().at(0)

and .at() throws immediately in that case. Other operations such as .len(), .values(), .map(), or membership checks are fine on empty collections, so they survive until Typst reaches a later iteration where the query has stabilized.

That is why the final rendered output can show that the dictionary contains "1" even though an unguarded .at("1") has already failed during an earlier pass.

So using:

#first.at("1", default: none)
#first.values().at(0, default: none)

is not just a workaround for a missing key. It also makes the code safe during the intermediate introspection passes before the final layout converges.

1 Like

@All

Thank you very much so far!

I already had come to the idea to give a default value (which indeed made the compiler error go away), but I was afraid that I then would read out and use the wrong value during further processing (that is, the default value instead of the real / actual value).

Thanks to your explanations, I now have understood the problem, and have understood that this won’t happen. If I would be absolutely paranoid, I could do something like that:

#{
  ...
  if (none == myarray.at(index, default: none)) {
    // do nothing or handle the situation here
  }
  else {
    result = myarray.at(index)
  }
  ...
}


This shouldn’t be necessary in nearly all cases when using .at() for reading. However, being the guy I am, I immediately managed to cause the next problem:

I had to use .at() to modify the value in an array at a certain index at the place where the compiler originally complained. The following then does not work (of course):

  #myarray.at(index, default: none) = myvalue


This could be solved using the above technique:

#if (none != myarray.at(index, default: none)) {
  myarray.at(index) = myvalue
}


If I had to wrap too many places in the code into such conditions, this would be uncomfortable. But it is a clean solution that we could very well live with (IMHO) :slight_smile:

Thank you for the constant great support!