Geblang - not just a toy

My last blog post - building my own programming language - introduced my latest side-project; Geblang, a statically typed scripting language, written in Go, which philosophically aims to be "PHP and Python, but with the features you wished they had."

Geblang has been built in an iterative, incremental process where I have maintained full design and control over not just the language itself, but the standard library, the architecture, the shape of its internals.

Most of the actual Go code was written with the assistance of coding models, which I am upfront about - for the simple reason I consider it little more than an implementation detail. Indeed, as I explore in this retrospective on the milestones to date, coding models can only be relied on so far. It is the understanding, at a technical level, of what you want that makes building real projects with an agentic AI workflow possible.

Although I publicly released Geblang in May, I was working towards that 1.0 release for quite a long time. Since the initial release, I've been able to iterate rapidly and today we are 1.20.

So many improvements have been made in that time, so many new features, so many bugs fixed.

This is the retrospective about what I've learned over these iterations; what got built, what broke and why I think the result is now more than an experiment.

Geblang - the PHP and Python alternative with all the features you wished they had

This pitch, the language philosophy, hasn't moved since day one. Geblang is founded on a simple observation: PHP and Python have spent two decades retrofitting guarantees onto dynamic foundations.

Even today, at the time of writing, an RFC is moving the PHP internals process to bring in only erased generics into PHP. Not erased at runtime, checked at compile time. Just erased. The proposal is little more than syntax for what is already done in docblocks and enforcing that syntax in any meaningful way will still reside solely in the domain of third-party static analysis tools.

I don't like that (I said as much on the internals thread about it). It's all the cost of generics with none of the benefit.

Python is much the same story: you can now natively add type hints as language, which CPython will cheerfully ignore.

Type hints the runtime ignores, generics that vanish before execution, async bolted on as an afterthought - these retrofits can only ever go so far.

Geblang starts from the other end. The guarantees are native, while the ease of learning, developer experience and scripting-language comfort is what gets preserved.

I tried to take the best of both worlds; PHP's inheritance model, Python's decorators, list slicing, primitives as objects and much more. A simple, gentle syntax that borrows from both.

On top of that I added fully reified generics, a static type system, and async/await built on goroutines for true parallelism - not single-threaded execution on top of an event loop. Operator overloading, function overloading, a modular system that makes sense. Explicit boundaries everywhere.

So types are real and checked before anything runs (geblang run, test, and build all refuse a program with a type error). Generics are real, not swept away at runtime: instanceof list<int> works, because the type parameters are actually there.

Here's a simple Geblang program that computes a Fibonacci number, both naively through regular recursion and with memoization (via a built-in decorator), both timed through the profiler module.

This is all stdlib, no dependencies required.

import profiler;
import io;

func fibNaive(int n): int {
    if (n < 2) { return n; }
    return fibNaive(n - 1) + fibNaive(n - 2);
}

@memoize
func fibMemo(int n): int {
    if (n < 2) { return n; }
    return fibMemo(n - 1) + fibMemo(n - 2);
}

let n = 32;

let naive = 0;
let tNaive = profiler.timer();
with (tNaive) {
    naive = fibNaive(n);
}

let memo = 0;
let tMemo = profiler.timer();
with (tMemo) {
    memo = fibMemo(n);
}

io.println("fib(${n})");
io.println("  naive recursion : ${naive} in ${tNaive.elapsedMs()} ms");
io.println("  @memoize        : ${memo} in ${tMemo.elapsedMs()} ms");

Two backends, one language

The most important architectural decision in Geblang is one that users never see directly. The language has two complete implementations and they are required to agree.

The first is a tree-walking evaluator and what I started Geblang with. Simple, easy to extend, the place new language features land first and the engine used by geblang test. The second is a bytecode compiler and stack-based virtual machine: the default for running scripts and for the release binaries geblang build produces, with on-disk bytecode caching (inspired by PHP again there) so the second run of a script skips compilation entirely.

