I'm Building My Own Programming Language

In July 2018 I started Expr, an expression language for Go. It began as a small side project. Today it runs inside Uber, TikTok, GoDaddy, Aviasales, and many other companies. People write business rules, filters, and alerts in it, and Go programs evaluate them millions of times a second.

Expr is deliberately small. It evaluates expressions, not programs. Every time I worked on it, a quiet thought kept coming back: what would a whole language look like?

I always wanted to build my own programming language. I never had the time. A language is a parser, a type checker, an interpreter, a standard library, tests, docs. That is years of evenings.

Now I have the time. Or rather, I have LLMs. So I'm doing it.

It's called Ant.

What I like

TypeScript is my favorite language to write. Types catch my mistakes before I do, and unions changed how I think about data. Once you start describing things as "this or that", every language without unions feels like something is missing.

I like JavaScript too. Almost every programmer can read it. Lambdas are short, template strings are pleasant, and you can get an idea working in minutes.

But JavaScript has so much crap in it. undefined and null. == and ===. this. Truthiness. "1" + 1. async spreading through your whole codebase. TypeScript can't fix most of it, because it has to stay compatible with JavaScript.

So here is the idea: take JavaScript and TypeScript, keep the good parts, and drop all the crap.

Unions

Unions are the heart of Ant. A record type is a name plus fields, and a union is a list of types:

type Circle = { radius: float64 }
type Rect = { width: float64, height: float64 }
type Shape = Circle | Rect

func area(s: Shape): float64 {
  if s is Circle { 3.14 * s.radius * s.radius }
  else if s is Rect { s.width * s.height }
}

There is no kind: "circle" field to maintain by hand: every record knows its type, and is checks it. Inside the branch, s is a Circle.

Notice there is no final else. The checker knows Circle and Rect are all the cases. If I add Triangle to Shape later, area stops compiling until I handle it.

Errors are unions too. No exceptions, no try/catch:

type NotFound = { id: string }
type Insufficient = { short: int }
type TransferError = NotFound | Insufficient
type Account = { id: string, balance: int }

func withdraw(accounts: Map[string, Account], id: string, amount: int): Result[void, TransferError] {
  var account = accounts[id] ?? return Result.err(NotFound{ id })
  if account.balance < amount {
    return Result.err(Insufficient{ short: amount - account.balance })
  }
  account.balance -= amount
  Result.ok(void)
}

?? return is one of my favorite things. A map lookup can miss, so accounts[id] has the type Account | null, and ?? lets you bail out right there. Below that line, account is just an Account. And try unwraps a Result or returns the error to the caller, like Rust's ?.

Lightweight types

Type annotations should help you, not get in your way.

Ant has two commands. ant check runs the type checker, which is what your editor and CI use. ant run parses the program and runs it, skipping the annotations entirely. Want to try something quickly? Just run it. Want guarantees? Check it.

Most types are inferred. You annotate function parameters, and that's mostly it.

Dropping the crap

Here is what's gone:

The checker catches all of these. Even without the checker, the runtime refuses to guess:

panic: + needs two numbers of one kind or two strings, got string and int

Pipes

The best pipe syntax already exists, and it's in bash: |. One character, and everybody knows what it means. Most languages can't use it, because | is taken by bitwise OR, so they settle for |>.

But how often do you write bitwise operations? In most code, almost never. So Ant gives | to pipes, as Expr does, and moves bit operations into the Bits namespace: Bits.or(READ, WRITE), Bits.shiftRight(word, 8).

var names = users
  | Array.filter({ x.age >= 18 })
  | Array.map({ x.name })
  | Array.sort()

a | f(b) simply means f(a, b). And { x.age >= 18 } is a lambda: a block with one implicit parameter, x.

No methods

In JavaScript user.name is a field and user.greet() is a method. They look the same. Is greet on the object? On the prototype? Is it a getter? Does it lose this when I pass it as a callback? I find this confusing, and I've been writing JavaScript for a long time.

So Ant has no methods. A dot always means a field. Functions live in the namespace of a type:

type Point = { x: float64, y: float64 }

func Point.distance(a: Point, b: Point): float64 {
  Math.sqrt((a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y))
}

Point.distance(p, q)
p | Point.distance(q)

p.distance(q) is an error: Point has no field distance.

This also kills the receiver problem. There is no this in JavaScript, no self in Python or Rust, no func (p *Point) in Go. The "receiver" is just the first parameter, with a normal name and a normal type. And with the pipe, p | Point.distance(q) reads like a method call anyway.

No colored functions

Bob Nystrom wrote a famous post: What Color is Your Function? In JavaScript, once one function becomes async, every caller has to become async too. The color spreads.

Ant has no async and no await. Any function can block, and any call can run concurrently with go:

func slowSquare(n: int): int {
  Time.sleep(Time.duration(milliseconds: 100))
  n * n
}

func main() {
  print(slowSquare(3))                // a plain call

  var results: Channel[int] = Channel.new()
  for n in [1, 2, 3] {
    go { Channel.send(results, slowSquare(n)) }
  }
  var total = 0
  for _ in [1, 2, 3] { total += Channel.receive(results) ?? 0 }
  print(total)                        // 14, after 100ms, not 300ms
}

slowSquare is just a function. No color.

Go stuff is amazing

You may have noticed: this part is stolen from Go. Goroutines, channels, select, defer, WaitGroup, contexts with deadlines. Go got concurrency right, and I see no reason to invent something worse.

Ant is also written in Go, and like Expr, it can be embedded into a Go program as a library. Load a script, call its functions, give it your own Go functions to call.

So the recipe is: the syntax of JavaScript, the types of TypeScript, the concurrency of Go, and none of the crap.

Building a language with LLMs

What does it mean to build your own language with LLMs?

For me, it means the boring parts stop being a wall. I write the specification: every rule, every edge case, every decision about what goes in and what stays out. The LLM writes the interpreter, the checker, and the tests against that spec. When the spec is unclear, the implementation shows it fast, and I fix the spec.

The interesting part, designing the language, is still mine. That is the part I always wanted to do. The rest used to be the reason I never started.

So what does it mean? Fun. It means fun.

Try it

go install github.com/ant-src/ant/cmd/ant@latest
ant check main.ant
ant run main.ant

Ant is young, and the spec is still a draft. I'd love to hear what you think: what you'd keep, what you'd drop, and which crap I missed.

1
261

Comments

No comments yet. You can be the first one!