Jasper rough cut: August 2026
October 21, 2026 -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.unwraperror: 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:
- No shadowing: the compiler refuses a block argument
namewhennamealready exists in the same scope. The same applies to nested blocks (Eg.rows.each { |row| row.each { |row| ... } }). - No overrides: a method that takes a block and calls
yieldis not a real method, only pasted code. If a subclass could override it, the pasting would get much more complex.
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:
- A value type, like a Crystal
struct, lives on the stack, not the heap, so it needs no allocation. myc already supports them, and they could be the option for data without allocations. - A minimal GC-like allocator beats the hell out of libc
mallocandfree. But then I am no longer only writing a compiler, I am also writing a GC. - A memory pool per class, like Zig's
MemoryPool, showed good results. The compiler knows the size of each class at compile time, so it can tune how it allocates that memory. - At first I thought that removing retain and release calls would help the most. Since ARC was not the biggest cost, inlining with link-time optimization (LTO) could do more, as it removes the function call overhead. Something to look at in the future.
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:
- Calls without an explicit
selfdid not use virtual dispatch, so a subclass could run the parent's method instead of its own. - A nilable value passed to a C function like
printfreached C as its internal pair (a tag and the value) instead of the value. It did not crash, it just gave a wrong result. The fix makes the conversion explicit with#to_unsafe.
Stack and numbers
Stack
- Harness: Claude Code
- OSes: macOS 15 (Sequoia) and Alpine Linux 3.23
Code
Lines include comments and blank lines.
- Compiler (
src/): from 10,253 to 14,416 lines (+4,163, of which +345 are the generated AST) - Stdlib: 85 to 258 lines (+173, almost all of it the new
array.jasper) - Specs: 749 to 1,216 examples (+467), including:
- Fixtures that must compile to the expected myc IR: 40 to 54 (+14)
- Fixtures that must fail with the expected error: 67 to 93 (+26)
Tokens & cost
- Input: 969.9M
- Uncached input: 36.9K
- Output: 7.4M
- Cache hit: 100%
- Active days (days of the month with sessions): 15
- Human hours: 8
- Total token cost: $721
- Models: Opus (planning, coordinator), Sonnet (task executor), Haiku (task executor)