Reference

The forgo documentation

Every addition forgo makes to Go, with the rules and the current limits. Sections follow the repository README.


Install & upgrade .fgo files ? — error propagation throw Postfix if //fgo:comptime //fgo:macro comptime/embed comptime/json Non-scalar consts Hot reload c-shared plugins Editors Versioning Building from source

Install & upgrade

# Linux / macOS
curl -fsSL https://github.com/lsegal/forgo/releases/latest/download/install.sh | sh

# Windows
irm https://github.com/lsegal/forgo/releases/latest/download/install.ps1 | iex
  • Both scripts install the latest GitHub release to ~/.forgo by default. Pass a specific version (sh install.sh v0.2.0, or -Version v0.2.0 on Windows) to install that release instead of latest.
  • Set FORGO_INSTALL_DIR / FORGO_REPO to change the install location or fork.
  • After installing, point GOROOT at the install directory and add its bin/ to PATH — the script prints the exact commands — then run forgo build, forgo run, and so on.
  • forgo upgrade (alias forgo update) re-runs the same install script over the current installation. Pass a version to install that instead of latest (forgo upgrade v0.4.0); it honors the same environment variables.

.fgo files

Files using forgo-specific syntax use the .fgo extension instead of .go. The toolchain treats the two identically everywhere — forgo build, forgo run, forgo test, module resolution — so a package can freely mix both. Files that are plain Go keep the .go extension.

? — Rust-style error propagation

expr? evaluates expr, and if it produced a non-nil error, returns immediately from the enclosing function with that error; otherwise it evaluates to the non-error value, so it chains.

func loadConfig(path string) (name string, err error) {
	f := open(path)?      // returns early if open fails
	cfg := parse(f)?.normalize()?
	name = cfg.Name
	return
}
  • ? only works inside a function (or func literal) whose last result is a named error. On error it assigns to that named result and does a naked return, relying on Go’s zero-initialization of the other named results.
  • Used where a value is expected (x := f()?, f()?.g()), it assumes expr returns exactly (value, error). Used as a whole statement, it assumes expr returns only error; to discard a value explicitly, write _ = f()?.
  • Chaining works because ? is parsed as a postfix operator at the same precedence as . and (...): foo()?.bar()? parses as (foo()?).bar()?.
  • It works in an if/for init clause and in a for loop’s Cond/Post clauses. Such a loop is rewritten with the condition checked (and break on failure) at the top of the body and the post statement moved to the bottom; continue targeting that loop is redirected to run the post statement first, so nested loops are unaffected.
  • ? is lowered before type-checking, so it can’t verify its assumption about expr’s results — a mismatch surfaces as an ordinary type-checking error. It also can’t be used directly inside a labeled statement if that would require hoisting code before the label; move the fallible call above the label instead.

Runnable: examples/tryop/main.fgo, and examples/tryop/loops.fgo for loop headers and labels.

throw — fail a function out with a new error

throw EXPR returns immediately from the enclosing function, passing EXPR through as the last (error) result and a zero value for every other result. throw "some text" is sugar for throw errors.New("some text") — the two forms are exactly equivalent.

func makeThing(s string) (*Thing, error) {
	if s == "" {
		throw errors.New("empty")     // same as: return nil, errors.New("empty")
	}
	return &Thing{name: s}, nil
}

// throw "text" (a bare string literal) is sugar for throw errors.New("text")
func makeThing2(s string) (*Thing, error) {
	if s == "" {
		throw "empty"                 // same as: throw errors.New("empty")
	}
	return &Thing{name: s}, nil
}
  • throw works in any function (or func literal) whose last result's type is spelled error — unlike ?, it doesn't need that result to be named, since it builds the return's value list directly instead of relying on Go's naked-return zero-initialization.
  • Every result before the last needs a zero value the compiler can work out from its syntax alone: pointer, slice, map, chan, func, interface (including any/error), or a basic type. A named struct, array, or other defined type isn't nil-able and can't be zeroed without a type checker, so throw there is a compile error — no default value for <result> — naming exactly which result couldn't be zeroed; fall back to a manual return for that function.
  • throw "literal text" requires the file to already have import "errors" (under any name, or .) — a package's import graph is computed from the literal source text before the compiler runs, so an import added only during compilation wouldn't be visible to it. throw errors.New(...) has no such requirement since you're already spelling out the import yourself.
  • throw is a contextual keyword, not a reserved word — plain Go code that uses throw as an ordinary identifier or function name (like runtime.throw in the standard library) is completely unaffected. throw is only read as the statement when immediately followed by a new operand; throw(x), throw.field, throw = x, and a bare throw all still parse as the identifier throw.