Every program that compiles must produce identical output on both. That parity invariant is the load-bearing wall the whole language rests on and I'm careful to enforce it:

  • I have a dedicated parity test suite which compiles and runs hundreds of programs on both backends and compares their output, byte for byte.
  • A semantic fuzzer generates random valid programs and fails the build if the two engines ever disagree.
  • Drift guards give every surface a single source of truth, so tests fail if any divergence is introduced.

Why carry two implementations? Because they let me refuse a trade-off. The evaluator keeps iteration fast and behaviour easy to reason about. The VM is where performance lives. A feature gets prototyped quickly on the evaluator, then made fast on the VM. It sounds like double the work, and sometimes it is, but it has caught an enormous number of bugs that a single implementation would have shipped. This has been a vital tool when using an AI-first workflow.

The 1.20 release closed the last gap I'm aware of between the two: uncaught errors now render identically on both runtimes, with a classed header and a full stack trace.

Developer experience - building the language I always wanted

A language is not just syntax. The reason PHP and Python feel productive is everything around the language and that was a design goal from day one rather than an afterthought.

I can't magic up the same history and rich ecosystem that comes with either of those languages, so I focused on a packed standard library to make Geblang genuinely useful out of the box (inspired by Go itself).

Geblang ships as one binary, with a few additional stdlib helpers written in Geblang directly. The whole toolchain is in one executable. Geblang provides a REPL, a test harness, a static analyser (geblang check), formatter, documentation generator, package manager, language protocol server and debug adapter in one. The VS Code extension gives you syntax highlighting, completion and hover for every standard library module, with step debugging in the IDE.

Deployment is one of the places I wanted to escape the classic PHP and Python experience. geblang build compiles a project, with all its modules and the standard library code it uses, plus any other resources (web templates, for example) into a single self-contained binary. It will even produce an accompanying Dockerfile for you if you want one.

Languages are hard - so many edge-cases

So many edge-cases. My word, building a language and interpreter is hard.

If you think using AI meant this was a one-shot, vibe-coded language, it absolutely isn't. I've put countless hours into this. AI has just produced Go code for me, quicker and undoubtedly better than I could.

Everything that counts has been my decision. I made some good ones, I made some not so good ones, I failed to make some decisions in many places because there were aspects of building this thing that until I thoroughly tested, I simply had no idea about.

Here are just a small smattering of the more interesting ones I've found and fixed along the way:

The closure that closed the wrong iterator. A closure created inside a for loop crashed on the VM while running fine on the evaluator. A return inside the closure body emitted the enclosing loop's iterator-cleanup instruction, so the closure tried to close an iterator using a slot from a different call frame. Once that was fixed, a let re-run each iteration shared a single captured cell, so closures stored in a list all saw the last value.

3 == 3.0f used to be false. Before some additional numeric work, cross-type comparison compared types before values, so an int and a float holding the same number were unequal. Geblang won't lie to you about float precision; 0.1 == 0.1f is false, because the binary float genuinely is not one tenth, but the fix now routes every numeric pair through the same exact-value comparison shared by both backends.

Spread that only worked the way I tested it. When list-spread into call arguments landed, it was tested on user-defined functions and worked. It did not work spreading into a native variadic on the VM - a gap the tests missed because they only exercised one call context. This was a lesson for me in only testing happy paths and one of the inspirations for the fuzz tester.

The vanishing stack frame. I put a lot of effort into narrowing the basic benchmarks I use to compare Geblang's core performance to PHP, Python and Node. One change was the VM optimises return f(x) into a tail call. Except it turned out the optimisation was quietly consuming the calling function's frame in error traces, so the trace was wrong and the cause of errors difficult to pin down.

Not just a toy - real speed, real capability

In the first blog post I was careful to manage expectations on performance, because I wasn't aiming at first for something even roughly on a par with the interpreted languages everyone uses today.

A lot of work, particularly on VM parity and VM optimisation has happened since then. Unboxed small integers, call-site caching, StringBuilder mechanics, countless other changes to tweak and make scripts faster. The benchmark suite on my local today looks like this:

