[ANN] typsy:0.1.0: classes, pattern-matching, enums, safe-counters, and more

Hey folks. Thought I’d share typsy, my (whimsically-named) library of Typst tools for programming geeks.

Some highlights:

  • classes
  • pattern-matching
  • enums
  • safe counters (no need to find a unique string)
  • mutually-recursive functions

Available on Typst universe and GitHub.

What’s in the box? Here are some examples.

Classes:

#import "@preview/typsy:0.1.0": class

#let Adder = class(
    fields: (x: int),
    methods: (
        add: (self, y) => {self.x + y}
    )
)
#let add_three = (Adder.new)(x: 3)
#let five = (add_three.add)(2)

Simple pattern-matching:

#import "@preview/typsy:0.1.0": Array, Int, matches

// Fixed-length case.
#matches(Array(Int, Int), (3, 4)) // true
// Variable-length case.
#matches(Array(..Int), (3, 4, 5, "not an int")) // false

Enums + ‘proper’ pattern matching:

#import "@preview/typsy:0.1.0": case, class, enumeration, match

#let Shape = enumeration(
    Rectangle: class(fields: (height: int, width: int)),
    Circle: class(fields: (radius: int)),
)
#let area(x) = {
    match(x,
        case(Shape.Rectangle, ()=>{
            x.height * x.width
        }),
        case(Shape.Circle, ()=>{
            calc.pi * calc.pow(x.radius, 2)
        }),
    )
}

Safe counters:

#import "@preview/typsy:0.1.0": safe-counter

#let my-counter1 = safe-counter(()=>{})
#let my-counter2 = safe-counter(()=>{})
// ...these are different counters!
// (All anonymous functions have different identities to the compiler.)

If anyone has any thoughts, let me know :slight_smile:

6 Likes

Hey. Any thoughts about overhead/performance?

Second to last example really screams to me that it could’ve been a polymorphism. Is it possible to create an array of different shapes and then call .map(shape => shape.area()) on it? Or can you use matches on Shape enum and its variants?

Also, can you provide default value to counters? This is the annoying limitation of native counters compared to state.

At least a little bit: I made sure to include some fastpaths for common pattern-matching cases.

I’ve not tried benchmarking anything carefully though. As a practical matter my documents still compile faster than I can alt-tab.

Yup, what you’ve described would be totally valid as well. Consider this simply an example of the match-case syntax.

Is what you’re describing different to simply calling .update(default-value) immediately after creating the counter?
(But if indeed you’re looking for a sugar for that, then I agree that sounds like a reasonable thing to want.)

Oh…right, Typst doesn’t have a typing system, so you can pass any object that implements a method and pretend it’s of type Shape.

I need exactly this within a single function call.

If I’m not mistaken, this would require some kind of monad-adjacent feature (What syntactic sugar do you most desire? - #2 by SillyFreak). The function doing that would need to return a value and emit content at the same time.

Yup, thinking this over, the underlying .update would need to be embedded as content. This means that at best we’d have syntax that looks like:

#let (my-counter, forhash) = safe-counter(default: 5)
#forhash

Anyway, as a result, I don’t think we really gain anything relative to the current approach. So @Andrew probably no defaults I’m afraid :)

A monad-like way of tackling this would be interesting. (Although on this topic, the suiji use-case could actually be handled without monads, by instead using a splittable PRNG.)

Yeah, which is why Add ability to define default/initial value for `counter` · Issue #6229 · typst/typst · GitHub exists anyway.

oh, looks interesting! If I understood this correctly, that would mean instead of

#{
  let rng = gen-rng-f(42)
  let p
  (rng, p) = random-point(rng)
}

you could write

#{
  let rng = gen-rng-f(42)
  let rng_
  (rng, rng_) = split-rng-f(rng)
  let p = random-point(rng_)
}

… which tbf doesn’t look better at the call site, but the random-point() function doesn’t need to bother with returning the rng. I’m not sure that’s worth it, but I appreciated learning about the concept!

Something like that! This can be neatened slightly by avoiding the (rng, sub-rng) = rng.split() antipattern (which still looks a lot like a classical stateful prng!) in favour of splitting into exactly as many keys as your require. Adapting your example in the other thread:

#let random-point(rng) = {
  let (rng1, rng2) = split(rng, n: 2)
  let x = random-f(rng1)
  let y = random-f(rng2)
  (x, y)
}

#{
  let rng = prng(seed: 42)
  let (rng1, rng2) = split(rng, n: 2)
  let p = random-point(rng1)
  let x = random-f(rng2)
  (p, x)
}

And if the number of keys isn’t known in advance then this can be handled by ‘folding in’ a loop index:

#{
  let rng = prng(seed: 42)
  for i in range(10) {
    let sub-rng = fold-in(rng, i)
    let x = gaussian(sub-rng, mean: 3, stddev: 5)
    // ... do something with `x`
  }
}

(And split(key, n) is just short for folding-in the indices 1..n.)


Overall the splits follow a tree-like pattern that matches your graph of function calls and loop iterations – not a sequence-like that splits on each random access.

2 Likes