Runnable: examples/tryop/chain.fgo for a version using both throw forms.

Postfix if — one-line guard clauses

STMT if COND is shorthand for if COND { STMT }, Ruby/Perl-style — useful for the kind of one-line guard clause throw is often used in.

func check(s string) (n int, err error) {
	throw "empty" if s == ""     // same as: if s == "" { throw "empty" }
	return len(s), nil
}

continue if i%2 == 0
total += i if want(i)
return errors.New("bad") if x < 0
  • Eligible statement kinds: an expression statement, a channel send, a plain (non-:=) assignment (including ++/--), return, throw, and break/continue/goto.
  • x := f() if cond is not allowed — wrapping a short variable declaration in an implicit block would silently shrink its scope to just that block, exactly the kind of subtle bug postfix if should never introduce. Use the ordinary block form when you need to declare inside the guard.
  • fallthrough if cond is not allowed either, since fallthrough must remain the last statement of its switch case, not the body of a synthesized if.
  • Unambiguous by construction: a statement must always be followed by ; before the next statement can begin, so if appearing immediately after a just-completed statement on the same line was always a syntax error before — there's no existing program this could misparse.

Runnable: examples/tryop/postfixif.fgo

//fgo:comptime functions

A function marked //fgo:comptime is ordinary Go — it type-checks and compiles normally, and can be called at runtime like any other function. Additionally, when it (or a chain of comptime functions calling each other) is invoked directly as the initializer of a const declaration with constant-foldable arguments, the compiler evaluates it during type-checking and folds the result into a real Go constant — usable anywhere the language requires a constant expression.

//fgo:comptime
func calculateFactorial(n int) int {
	result := 1
	for i := 1; i <= n; i++ {
		result *= i
	}
	return result
}

//fgo:comptime
func factorialMessage(n int) string {
	return fmt.Sprintf("Factorial of %d is %d", n, calculateFactorial(n))
}

// Evaluated entirely by the compiler.
const factFive = calculateFactorial(5)
const msg = factorialMessage(5)
  • Supported inside a comptime function body: int/float/string/bool locals and arithmetic, if, 3-clause for loops, compound assignment, ++/--, calls to other //fgo:comptime functions, and a small allow-list of real stdlib calls (fmt.Sprintf, strconv.Itoa, and the comptime/embed helpers). Anything else (closures, goroutines, maps, slices, method calls, range loops, …) is not supported in v1 and reports a compile error if reached.
  • Folding only triggers for const initializers, not other constant-expression contexts written directly as a call — go through an intermediate const if you need that. Comptime calls also can't cross package boundaries in v1: a function must be tagged //fgo:comptime in the same package where it's folded.

Runnable: examples/factorial/main.fgo, including proof that factFive is a real constant (it sizes an array).

//fgo:macro functions — AST macros

A function marked //fgo:macro receives the unevaluated syntax tree of each of its call-site arguments and returns a syntax tree that is spliced into the caller's code in place of the macro call — before type checking runs. Macro functions are never type-checked or compiled themselves; they exist purely at compile time and are removed from the AST once expanded.

//fgo:macro
func double(x Node) Node {
	return Quote(func() {
		Splice(x) + Splice(x)
	})
}

double(compute()) // expands to: compute() + compute()
  • Quote(func(){ ... }) captures the function literal's body as an AST template without evaluating it. A single-expression template unwraps so the macro can be used in expression position; anything else is treated as a statement-position macro.
  • Splice(x) marks a point in a quoted template where the tree bound to local variable x (a macro parameter, or a value derived from one) is substituted in.
  • Macro parameter/return types (Node above) are placeholders — v1 does not define a real Node type, since macro signatures are never type-checked. Macro call recognition is purely syntactic, expansion happens once per call site (recursively into the result), macros are expanded within function bodies only (not package-level var/const initializers in v1), and macros have no hygiene/renaming.

comptime/embed — compile-time file reads

A small stdlib package for reading files and inspecting the filesystem at compile time, so a file's contents (or a directory listing, or an existence check) can be folded into a const the same way a //fgo:comptime function's result can.

