All of Go, in fewer lines
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}" })}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,catchhandles 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? throwsis Go’sfunc 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
gocommand. - Built for backends.
examples/todoapiis a complete REST API in Lyth: routing, JSON, bearer-token authentication, PostgreSQL throughpgx, structured logging, graceful shutdown, and unit and integration tests, onnet/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:
brew install lyth-lang/tap/lythgo install github.com/lyth-lang/lyth/cmd/lyth@latestThen, 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.