Skip to content

All of Go, in fewer lines

Lyth compiles to the Go you would write by hand, minus the boilerplate. An error propagates with one character, the compiler checks for nil, and code does more per line, so it is easier to read and review, whether you or an AI wrote it. Every Go package works as is, and programs run as fast as hand-written Go, because they are Go.
users.lyth
import "errors"
struct User { // json tags: "id", "name", "email"
ID: int64
Name: string
Email: string? // *string in Go, null in JSON
}
func find(id: int64): User? throws { // Go: (*User, error)
if id < 0 {
throw errors.New("negative id ${id}")
}
if id == 0 {
return null // absent is a value, not an error
}
return User(ID = id, Name = "Ada", Email = null)
}
func describe(raw: string): string throws {
id := raw.toInt64()? // ? propagates the parse error
user := find(id)? ?: return "nobody"
email := user.Email ?: "no email"
return "${user.Name} <${email}>"
}
func main() throws {
println(describe("1")?)
println(describe("x") catch (err) { "error: ${err}" })
}
users.lyth.go, generated
import (
"errors"
"fmt"
"os"
"strconv"
)
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email *string `json:"email"`
}
func find(id int64) (*User, error) {
if id < 0 {
return nil, errors.New("negative id " + strconv.FormatInt(id, 10))
}
if id == 0 {
return nil, nil
}
return &User{ID: id, Name: "Ada", Email: nil}, nil
}
func describe(raw string) (string, error) {
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return "", err
}
var user User
v, err := find(id)
if err != nil {
return "", err
}
if v == nil {
return "nobody", nil
}
user = *v
var email string
v2 := user.Email
if v2 != nil {
email = *v2
} else {
email = "no email"
}
return user.Name + " <" + email + ">", nil
}
func run() error {
v, err := describe("1")
if err != nil {
return err
}
fmt.Println(v)
v2, err := describe("x")
if err != nil {
v2 = "error: " + err.Error()
}
fmt.Println(v2)
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}

The same program: 25 lines of Lyth, and the 62 lines of Go that lyth gen produces for it, generated by the current compiler each time the site is built. Every example in these docs has a Go tab made the same way.

Why Lyth

  • Less code for the same program. ? propagates an error, catch handles one inside an expression, ?: supplies a fallback for a missing value, and lambdas, when, and string templates replace the statements Go needs around them. In the benchmark suite, where each workload is written independently in both languages, the Lyth versions are about 20% shorter overall (277 versus 344 lines) and 37% shorter in error-heavy code, and a collection pipeline takes less than half the lines of the equivalent loops.
  • Easier to read and review. Much of today’s code is written with AI, often hundreds of lines at a time, and people still have to read it. With the plumbing gone, what is left is what the program does. The compiler checks what a reviewer would otherwise have to spot: using a value that may be nil without a check, or leaving an error unhandled, is a compile error rather than a crash in production.
  • Error handling without the ceremony. func find(id: int64): User? throws is Go’s func find(id int64) (*User, error). Errors stay values, as in Go: no exceptions, no unwinding, no hidden control flow. Only the repetition around them is gone.
  • All of Go, with no trade-off. The generated Go is what a careful Go programmer would write: no runtime, no reflection, no framework. Across 18 benchmarks (JSON, HTTP, PostgreSQL, concurrency, CPU- and allocation-heavy code), Lyth and hand-written Go show no measurable difference in time, allocations, or memory (results). Every Go package is used directly under its Go names, Lyth packages are ordinary Go packages that Go code can import, and builds use the official go command.
  • Built for backends. examples/todoapi is a complete REST API in Lyth: routing, JSON, bearer-token authentication, PostgreSQL through pgx, structured logging, graceful shutdown, and unit and integration tests, on net/http, pgx, and other ordinary Go libraries with no Lyth framework in between.

What Lyth changes, and what it does not

Lyth adds Lowers to
throws, postfix ?, catch, throw, typed catch (e: *NotFound) (T, error) results, if err != nil blocks, errors.AsType
nullable types T?, smart casts, ?., ?:, !! plain pointers/interfaces with nil checks
read-only parameters, loop variables, and value receivers compile-time checks only
lambdas { x -> x * 2 }, it, trailing lambdas Go closures
if/when expressions, string templates "${x}" statements, + with strconv
type tests x is T, as, as?, when x { is T -> } type assertions and type switches
struct construction User(Name = "a"), automatic json tags keyed composite literals, lower-camel json keys
typealias Handler = (Ctx) -> unit throws type Handler = func(Ctx) error
default and named arguments connect("db", timeout = 5 * time.Second) plain positional calls
x in xs, code in 200..299, for x in xs, while c, break@outer slices.Contains, comparisons, for range loops, labels
declared conformance struct Circle : Shape var _ Shape = Circle{}
ch.send(v), ch.receive(), ch.close(), select { v := ch.receive() -> ... } ch <- v, <-ch, close(ch), Go select
enum Color { Red, Green }, sealed interface Shape, exhaustive when iota constants with String(), an interface with a marker method, a switch with a panicking default

Where Lyth passes a Go concept through unchanged, it keeps Go’s spelling: func, :=, int and string, []T and map[K]V, chan T, generics in square brackets, conditions without parentheses, a capitalized name is exported, and a Lyth name is its Go name, so stack traces, go doc, and Go callers show what you wrote.

Lyth keeps Go’s semantics everywhere. Go packages are used directly with their exact Go names (http.ListenAndServe, r.URL). Lyth packages compile to ordinary Go packages with idiomatic APIs. There is no runtime, no reflection, and no framework.

Quick start

Install with Homebrew on macOS or Linux (it installs Go too), or with Go 1.26 or newer:

Terminal window
brew install lyth-lang/tap/lyth
go install github.com/lyth-lang/lyth/cmd/lyth@latest

Then, in a clone of this repository, run lyth run examples/hello/hello.lyth.

Inside a Go module, lyth works like the go command: lyth build, lyth run ./cmd/api, lyth test ./..., lyth vet, plus lyth check (type-check only) and lyth gen -stdout (print the generated Go). Builds never write into your source tree. Errors, go vet findings, test failures, and panics point at .lyth lines, with Lyth types and suggested fixes.

Start with the tour, or with Lyth for Go programmers if you know Go. Tooling covers every command and flag, and testing covers lyth test.