profile picture

Jasper rough cut: August 2026

October 21, 2026 - jasper crystal compilers ai rough-cut

In August I worked on generics, method overloads and blocks. As the type system grew, I spent more time on research and design before I gave tasks to the agents.

Notes from building Jasper, one month at a time

New here? The post from July explains what Jasper is and how I work with the agent building it.

This is a rough cut and, like before, some of these decisions may change.

Rough cut: August 2026

With classes and inheritance in place, it was time to look at the details and refine the implementation.

August was a slow month, with fewer features than July. I took time off from work (and from my computer) to spend time with my family.

I also changed how I used the agents. Instead of starting as many agents as possible, I asked myself a question first: should I give an agent a task I did not fully understand, or should the agent first do research, compare approaches, or spike some prototypes to check if an idea is worth it?

I took the second option as a wrong design decision in the foundations of a language takes a lot of effort to correct later, independently of whether an agent or a human wrote the code.

Generics

Until now, Array(T) was a special case inside the compiler. Its methods lived in the compiler code, so to add or change a method, I had to change and rebuild the compiler.

With real generics, Array(T) is plain Jasper code, and user classes can have type parameters too:

class Cell(T)
  def initialize(@value : T)
  end

  def value : T
    @value
  end
end

Cell(Int32).new(42)
Cell(String).new("hello")

The compiler makes one copy of the class for each list of type arguments (this is called monomorphization). Cell(Int32) and Cell(String) become two separate classes, and the rest of the compiler handles them like any class written by hand. There is no boxing and no type check at runtime.

Array(T) is now a normal class in stdlib/array.jasper, and the compiler was down about 400 lines of code.

How does ARC work?

If you are not familiar with ARC (Automatic Reference Counting), here is the short version.

Each object has a counter. Assigning the object somewhere (or passing it to another method) increments its references (retain). Removing it or going away from scope decreases it (release). When the counter reaches zero, Jasper frees the object. Intrinsic values (Eg. Int32, Bool) are copied, not counted, so there is nothing to retain or release.

An array is an object too. When its counter reaches zero (nothing references the array anymore), the array must first release each element it holds, and then free its memory. If an array of Shape does not release each Shape before it frees itself, those shapes leak. Create a lot of throwaway arrays, and the program loses memory it never gets back.

In languages with a garbage collector (GC), like Ruby or Crystal, the GC does this for you. Under ARC, the array code must handle both cases: arrays of intrinsic values (Value) and arrays of objects (Reference).

Note: ARC cannot free a cycle. When two objects point at each other, their counters never reach zero, so Jasper never frees them. Right now the compiler does not detect these cycles or warn about them, and that memory leaks. The recording in the blocks section below shows one (--check-leaks reports 2 live allocations).

ARC and monomorphization

Monomorphization copies every method of the class for each T. The copy for Array(Shape) must release each element, and the copy for Array(Int32) must not.

Crystal solves this with a macro ({% if T < Reference %}). Jasper has no macros, and a normal if cannot ask "is T a reference?".

One loop for both does not work well either. For an Int32, the compiler turns the release call into nothing, but the loop stays. An array of 1,000 Int32 walks all 1,000 elements to do nothing.

To fix this, I added type constraints on methods:

class Array(T)
  primitive def __jasper_release : Void
    # when the counter reaches zero:
    __jasper_release_elements
    # then free the buffer and the array itself
  end

  primitive def __jasper_release_elements : Void where T : Reference
    # release each element
  end

  primitive def __jasper_release_elements : Void where T : Value
    # nothing to release
  end
end

Each T is either a Reference (an object counted by ARC) or a Value (Eg. Int32). The compiler keeps a method with a where clause only in the copies of the class where the constraint is true. Array(Int32) gets the empty method, so there is no loop at all.

A clause can also name more than one type parameter (Eg. where K : Value, V : Reference for Hash(K, V)), and all of them must match.