import "comptime/embed"

const banner = embed.ReadFile("banner.txt")   // read entirely by the compiler
const hasConfig = embed.Exists("config.json")
const assetNames = embed.ReadDir("assets")    // newline-joined entry names

It provides ReadFile, ReadFileRange(path, offset, length), Exists, IsDir, ReadDir, and Getwd. A relative path is resolved against the directory of the source file containing the call (like Zig's @embedFile), not the compiler's working directory. Every function panics on error rather than returning one — a missing file surfaces as a compile error pointing at the const line, not a runtime panic.

Load(patterns ...string) FS goes further and embeds a whole tree of files into an FS value, the way //go:embed populates an embed.FS — folded through a const initializer instead of a directive.

const content = embed.Load("image", "template", "html/index.html")

data, _ := content.ReadFile("image/hello.jpg")
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(content))))

A pattern naming a directory embeds every file in that directory's subtree (skipping names beginning with . or _); a plain path embeds exactly that one file. FS implements io/fs.FS, io/fs.ReadFileFS, and io/fs.ReadDirFS — content above is a genuine compile-time constant (see non-scalar consts), not a value rebuilt from disk at every reference.

Runnable: examples/embedfile/main.fgo, and examples/embedfs/main.fgo for embedding a small file tree and serving it over HTTP.

comptime/json — compile-time JSON marshal/unmarshal

Marshals and unmarshals JSON at compile time, so a value built from a struct/slice/map composite literal can be folded into a const string, and (combined with comptime/embed) a JSON file's fields can be folded into consts of their own.

import (
	"comptime/embed"
	"comptime/json"
)

type Schema struct {
	Name string
	Port int
}

const cfgJSON = json.Marshal(Schema{Name: "svc", Port: 8080})
const schema = json.Unmarshal[Schema](embed.ReadFile("schema.json"))

// schema is a real compile-time constant of type Schema, so schema.Name,
// schema.Port, etc. are themselves constants, usable anywhere Go requires one.
const port = schema.Port

Unmarshal is generic (Unmarshal[T any](s string) T) purely so real Go type-checking accepts .Field on its result with a concrete field to point at; matching against a struct's fields ignores Go's export-name capitalization or json:"..." struct tags — keep a struct's field names identical to the JSON keys you read them by. This isn't available as general local variables inside a //fgo:comptime function body (no closures, generics, or method calls there either).

Runnable: examples/schemajson/main.fgo, loading and unmarshalling a schema.json file entirely at compile time.

Non-scalar (struct/slice/map) consts

Ordinary Go restricts const to bool/numeric/string values — a struct, slice, or map can never be a constant, in any Go compiler. forgo changes that: a struct/map (an ordered field name → value mapping) or a slice/array (an ordered element list) can be a real constant, alongside the ordinary scalar kinds. In practice, this is what lets comptime/json's Unmarshal[T] fold into a real, named constant instead of only a one-shot expression.

type Schema struct {
	Name string
	Port int
	Tags []string
}

const schema = json.Unmarshal[Schema](embed.ReadFile("schema.json"))

// schema is a genuine compile-time constant, referenceable anywhere in the
// package, not just inside the expression that produced it.
const name = schema.Name
var proof [schema.Port % 16]byte // usable as an array length, like any const

schema.Field and schema.Tags[0] are themselves constants — so they compose with everything an ordinary scalar const already does: sizing an array, seeding another const, a case label, and so on.

