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