Skip to content

Lyth for Go programmers

If you know Go, you already know Lyth’s runtime, standard library, packages, concurrency model, and performance. Lyth changes how everyday code reads, and moves two classes of runtime failure (nil dereferences and ignored errors) to compile time. This page compares the two side by side, including the places where Lyth is no simpler than Go.

You also know most of Lyth’s spelling. For everything Go already has, Lyth writes it Go’s way: func, := and =, Go’s type names (int, string, error, any, map[K]V, chan T), generics in square brackets (Max[T: cmp.Ordered], errors.AsType[*E](https://github.com/lyth-lang/lyth/tree/main/docs/guide/err)), conversions such as float64(n), and if and for headers without parentheses. Lyth syntax appears where Lyth adds something Go lacks or changes a meaning Go would get wrong; the complete list is at the end of this page, with the reason for each difference.

Each example’s Go tab is what lyth gen produces from it. It is the code the Go compiler builds, so the comparison is exact: there is no hidden runtime or wrapper library. The generated code follows Go conventions closely, with a few telltale signs such as generated temporary names (v, v2).

import "errors"
import "strconv"
import "strings"
struct Order {
Item: string
Qty: int
}
func parseOrder(line: string): Order throws {
item, qty, found := strings.Cut(line, "=")
if !found {
throw errors.New("missing = in ${line}")
}
return Order(Item = item, Qty = strconv.Atoi(qty)?)
}
func parseAll(lines: []string): []Order throws {
orders: []Order = []
for line in lines {
orders = append(orders, parseOrder(line)?)
}
return orders
}
func main() {
orders := parseAll(["tea=2", "cake=1"]) catch (err) {
println("error:", err)
return
}
println(len(orders), orders[0].Item, orders[1].Qty)
parseAll(["tea=2", "cake"]) catch (err) { println("error:", err) }
}
import (
"errors"
"fmt"
"strconv"
"strings"
)
type Order struct {
Item string `json:"item"`
Qty int `json:"qty"`
}
func parseOrder(line string) (Order, error) {
item, qty, found := strings.Cut(line, "=")
if !found {
return Order{}, errors.New("missing = in " + line)
}
atoi, err := strconv.Atoi(qty)
if err != nil {
return Order{}, err
}
return Order{Item: item, Qty: atoi}, nil
}
func parseAll(lines []string) ([]Order, error) {
orders := []Order{}
for _, line := range lines {
v2 := orders
v, err := parseOrder(line)
if err != nil {
return nil, err
}
orders = append(v2, v)
}
return orders, nil
}
func main() {
orders, err := parseAll([]string{"tea=2", "cake=1"})
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(len(orders), orders[0].Item, orders[1].Qty)
if _, err := parseAll([]string{"tea=2", "cake"}); err != nil {
fmt.Println("error:", err)
}
}
2 tea 1
error: missing = in cake

Every ? is an if err != nil { return ..., err } in the Go tab. Lyth writes the zero values (Order{}, nil) for you, which is where hand-written Go error handling most often goes wrong. A call whose error is neither propagated nor handled is a compile error, not a go vet warning.

import "strconv"
import "time"
struct Config {
Port: int
Timeout: time.Duration
Debug: bool
}
func load(env: map[string]string): Config throws {
return Config(
Port = strconv.Atoi(env["PORT"])?,
Timeout = time.ParseDuration(env["TIMEOUT"])?,
Debug = strconv.ParseBool(env["DEBUG"])?,
)
}
func main() {
cfg := load(["PORT": "8080", "TIMEOUT": "5s", "DEBUG": "true"]) catch (err) {
println(err)
return
}
println(cfg)
load(["PORT": "80", "TIMEOUT": "soon", "DEBUG": "no"]) catch (err) { println("error:", err) }
}
import (
"fmt"
"strconv"
"time"
)
type Config struct {
Port int `json:"port"`
Timeout time.Duration `json:"timeout"`
Debug bool `json:"debug"`
}
func load(env map[string]string) (Config, error) {
atoi, err := strconv.Atoi(env["PORT"])
if err != nil {
return Config{}, err
}
duration, err := time.ParseDuration(env["TIMEOUT"])
if err != nil {
return Config{}, err
}
v, err := strconv.ParseBool(env["DEBUG"])
if err != nil {
return Config{}, err
}
return Config{
Port: atoi,
Timeout: duration,
Debug: v,
}, nil
}
func main() {
cfg, err := load(map[string]string{"PORT": "8080", "TIMEOUT": "5s", "DEBUG": "true"})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(cfg)
if _, err := load(map[string]string{"PORT": "80", "TIMEOUT": "soon", "DEBUG": "no"}); err != nil {
fmt.Println("error:", err)
}
}
{8080 5s true}
error: time: invalid duration "soon"

This is where Lyth saves the most. Go needs a temporary and an error check per field before it can build the value; Lyth keeps one expression, and evaluation still goes left to right, so the first failing field is the one reported.

import "errors"
struct User {
Name: string
Email: string?
}
var users: map[string]User = [
"ada": User(Name = "Ada", Email = "ada@example.com"),
"bob": User(Name = "Bob", Email = null),
]
func find(id: string): User? throws {
if id == "" {
throw errors.New("empty id")
}
user, ok := users[id]
if !ok {
return null
}
return user
}
func describe(id: string): string throws {
user := find(id)? ?: return "nobody"
email := user.Email ?: "no email"
return "${user.Name} <${email}>"
}
func main() {
for id in ["ada", "bob", "eve", ""] {
println(describe(id) catch (err) { "error: ${err}" })
}
}
import (
"errors"
"fmt"
)
type User struct {
Name string `json:"name"`
Email *string `json:"email"`
}
var users = map[string]User{"ada": {Name: "Ada", Email: new("ada@example.com")}, "bob": {Name: "Bob", Email: nil}}
func find(id string) (*User, error) {
if id == "" {
return nil, errors.New("empty id")
}
user, ok := users[id]
if !ok {
return nil, nil
}
return new(user), nil
}
func describe(id string) (string, error) {
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 main() {
for _, id := range []string{"ada", "bob", "eve", ""} {
v, err := describe(id)
if err != nil {
v = "error: " + err.Error()
}
fmt.Println(v)
}
}
Ada <ada@example.com>
Bob <no email>
nobody
error: empty id

User? is a *User in Go, so “not found” is a value and failure is an error, as idiomatic Go APIs do it. The difference is that Lyth checks it: user.Name does not compile until user is known to be non-null, while Go compiles a missing nil check and panics at run time.

struct Address {
City: string
}
struct Customer {
Address: Address?
}
struct Order {
Customer: Customer?
}
func cityOf(o: Order?): string = o?.Customer?.Address?.City ?: "unknown"
func main() {
full := Order(Customer = Customer(Address = Address(City = "Oslo")))
println(cityOf(full), cityOf(Order(Customer = Customer(Address = null))), cityOf(null))
}
import "fmt"
type Address struct {
City string `json:"city"`
}
type Customer struct {
Address *Address `json:"address"`
}
type Order struct {
Customer *Customer `json:"customer"`
}
func cityOf(o *Order) string {
var v string
var customer *Customer
if o != nil {
customer = o.Customer
}
var address *Address
if customer != nil {
address = customer.Address
}
if address != nil {
v = address.City
} else {
v = "unknown"
}
return v
}
func main() {
full := Order{Customer: &Customer{Address: &Address{City: "Oslo"}}}
fmt.Println(cityOf(new(full)), cityOf(&Order{Customer: &Customer{Address: nil}}), cityOf(nil))
}
Oslo unknown unknown

One line in Lyth; a ladder of nil checks in Go.

struct ValidationError {
Field: string
}
func *ValidationError.Error(): string = "invalid ${Field}"
func validate(name: string): *ValidationError? = if name == "" { &ValidationError(Field = "name") } else { null }
func check(name: string): error? = validate(name)
func main() {
println(check("ada") == null, check(""))
}
import "fmt"
type ValidationError struct {
Field string `json:"field"`
}
func (v *ValidationError) Error() string { return "invalid " + v.Field }
func validate(name string) *ValidationError {
if name == "" {
return &ValidationError{Field: "name"}
}
return nil
}
func check(name string) error {
v := validate(name)
if v == nil {
return nil
}
return v
}
func main() {
fmt.Println(check("ada") == nil, check(""))
}
true invalid name

Written the same way in Go, check("ada") returns a non-nil error: the interface holds a nil *ValidationError, so err != nil is true and the caller reports a failure that never happened. It is one of Go’s best-known mistakes. Lyth knows the pointer is null and converts it to a nil interface, which costs one nil check (if v == nil { return nil } in the Go tab). The same rule makes an int? converted to any? hold an int or nothing.

func portOr(s: string, fallback: int): int = s.toInt() catch { fallback }
func percent(s: string): int throws {
n := s.toInt() catch { throw Problem(Field = "percent", Reason = "not a number: ${s}") }
if n < 0 || n > 100 {
throw Problem(Field = "percent", Reason = "out of range: ${n}")
}
return n
}
struct Problem {
Field: string
Reason: string
}
func Problem.Error(): string = "${Field}: ${Reason}"
func main() {
println(portOr("8080", 80), portOr("http", 80))
println(percent("42") catch { -1 })
percent("142") catch (err) { println(err) }
percent("lots") catch (err) { println(err) }
}
import (
"fmt"
"strconv"
)
func portOr(s string, fallback int) int {
toInt, err := strconv.Atoi(s)
if err != nil {
toInt = fallback
}
return toInt
}
func percent(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, Problem{Field: "percent", Reason: "not a number: " + s}
}
if n < 0 || n > 100 {
return 0, Problem{Field: "percent", Reason: "out of range: " + strconv.Itoa(n)}
}
return n, nil
}
type Problem struct {
Field string `json:"field"`
Reason string `json:"reason"`
}
func (p Problem) Error() string { return p.Field + ": " + p.Reason }
func main() {
fmt.Println(portOr("8080", 80), portOr("http", 80))
v, err := percent("42")
if err != nil {
v = -1
}
fmt.Println(v)
if _, err := percent("142"); err != nil {
fmt.Println(err)
}
if _, err := percent("lots"); err != nil {
fmt.Println(err)
}
}
8080 80
42
percent: out of range: 142
percent: not a number: lots

catch { value } replaces a failed result with a fallback, and catch { throw ... } turns a low-level error into a domain error. Problem implements Go’s error interface by having an Error() method, like any Go type. s.toInt() is one of a handful of prelude methods; it lowers to strconv.Atoi.

import "errors"
import "os"
func loadProfile(user: string, attempt: int): string throws {
data := os.ReadFile("/profiles/${user}.json") catch (err) {
throw err.wrap("loading profile of ${user} (attempt ${attempt})")
}
return string(data)
}
func main() {
loadProfile("ada", 2) catch (err) {
println(err)
println("not found:", errors.Is(err, os.ErrNotExist))
}
}
import (
"errors"
"fmt"
"os"
)
func loadProfile(user string, attempt int) (string, error) {
data, err := os.ReadFile("/profiles/" + user + ".json")
if err != nil {
return "", fmt.Errorf("loading profile of %s (attempt %d): %w", user, attempt, err)
}
return string(data), nil
}
func main() {
if _, err := loadProfile("ada", 2); err != nil {
fmt.Println(err)
fmt.Println("not found:", errors.Is(err, os.ErrNotExist))
}
}
loading profile of ada (attempt 2): open /profiles/ada.json: no such file or directory
not found: true

err.wrap with a template becomes a single fmt.Errorf with format verbs and %w, the same call (and the same allocations) as hand-written Go.

import "errors"
import "fmt"
struct NotFound {
ID: int
}
func *NotFound.Error(): string = "user ${ID} not found"
func find(id: int): string throws {
if id < 0 {
throw errors.New("database unavailable")
}
if id != 1 {
throw fmt.Errorf("find: %w", &NotFound(ID = id))
}
return "Ada"
}
func status(id: int): int {
find(id) catch (_: *NotFound) {
return 404
} catch {
return 500
}
return 200
}
func nameOr(id: int, fallback: string): string throws {
name := find(id) catch (_: *NotFound) { fallback }?
return name
}
func main() {
println(status(1), status(2), status(-1))
println(nameOr(2, "guest") catch { "?" }, nameOr(-1, "guest") catch (err) { "error: ${err}" })
}
import (
"errors"
"fmt"
"strconv"
)
type NotFound struct {
ID int `json:"id"`
}
func (n *NotFound) Error() string { return "user " + strconv.Itoa(n.ID) + " not found" }
func find(id int) (string, error) {
if id < 0 {
return "", errors.New("database unavailable")
}
if id != 1 {
return "", fmt.Errorf("find: %w", &NotFound{ID: id})
}
return "Ada", nil
}
func status(id int) int {
if _, err := find(id); err != nil {
if _, ok := errors.AsType[*NotFound](err); ok {
return 404
}
return 500
}
return 200
}
func nameOr(id int, fallback string) (string, error) {
name, err := find(id)
if err != nil {
if _, ok := errors.AsType[*NotFound](err); ok {
name = fallback
} else {
return "", err
}
}
return name, nil
}
func main() {
fmt.Println(status(1), status(2), status(-1))
v, err := nameOr(2, "guest")
if err != nil {
v = "?"
}
v2, err := nameOr(-1, "guest")
if err != nil {
v2 = "error: " + err.Error()
}
fmt.Println(v, v2)
}
200 404 500
guest error: database unavailable

catch (e: T) handles errors of type T wherever they sit in a wrapped chain: it is errors.AsType, the Go 1.26 form of errors.As. Clauses are tried in order. Errors that no typed clause handles must still go somewhere, so the expression ends with ? to propagate them or with a plain catch to handle them. Go compiles the version that forgets the return err after an errors.As check; Lyth reports it as an unhandled error (E0303).

import "time"
func connect(host: string, port: int = 5432, tls: bool = true, timeout: time.Duration = 5 * time.Second): string =
"${host}:${port} tls=${tls} timeout=${timeout}"
func main() {
println(connect("db"))
println(connect("db", tls = false))
println(connect("replica", 6432, timeout = time.Second))
}
import (
"fmt"
"strconv"
"time"
)
func connect(host string, port int, tls bool, timeout time.Duration) string {
return host + ":" + strconv.Itoa(port) + " tls=" + strconv.FormatBool(tls) + " timeout=" + timeout.String()
}
func main() {
fmt.Println(connect("db", 5432, true, 5*time.Second))
fmt.Println(connect("db", 5432, false, 5*time.Second))
fmt.Println(connect("replica", 6432, true, time.Second))
}
db:5432 tls=true timeout=5s
db:5432 tls=false timeout=5s
replica:6432 tls=true timeout=1s

Go code reaches for a config struct or functional options as soon as a function has optional settings. In Lyth, parameters have defaults and calls name the arguments they pass. The Go function keeps a plain signature, and each call site passes every argument, so nothing is allocated or looked up at run time. Named arguments work for functions declared in Lyth. Go functions take positional arguments, because Go does not treat parameter names as part of an API.

import "io"
import "strings"
func writeText(w: io.Writer, s: string): int throws {
if w is io.StringWriter {
return w.WriteString(s) // w is an io.StringWriter here
}
return w.Write([]byte(s))
}
func kind(v: any): string = when v {
is int -> "int"
is string -> "string of ${len(v)} bytes"
is error -> "error: ${v.Error()}"
else -> "something else"
}
func main() {
sb := strings.Builder()
println(writeText(&sb, "hello") catch { 0 }, sb.String())
println(kind(42), kind("lyth"), kind(io.EOF), kind(1.5))
}
import (
"fmt"
"io"
"strconv"
"strings"
)
func writeText(w io.Writer, s string) (int, error) {
if stringWriter, ok := w.(io.StringWriter); ok {
return stringWriter.WriteString(s)
}
return w.Write([]byte(s))
}
func kind(v any) string {
switch v := v.(type) {
case int:
return "int"
case string:
return "string of " + strconv.Itoa(len(v)) + " bytes"
case error:
return "error: " + v.Error()
default:
return "something else"
}
}
func main() {
sb := strings.Builder{}
v, err := writeText(&sb, "hello")
if err != nil {
v = 0
}
fmt.Println(v, sb.String())
fmt.Println(kind(42), kind("lyth"), kind(io.EOF), kind(1.5))
}
5 hello
int string of 4 bytes error: EOF something else

is smart-casts, so the branch uses w as the tested type without a second name; the generated Go binds one (if sw, ok := w.(io.StringWriter); ok). A throws function that returns another fallible call passes its results on as they are, error included, as Go’s return w.Write(b) does. A when of is tests is a Go type switch. In Go, the two forms are about as long as Lyth’s; the gain is consistency with null checks rather than brevity.

enum Method { Get, Post, Delete }
sealed interface Result {
Status(): int
}
struct Ok : Result {
Body: string
}
func Ok.Status(): int = 200
struct NotFound : Result {
Path: string
}
func NotFound.Status(): int = 404
func allowed(m: Method): bool = when m {
Get -> true
Post, Delete -> false
}
func render(r: Result): string = when r {
is Ok -> r.Body
is NotFound -> "no page at ${r.Path}"
}
func main() {
println(Method.Get, allowed(Method.Get), allowed(Method.Delete))
println(render(Ok(Body = "hi")), render(NotFound(Path = "/x")))
}
import (
"fmt"
"strconv"
)
type Method int
const (
MethodGet Method = iota
MethodPost
MethodDelete
)
// String returns the name of the Method entry.
func (m Method) String() string {
switch m {
case MethodGet:
return "Get"
case MethodPost:
return "Post"
case MethodDelete:
return "Delete"
}
return "Method(" + strconv.Itoa(int(m)) + ")"
}
type Result interface {
Status() int
isResult()
}
type Ok struct {
Body string `json:"body"`
}
var _ Result = Ok{}
func (Ok) isResult() {}
func (o Ok) Status() int { return 200 }
type NotFound struct {
Path string `json:"path"`
}
var _ Result = NotFound{}
func (NotFound) isResult() {}
func (n NotFound) Status() int { return 404 }
func allowed(m Method) bool {
switch m {
case MethodGet:
return true
case MethodPost, MethodDelete:
return false
default:
panic("lyth: no when branch for this Method (example.lyth:19:33)")
}
}
func render(r Result) string {
switch r := r.(type) {
case Ok:
return r.Body
case NotFound:
return "no page at " + r.Path
default:
panic("lyth: no when branch for this Result (example.lyth:24:34)")
}
}
func main() {
fmt.Println(MethodGet, allowed(MethodGet), allowed(MethodDelete))
fmt.Println(render(Ok{Body: "hi"}), render(NotFound{Path: "/x"}))
}
Get true false
hi no page at /x

The Go tab shows the idiom Go programmers write by hand: type Method int with iota constants (MethodGet, …) and a String method, and an interface with an unexported method that only the listed structs have. What Go cannot do is check a switch against the set. In Lyth, a when over an enum or a sealed interface must cover every entry or member type (or have an else), so adding Method.Put or a new Result makes every when that forgets it a compile error. The generated switch ends in a default that panics with the Lyth position, for values from outside the set such as Method(7).

import "time"
func main() {
results := make(chan int, 3)
for i in 1..3 {
go { results.send(i * 10) }
}
sum := 0
for _ in 1..3 {
sum += results.receive()
}
println(sum)
never := make(chan int)
select {
v := never.receive() -> println(v)
time.After(10 * time.Millisecond).receive() -> println("timed out")
}
}
import (
"fmt"
"time"
)
func main() {
results := make(chan int, 3)
for i := 1; i <= 3; i++ {
go func() {
results <- i * 10
}()
}
sum := 0
for i2 := 1; i2 <= 3; i2++ {
sum += <-results
}
fmt.Println(sum)
never := make(chan int)
select {
case v := <-never:
fmt.Println(v)
case <-time.After(10 * time.Millisecond):
fmt.Println("timed out")
}
}
60
timed out

Channels are Go channels, with the operator spelled as methods: ch.send(v) is ch <- v, ch.receive() is <-ch, ch.close() is close(ch), and <-ctx.Done() becomes ctx.Done().receive(). select is Go’s select with when-style branches; else is default, and break exits the enclosing loop rather than the select.

import "lyth/coll"
struct Order {
Customer: string
Total: int
Paid: bool
}
func main() {
orders := [
Order(Customer = "ada", Total = 30, Paid = true),
Order(Customer = "linus", Total = 12, Paid = false),
Order(Customer = "ada", Total = 8, Paid = false),
]
unpaid := orders.Filter { !it.Paid }.Map { it.Customer }.Distinct()
byCustomer := orders.GroupBy { it.Customer }
revenue := orders.Fold(0) { sum, o -> sum + o.Total }
println(unpaid, byCustomer.SortedKeys(), len(byCustomer["ada"]), revenue)
}
import (
"fmt"
"lyth/coll"
)
type Order struct {
Customer string `json:"customer"`
Total int `json:"total"`
Paid bool `json:"paid"`
}
func main() {
orders := []Order{{Customer: "ada", Total: 30, Paid: true},
{Customer: "linus", Total: 12, Paid: false},
{Customer: "ada", Total: 8, Paid: false}}
unpaid := coll.Distinct(coll.Map(coll.Filter(orders, func(it Order) bool {
return !it.Paid
}), func(it Order) string {
return it.Customer
}))
byCustomer := coll.GroupBy(orders, func(it Order) string {
return it.Customer
})
revenue := coll.Fold(orders, 0, func(sum int, o Order) int {
return sum + o.Total
})
fmt.Println(unpaid, coll.SortedKeys(byCustomer), len(byCustomer["ada"]), revenue)
}
[linus ada] [ada linus] 2 50

Go has no generic methods, so the same pipeline in Go is either three loops or nested helper calls. lyth/coll is a Lyth package of generic extension functions: orders.Filter { ... } is the Go call coll.Filter(orders, ...), and the Go tab shows the chain. Each step allocates a new slice, which the code makes visible; where that matters, a loop is still available and reads the same as Go. The Collection API documents each operation’s result, empty-input behavior, and measured cost.

import "strconv"
func total(prices: []int, discount: int): int {
discount = 20 // error[E0501]: cannot assign to parameter discount: parameters are read-only
sum := 0
for p in prices {
p = p * 2 // error[E0501]: cannot assign to loop variable p
sum += p
}
return sum - discount
}
func parse(a: string, b: string, verbose: bool): int {
n, err := strconv.Atoi(a)
if verbose {
m, err := strconv.Atoi(b) // warning[W0006]: err hides the variable declared in an enclosing scope
println(m, err)
}
if err != null {
return 0
}
return n
}

Locals are mutable, as in Go: x := e declares one and = assigns it. Parameters, loop variables, catch bindings, and self are read-only, and a value receiver’s fields cannot be assigned, because the assignment would change only the method’s copy. Go has no such check, and it costs nothing at run time: the generated code uses ordinary Go variables. A local the function never assigns again keeps the type that a null check or is test gave it (a smart cast), like a parameter.

A declaration in an inner block may hide an outer variable, as in Go, but draws warning W0006. parse above has Go’s shadowed-err bug: the inner := declares a new err, so the check after the block never sees the second failure. Go compiles it silently; Lyth points at both declarations.

import "encoding/json"
import "net/http"
import "net/http/httptest"
struct Greeting {
Message: string
}
func hello(w: http.ResponseWriter, r: *http.Request) {
name := r.URL.Query().Get("name")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(Greeting(Message = "hello ${name}")) catch {}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", hello)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/hello?name=gopher", null))
print(rec.Code, " ", rec.Body.String())
}
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
)
type Greeting struct {
Message string `json:"message"`
}
func hello(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(Greeting{Message: "hello " + name}); err != nil {
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", hello)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/hello?name=gopher", nil))
fmt.Print(rec.Code, " ", rec.Body.String())
}
200 {"message":"hello gopher"}

