Every addition forgo makes to Go, with the rules and the current limits. Sections follow the repository README.
# 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
~/.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.FORGO_INSTALL_DIR / FORGO_REPO to change the install location or fork.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 filesFiles 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 propagationexpr? 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.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()?.? is parsed as a postfix operator at the same precedence as . and (...): foo()?.bar()? parses as (foo()?).bar()?.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 errorthrow 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.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.
if — one-line guard clausesSTMT 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
:=) 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.; 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 functionsA 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)
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.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 macrosA 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.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 readsA 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/unmarshalMarshals 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.
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
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.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.
A Go binary is fully linked ahead of time, so "inject new code" has to mean something concrete:
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.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.)
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
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.-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.GetProcAddress directly.-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.--watch runs the package with default build flags; other build flags passed alongside it are not yet forwarded to the watched build.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.
runtime/cgo.Handle.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.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.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.os.Setenv and GODEBUG changes in one runtime do not reach runtimes already loaded.os.Exit in any runtime ends the process at once, running only that runtime's exit hooks.os.Interrupt and SIGTERM) reach every runtime that called Notify for them, through a per-process registry the runtimes share.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.dlclose, as with upstream Go.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.
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.
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