goer Download ↓

Reference / imgo

The language, honestly described.

“A cut-down version of Go” is the kind of phrase that makes a Go developer nervous. Cut down how much? Will the thing I need be missing? This page answers that plainly. You’ll see what imgo (“immutable Go”) keeps, what it drops and why, and how we check that it behaves like Go where it claims to. There are three small programs you can run right on the page.

The short version: imgo is Go with one line at the top of the file, //go:build goer. It uses Go’s own type checker. And it takes away the things that let two pieces of code change the same data.

The idea

imgo doesn’t add any syntax, and there’s no new file type. It borrows a few of Go’s spellings for process things: pid <- M{} sends a message, go Worker() starts a process, close(pid) stops one, and select picks a message out of the mailbox.

Data works differently, though. A value belongs to whoever is holding it. Structs, slices, strings, and maps all live in one process’s memory, and when you send one in a message, the receiver gets its own copy.

And there’s one rule that everything else hangs on: a process can start processes and send messages, and that’s all it can do to the world. Files, sockets, clocks, and payment providers are reached through a port.

What you can write

WhatHow you write itGood to know
numbers and basicsint · int8…int64 · uint…uint64 · float32/float64 · bool · string · []byteThey behave like Go’s at every size, wraparound included. One thing imgo pins down that Go leaves to the platform: turning a float into an integer type that can’t hold it gives the same answer everywhere (NaN becomes 0, too-big clamps to the limit).
structsstruct { Name string }You can read and write nested fields (a.B.C). Changing a struct you hold never changes anyone else’s copy.
methodsfunc (a Account) Deposit(n int) AccountValue receivers only, so a method returns the new value. Method names have to be unique in the package.
slices and arrays[]Tmake([]T, n), append, len, range, slicing, copy, clear. A slice is a plain value: assigning or passing one gives a separate copy. Nested writes work: s.Items[i] = v, grid[i][j] = v, xs[i].F = v.
mapsmap[K]TKeys can be strings, integers, floats, or bools, including named types. delete and clear work. A map is a plain value too.
operators+ - * / % · & | ^ &^ << >> · every op= · ++/-- · min/maxAll of Go’s arithmetic and bit operators, on variables, fields, and elements.
variablesvar · := · a, b = b, a · const · iotavar with or without a value, several names at once, swapping, and constants (your own and imported ones).
stringslen · s[i] · s[a:b] · + · range · []rune(s) · []byte(s)Same as Go. Length, indexing, and slicing count bytes. range goes character by character. Escapes like \n in a literal work.
sendingpid <- m · select { case pid <- m: … default: … } · Monitor(pid)A send never waits. If the process has stopped, the message is dropped. If its mailbox is full, the sender crashes, unless you used the select form, in which case default runs. Monitor gets you a Down message when a process stops.
control flowif · switch · for · range · break · continue · gotoValue and type switches, fallthrough, labels, and goto inside one function. A loop that never ends gets cut off after a set amount of work.
several resultsfunc Split(n int) (A, B)Any mix of basic values and structs. Named results and functions that return nothing work too.
type assertionsx.(T) · v, ok := x.(T)Both forms, on variables, fields, map and slice reads, and call results.
packagesimport "myapp/pricing"An imgo package can import other imgo packages and the small goer/std library (strings, slices, sort, strconv, math). Nothing else.
functions as valuesfunc(x int) int { … } · x.M · T.MA function literal gets its own copy of the variables it uses, and can’t change them afterwards. Method values work too.
genericsfunc F[T any](x T) T · Pair[K, V]Each use is turned into ordinary code at compile time. Works for your own and imported generics, with the types spelled out or worked out for you.
defer · select · recoverdefer f() · select { case x := <-M: } · recover()defer runs when its own function returns or panics. select waits for a message of a given type, with an optional timeout or default. recover only catches a panic from its own function.
range over a functionfor v := range itGo 1.23’s range-over-func, with one or two values.

A note on []byte

imgo has a real []byte, and it holds exactly the bytes you gave it. You can take its length, index it, slice it, compare it, range over it, append to it, and convert to and from string.

What you can’t do is change one in place. There’s no b[i] = x and no make([]byte, n). append gives you a new value. That’s the same rule as everything else in imgo.

Messages

A message is data you send to a process. The process reads it when its turn comes. In imgo it’s a struct, and by habit the behaviour’s second parameter is Msg interface{} so a type switch can sort messages out. From Go, the same message is a msg.Spec: a type name and some fields.

Simple fieldsA field can be an int64, float64, bool, string, or Pid. A Pid field called ReplyTo means “send the answer here”.
Nested fieldsA field can also hold a list, a struct, or a map, as deep as you like. From Go, build it with msg.Encode and attach it with msg.Val. The receiver unpacks it into its own memory. Functions and interfaces can’t be fields.
BytesA []byte field travels as a string field holding the same bytes. In Go, ports.FieldBytes reads it back as a []byte.
Slim processesThe stripped-down processes the megapark benchmark uses only accept runtime.CompactMsg.

defer and select

defer

defer f(args) works much like Go’s. The arguments are worked out when the defer line runs. Deferred calls run last-in, first-out when the function returns, or when that same function panics. A defer in a loop adds one call each time round.

Two differences from Go. A panic in a function you called doesn’t run your defers. And a function that uses defer can’t also use Ask; the compiler will tell you.

recover() catches a panic from its own function only, and all you can ask it is whether there was one: recover() != nil.

select

A process has one mailbox, so select does the one useful thing it can: wait for a message of a certain type. Each case names a message type, or a timeout with SelTimeout(ms), or default for “don’t wait”.