Code that mostly calls Go APIs reads almost the same in both languages: Lyth uses Go’s names verbatim (http.NewServeMux, r.URL, *http.Request), and there is no Lyth web framework in between. The differences are small: json tags come from field names, and an error you choose to ignore is written catch {} instead of being silently dropped.

Each difference from Go’s spelling names something Go lacks or changes a meaning Go would get wrong (ADR 0031):

Lyth Go Why
name: Type, func f(): R name Type, func f() R one rule for every name and type, which reads well with User? and throws
[T: cmp.Ordered] [T cmp.Ordered] the same rule; Go’s form is an error with the fix
throws, ?, catch, throw (T, error), if err != nil the main improvement; lowers to Go’s convention
T?, ?., ?:, !!, smart casts *T, nil checks nil safety at compile time
null nil Lyth’s null is never a typed nil
func T.M(), func *T.M(), self func (t T) M() the receiver always has the same name and cannot be shadowed; there are no static methods, so constructors stay package functions (NewServer)
for x in xs, for i, x in xs, for i in 0..<n for _, x := range xs, for i := 0; i < n; i++ element first, and ranges a..<b, a..b; Go’s range would be a false friend, and for i, x := range xs is an error whose hint gives the Lyth form
while c, while true for c, for for only iterates
when x { ... } switch x { ... } an expression, exhaustive over enums and sealed interfaces; break exits the loop
if/when as values; = expr bodies statements fewer temporaries
ch.send(v), ch.receive(), ch.close() ch <- v, <-ch, close(ch) <- appears only in the types chan<- T and <-chan T
read-only parameters and loop variables; a value receiver’s fields cannot be assigned assignable an assignment to a parameter or a copy is almost always a mistake
shadowing warns (W0006) allowed silently catches the shadowed err
named and default arguments positional only optional settings without an options struct

A few of these make Lyth slightly longer than Go: name: Type costs a character per parameter, send(...) catch {} is longer than an ignored send(...) because discarded errors are explicit, and ch.receive() is longer than <-ch.

  • The spelling of what Go already has: func, :=, const, go, defer, select, the predeclared types, []T, map[K]V, chan T, generics in square brackets, conversions such as []byte(s), and headers without parentheses.
  • Goroutines, sync, context, and the memory model.
  • Names and visibility: 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.
  • Interfaces are structural; method sets and pointer receivers follow Go.
  • Slices, maps, zero values, defer, panic, and recover.
  • Packages, modules, go.mod, build tags, cross-compilation, and the race detector: lyth drives the go command.
  • Performance: across 18 benchmarks, Lyth and hand-written Go show no measurable difference in time, allocations, or memory (benchmarks).

Across the benchmark suite’s seven workloads, written by hand in both languages, the Lyth versions are 19% shorter in total (277 versus 344 lines). The spread is wide: 37% shorter for error-heavy code, 11% for allocation-heavy loops, and 3% for an HTTP handler. Lyth pays off most where Go code is mostly error and nil plumbing.