Current limits

  • A struct-, array-, or slice-shaped composite const (including nested combinations, like embed.FS's struct-holding-a-slice-of-structs) can be used directly as an ordinary runtime value. Every reference to the same const reads from the same underlying data.
  • A composite const with a map field still can't be used bare this way — only through field/index access down to a scalar or a further struct, as in schema.Tags[0]. Go doesn't statically lay out map data at all, so materializing one as a plain value at an arbitrary reference site is unsupported; using one where it isn't supported reports a specific compile error rather than crashing.

forgo run --watch — hot reload

$ forgo run --watch ./examples/hotreload
pid 44081 — edit render() in examples/hotreload/main.fgo and save
tick 1
tick 2
...                                    # edit render(), save
forgo: reloaded 1 function in 11.9s
GEN1 tick 104 (worker beat 51, 104 in history, up 51s)
GEN1 tick 105

Edit a function, save, and the running program picks up the new code without restarting. Its goroutines keep running, its heap is untouched, and every package-level variable keeps the value it had — counters keep counting, caches stay warm, open files and connections stay open. This is the same thing Dart and Flutter do, done for an ahead-of-time compiled language. Note the tick count and the uptime above: same process, new code.

How it works

A Go binary is fully linked ahead of time, so "inject new code" has to mean something concrete:

  1. The first build is an ordinary one, except the linker also writes down a record of everything it linked: for each symbol, its address in the binary and a digest of its contents together with the names its relocations point at.
  2. The program starts with a small agent (runtime/fgohot) linked in. The agent reserves half a gigabyte of address space within reach of the program's own code and tells the watcher where it is.
  3. On save, the whole program is linked again, against that record. Any symbol whose digest is unchanged is pinned: it keeps the address it already occupies in the running process, and is left out of the output entirely. Only what changed, plus anything genuinely new, is laid out into the reserved region. This is the step that preserves state — nothing is copied, migrated, or re-initialized, because nothing moved.
  4. The agent maps the resulting image, registers its moduledata with the runtime, runs the initializers of packages that are new, and finally overwrites the first five bytes of each changed function with a jump to its new body, with the world stopped.

A function already executing finishes its current call in the old code; the next call runs the new code. Dart's hot reload makes the same bargain.

A change inside a function that is already running and never returns doesn't take effect, because the patch only redirects future calls, and there is no future call. The most common way to hit this is editing code written directly in main()'s own loop body — move logic you want to be reloadable into an ordinary function that gets called repeatedly, and edit that instead. (Flutter's hot reload has the identical rule for a State.build() that's already mid-execution.)

What requires a restart

Some changes cannot be applied to a process that is already running, and forgo refuses them rather than corrupting it. The program keeps running the old code and the watcher says why:

forgo: cannot reload without restarting — the type main.Point changed shape
  • A struct's layout changed. Objects already on the heap were laid out by the old descriptor, and code that is not being replaced still uses it.
  • A function's signature changed. Callers that are not being replaced would still pass arguments the old way.
  • A package's initialization changed. Package init runs once per process; re-running it would trample the state it already built, and skipping it would leave the new code without state it expects.

Limits

  • linux, macOS, and windows.
  • On Windows, the watched build always uses -buildmode=exe, overriding the toolchain's PIE-by-default on windows/amd64, so a hot link's addresses stay meaningful from one run to the next. The linker refuses to hot-link a PIE binary outright rather than silently producing bad relocations.
  • Windows images are never handed to the OS's PE loader — the agent maps them itself, resolving the image's own Import Address Table by walking the PE import directory and calling GetProcAddress directly.
  • The watched build compiles with inlining disabled (-gcflags=all=-l), so changing a function cannot leave stale copies of its body inside callers that are not being replaced. Watch mode is therefore slower than a normal build — it is a development mode, not a production one.
  • Each reload consumes a slice of the reserved region; after a few hundred reloads the program must be restarted. It says so when that happens.
  • A reload links the whole program, so it costs about as long as a link, not as long as a build from scratch — under a second to a couple of seconds for a small program on a native filesystem.
  • --watch runs the package with default build flags; other build flags passed alongside it are not yet forwarded to the watched build.

Several Go runtimes in one process — c-shared plugins

Plugin hosts load every plugin into one process: a DAW loading VST3/CLAP plugins, Python loading extension modules. Each -buildmode=c-shared library carries its own Go runtime, and upstream Go does not support two of them side by side (golang/go#65050). forgo libraries can share a process with each other, with several instances of the same plugin, and with copies of one plugin loaded from two paths.

Each library keeps a fully separate heap, garbage collector, scheduler, and set of goroutines, and the libraries only meet through the C ABI. Each runtime keeps g in its own thread-local slot, and no Go symbol is exported, so neither runtime can bind to the other's copy. Signal handlers chain: the library loaded last passes a signal on when the thread is not running its own Go code. forgo also does that for the preemption (SIGURG) and profiling (SIGPROF) signals. Without it, the last library swallows the earlier library's preemption requests, and the earlier runtime's next garbage collection hangs.

Rules for plugin authors

  • Pass only C data between libraries. Never hand a Go pointer, func value, channel, or interface to another library.
  • All instances of a plugin loaded from one file share one library image and one runtime, so package-level variables are shared by every instance. Keep per-instance state behind a handle such as a runtime/cgo.Handle.
  • Expect several host threads to call in at once, and expect an instance to move between threads.
  • A library can be unloaded (dlclose, FreeLibrary) and loaded again; the next load gets a fresh runtime. Goroutines still alive at unload are discarded without running their deferred calls, so clean up from the host's deinit callback.
  • A goroutine in a blocking system call or C call must return before the unload; after five seconds the unload is refused with a fatal error. No host thread may be inside a call into the library while it is unloaded.
  • Unload libraries in the reverse order of loading when you can: a library can only take its signal handler out of the chain when no handler was installed on top of it.

Process-wide state

  • os/signal.Notify: every forgo runtime that calls Notify for a signal gets it, and a C handler installed before the libraries loses it until they have all called Reset. On Linux, macOS, and FreeBSD the runtimes recognize each other's handlers through the dynamic loader; a c-archive runtime, an upstream Go library, or a statically linked program falls back to upstream's rule that the runtime that asked last gets the signal. Unlike upstream Go, a runtime that calls Reset while another runtime's handler sits on top leaves that handler in place.
  • SIGPIPE: a Go write to a closed pipe or socket fails with EPIPE in whichever runtime made it.
  • Open-file limit: a forgo c-shared or c-archive library leaves RLIMIT_NOFILE to the host instead of raising it, so a second runtime cannot mistake the raised limit for the host's and hand it to its child processes.
  • Environment: each runtime copies the environment when it loads, so os.Setenv and GODEBUG changes in one runtime do not reach runtimes already loaded.
  • Exit: os.Exit in any runtime ends the process at once, running only that runtime's exit hooks.
  • Crash reports: an unrecovered panic or fault in one library prints that library's goroutines only.
  • Windows: timer resolution requests are counted per call, so runtimes cannot cancel each other's. Console control events (os.Interrupt and SIGTERM) reach every runtime that called Notify for them, through a per-process registry the runtimes share.

Limits

  • darwin/amd64 is not covered: every Go runtime there stores g in the same Apple-reserved TLS slot, which is the golang/go#65050 crash. forgo does not currently build for darwin/amd64, linux/arm64, or windows/arm64.
  • Unloading is supported on linux/amd64, linux/arm64, darwin/arm64, windows/amd64 and windows/arm64. Elsewhere a library stays loaded after dlclose, as with upstream Go.
  • A library built by upstream Go does not forward preemption signals, so one loaded after a forgo library can still swallow the forgo library's preemption requests. Load it first when you control the order.

Editors — forgopls and the VS Code extension

The Forgo VS Code extension adds .fgo syntax highlighting for ?, //fgo:comptime, and //fgo:macro, plus a language client backed by forgopls — a build of gopls that understands ? well enough to type-check it correctly. //fgo:comptime and //fgo:macro are still compiler-only and will show as false-positive diagnostics.

forgopls ships in the toolchain install; download the extension's .vsix from the latest release and install it with:

code --install-extension forgo-<version>.vsix

or in VS Code: Extensions view → ... menu → Install from VSIX.... See editors/vscode/README.md for settings and building from source.

Versioning

forgo has its own version, independent of the golang/go release it's synced against — tracked in FORGO_VERSION at the repo root and tagged as vX.Y.Z. Bump it locally with:

./scripts/version.sh show    # print the current version
./scripts/version.sh patch   # or minor / major — bumps and writes FORGO_VERSION

In practice this is normally driven by the Release workflow (Actions → Release → Run workflow → pick patch/minor/major), which bumps FORGO_VERSION, commits and tags it, builds the toolchain for linux/amd64, darwin/arm64, and windows/amd64, and publishes them all to a new GitHub release. CI builds and smoke-tests the toolchain on every push/PR (Linux, macOS, Windows) so a release build is never the first time a change gets built end to end.

Building from source

This repo's root is a Go distribution checkout (src/, plus bin/, pkg/, lib/, api/, go.env needed to bootstrap-build it) — it's what you get by forking golang/go. Build it like upstream Go:

cd src
GOROOT_BOOTSTRAP=/path/to/a/go1.24+/install ./make.bash   # Linux/macOS
# or ./make.bat on Windows, ./make.rc on Plan 9

This produces bin/forgo, the name you actually invoke:

GOROOT=/path/to/forgo /path/to/forgo/bin/forgo run ./examples/factorial