For now, a constraint can only be Reference or Value. Later, it should also accept specific classes or interfaces (Eg. Comparable).

Note: these methods use primitive def. They are part of ARC itself, so the compiler must not add retain and release calls inside them (a release that calls release never ends). The compiler builds a primitive def without ARC, and only a primitive def can use the low-level helpers (Eg. __jasper_free). Right now the compiler allows it only in the prelude (the core part of the stdlib), not in user code.

User code can use constraints too:

class Cell(T)
  def unwrap : T where T : Reference
    @value
  end

  def initialize(@value : T)
  end
end

c = Cell(Int32).new(42)
v = c.unwrap
error: undefined method `unwrap` for Cell(Int32)
  v = c.unwrap
      ^^^^^^^^
  note: cell.jasper:2:24: dropped by this constraint
      def unwrap : T where T : Reference
                           ^^^^^^^^^^^^^

There is a downside. where is not valid Crystal, so code that uses it cannot go through the Crystal crosscheck. For now, only the low-level methods in the prelude use it, and the crosscheck skips the prelude.

With this, Jasper is no longer compatible with Crystal, which was the original plan.

I think that is OK. Jasper already made some things strict that are flexible or optional in Crystal. At some point it kinda stopped being Crystal, and I just needed to admit it to myself.

Another example: in Crystal, a bare super passes the current arguments to the parent. That is too implicit, so in Jasper a bare super means no arguments. When the parent method needs arguments, you must pass them.

Method overloads

Jasper now has method overloads. Two methods can have the same name when they take different argument types:

def double(i : Int32)
  # ...
end

def double(c : Char)
  # ...
end

The goal is to write the compiler in Jasper itself (Red Jasper). Its AST walker needs overloads to send each node type to the right method.

The name mangling work from July already put the argument types in the mangled name:

def f(x : Int32)          top$$f$i32
def f(x : Char)           top$$f$char
def f(x : Array(Int32))   top$$f$Array$i32

For now, overloads only work for top-level methods and for methods inside a single class. Overloads across a class hierarchy (parent and child) will come later.

Blocks (each, map)

Blocks are syntax sugar, but they are one of the things I love most about Ruby and Crystal.

Before this, to loop over a collection you had to use while with an index.

Now, a method can take a block with & and call it with yield:

class Array(T)
  def each(& : T -> Void) : Void
    i = 0
    while @size > i
      yield(@buffer[i])
      i = i + 1
    end
  end
end

Like the rest of Jasper, blocks are typed (Eg. & : T -> Void).

The compiler pastes the method body at the place of the call. There is no Proc type and no closures.

I prototyped several approaches and settled on two rules:

I have no plans to relax these rules for now. This means some Crystal code will not compile under Jasper, and that is OK.

Research and benchmarks

I picked ARC for two reasons. It avoids the complexity of a GC. It also keeps the option to build shared libraries in Jasper that other tools can load (Eg. write plugins for other tools using Jasper).

But I had never measured how ARC performs. In this month's benchmarks, the memory allocator (calloc and free) took 61% to 85% of the work. ARC itself (retain and release) took between 0% and 23% in the worst case.

Switching from calloc to malloc also saved time. Other allocators (jemalloc and mimalloc) did better than the musl one, which is no surprise.

I spiked a benchmark harness and some prototypes, and they left some research notes to revisit later:

For now, these are research notes, not decisions for the language. I will come back to them when speed becomes a problem.

Linting the compiler's code

Code that compiles and passes the tests is not enough to accept a change. The code must also be readable and follow good practices.

When I added Ameba, it reported more than 390 issues across the codebase. That list gave me many small changes to give to the agents.

With Ameba in place, new code must pass every rule. Older files that already had issues are in a baseline, and I remove them from it as I clean them up.

There is no bug-free software

This month had fewer memory bugs, and more bugs where Jasper and Crystal did not behave the same:

Stack and numbers

Stack

Code

Lines include comments and blank lines.

Tokens & cost