case             geblang   python   php   node
numeric_loop          76      135    30     29
recursive_fib         60       39    23     27
list_pipeline         15       16    11     24
string_concat         14       28    15     26
dict_ops              25       21    13     38
class_dispatch        27       20    13     26
regex_match           45       47    15     26
large_json_roundtrip  369      494   307    270
list_functional       16       17    12     23

Geblang does not have a JIT as of today, hence it won't realistically beat PHP but for a language whose earliest builds were 5-15x slower than CPython on the same tests, I'll take it.

One of the latest releases, I introduced ndarray and dataframe modules into the standard library.

If you've used NumPy and Pandas you already know how to use them; the difference is in Geblang, they're built-in with native Go implementations.

I am continually looking for ways to make Geblang not just faster, but with the tooling to make it capable.

Which leads me to...

Designed for web, not constrained by it

I've spent 20 years now writing PHP and there's a lot to be said for it. In particular, PHP's conventional request model is one of its greatest strengths. Every request starts from a clean slate and shares nothing. The cost, of course, is equally famous; years of accumulated legacy. The standard library inconsistencies, the mixed array types and their swathe of global stock functions. The type coercion, the bolted-on type system the latest generics RFC tries to help retrofit.

Geblang is web-friendly first and tries to take PHP's best ideas without the baggage. The built-in HTTP server runs each request handler in an isolated copy of the runtime: one request can never see another's in-flight state, so shared-mutable-state errors can't be written by accident. But the process stays resident so you keep goroutine-level throughput. And when you genuinely want cross-request state, such as counters, caches, sessions, etc., you opt in explicitly through a synchronised store.

Geblang has the benefit that the legacy mistakes are not there. There is no legacy. The standard library was designed as a whole to be consistent.

Equality compares exact values under one rule shared by every numeric type, not a coercion table. Errors are typed, catchable values with real stack traces, not a mixture of warnings, notices and fatals.

A handler that needs to call two upstream APIs just awaits them both, a long-running query blocks its goroutine and nothing else. There's no special async runtime, no fibers to deal with, no event loop to block.

Introducing the Gebweb framework

The language is just the foundation. I've spent a long time in the Symfony world. It's the framework I use most and admire most (in any language).

Gebweb is my attempt to bring that feel to Geblang, with the static typing and native async the PHP world had to retrofit.

You write typed controllers and annotate handlers (@Get, @Post, @Auth, @RequiresRole), request parameters bind straight into typed handler arguments. Around that core it has the things you typically reach for when building something real: a DI container, request validation, an OpenAPI spec and Swagger UI generated from your code (inspired by API Platform), a Twig-inspired template engine, pluggable authentication, response caching, background jobs and more.

But because of Geblang's advantages over PHP, I am also able to offer WebSockets and server-sent events in the same server.

Gebweb provides its own CLI too with an interactive project wizard and a Symfony-inspired profiler bar that drops into your pages in development.

I'm pleased to announce Gebweb is now publicly available at github.com/dwgebler/gebweb.

Easy to build, easy to maintain

I mentioned already that deployment pain is something I wanted Geblang to avoid.

For users, a Geblang application is (or can be bundled as) one binary, its assets embedded. Dependencies are declared in a manifest YAML file before build. There is no runtime to version-match on the server, no extension compilation, no vendor install on the server.

And in terms of the language engine, Geblang is a single Go module with a deliberately small dependency footprint. If you have Go installed, make build produces the toolchain, make test runs everything, and there's a Docker build if you'd rather not install Go at all. Your system doesn't need anything else to produce Geblang from source.

Adding a new stdlib module touches a known list of places and a guard test fails if any are forgotten, including the IDE completion catalog.

I can make a change to the language with two complete implementations and know within minutes whether I've broken either of them.

For a project built at this pace, with this much generated code under human direction, this safety net works.

In my first post I said Geblang exists for learning and fun.

That's still true. But it's come so much further than that. It's a language which has already proven to me it has real use. Just probably still many more edge-cases I'm yet to discover.

If you're curious, please check it out, leave me a star or find a bug and file an issue.

Comments (0)

No comments yet. Be the first to share your thoughts!

Leave a Comment