It looks through the mailbox from the oldest message, takes the first one that matches, and leaves the rest waiting in order. With no default and no timeout, it waits for as long as it takes.

selective receive
select {
case x := <-Pong:     // binds the matched message record, owns it
    s.Result = x.N
case <-SelTimeout(50): // fires once; delivers nothing to the mailbox
    s.TimedOut = 1
default:               // non-blocking: runs when nothing matches now
}

Calling out to Go

Anything with an effect happens on the Go side. A port is Go code that looks like a process: you send it a request, it does the work, it sends back a reply. A port is a func(msg.Spec) msg.Spec. It runs on its own goroutine, so it can take as long as it needs, and only the process that asked is kept waiting.

imgo can also call some Go functions directly. That interface is finished: all 42 items on its checklist are done.

Try it

Each of these is a complete program. Press run and the page compiles it with the real imgo compiler and sends it one message, just like the playground does. What you see is the state afterwards.

Strings are bytes, and range goes by character. Same as Go.

runes.go
//go:build goer

package words

type State struct {
	Bytes int
	Runes int
	Upper string
}
type Measure struct{}
type Msg interface{}

func HandleInit() State { return State{} }

// shout upper-cases the ASCII letters and leaves every other rune alone.
func shout(s string) string {
	out := []rune{}
	for _, r := range s {
		if r >= 'a' && r <= 'z' {
			r = r - 32
		}
		out = append(out, r)
	}
	return string(out)
}

func Handle(s State, m Msg) State {
	switch m.(type) {
	case Measure:
		word := "héllo, 世界"
		s.Bytes = len(word)         // strings are bytes, as in Go
		s.Runes = len([]rune(word)) // and range over them by rune
		s.Upper = shout(word)
	}
	return s
}

Generics and functions as values. A generic function gets a separate copy for each type it’s used with, worked out at compile time. A function literal gets its own copy of the variables it uses.

pipeline.go
//go:build goer

package pipeline

type State struct {
	Total   int
	Longest string
}
type Run struct{}
type Msg interface{}

func HandleInit() State { return State{} }

func Map[T any, U any](xs []T, f func(T) U) []U {
	out := []U{}
	for _, x := range xs {
		out = append(out, f(x))
	}
	return out
}

func Fold[T any, A any](xs []T, acc A, f func(A, T) A) A {
	for _, x := range xs {
		acc = f(acc, x)
	}
	return acc
}

func Handle(s State, m Msg) State {
	switch m.(type) {
	case Run:
		words := []string{"let", "it", "crash"}
		lengths := Map(words, func(w string) int { return len(w) })
		s.Total = Fold(lengths, 0, func(sum int, n int) int { return sum + n })
		s.Longest = Fold(words, "", func(best string, w string) string {
			if len(w) > len(best) {
				return w
			}
			return best
		})
	}
	return s
}

A fault is a panic. Dividing by zero panics on that line, the deferred call runs, and recover stops it. The function returns its named results as they were at that moment.

careful.go
//go:build goer

package careful

type State struct {
	Quotient int
	Failed   bool
}
type Divide struct{ By int }
type Msg interface{}

func HandleInit() State { return State{} }

// swallow stops a panic raised in the function that deferred it.
func swallow() {
	if recover() != nil {
	}
}

// safeDiv returns its named results as they stood when the fault happened.
func safeDiv(a int, b int) (q int, failed bool) {
	failed = true
	defer swallow()
	q = a / b // b is 0 in the scene: a runtime fault, raised as a panic
	failed = false
	return q, failed
}

func Handle(s State, m Msg) State {
	switch x := m.(type) {
	case Divide:
		q, failed := safeDiv(84, x.By)
		s.Quotient = q
		s.Failed = failed
	}
	return s
}

How it’s tested

Here’s how we know imgo does what it says. Goer runs imgo two ways: interpreted, and compiled to machine code. Every language test runs both ways and has to give the expected answer both times. Each test also checks how much memory was freed, so a leak fails a test.

Then there’s the bigger check. We keep 135 small programs that are valid in both Go and imgo. The Go compiler runs them, imgo runs them, and the answers have to match. That way Go itself is the judge.

Anything involving real processes (starting them, sending, Ask, other nodes, a backed-up mailbox) is tested against a live running system.

What’s left out

Missing on purpose.Goroutines, real channels, closures that change what they captured, pointers, unsafe, cgo, direct file or network access, and package-level variables you can change. A chan T can show up as a type, but only as a stand-in for “somewhere to reply”.

Go that won’t compile. You get an error pointing at the spot for: cap (a slice has no hidden spare room to ask about); copy inside a bigger expression (write n := copy(dst, src) on its own line); case nil in a type switch; writing through a function’s result (f().x = v); and putting a call’s several results straight into fields (s.A, s.B = f()).

Type switches go by shape. A switch on the message a behaviour receives goes by the struct’s type, as you’d expect. A switch on any other interface value can only tell an integer from a string from a list from a struct. So two struct cases there won’t compile, and an int64 matches case int.

Faults are panics, with three exceptions. An index out of range, dividing by zero, a failed type assertion, or calling a nil function all panic on the line that did it, so defer and recover work on them. Running out of the work budget, calling too deep, or a failed call to the runtime (like sending to a full mailbox) aren’t panics. Those stop the process on the spot.

A few things you do from Go. SuperviseOn is one. And Ask always means one reply; for a port that sends many, send the request with ReplyTo and handle the pieces in your switch.

Everything else we set out to do is in: processes that can’t touch each other, supervisors, changing code between messages, running across machines, and all of it living inside an ordinary Go program.