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).
Error propagation
Section titled “Error propagation”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 1error: missing = in cakeEvery ? 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.
Building a value from fallible parts
Section titled “Building a value from fallible parts”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.
Errors and absence together
Section titled “Errors and absence together”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>nobodyerror: empty idUser? 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.
Null-safe chains
Section titled “Null-safe chains”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 unknownOne line in Lyth; a ladder of nil checks in Go.
No typed-nil errors
Section titled “No typed-nil errors”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 nameWritten 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.
Fallbacks and parsing
Section titled “Fallbacks and parsing”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 8042percent: out of range: 142percent: not a number: lotscatch { 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.
Wrapping errors with context
Section titled “Wrapping errors with context”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 directorynot found: trueerr.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.
Handling one kind of error
Section titled “Handling one kind of error”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 500guest error: database unavailablecatch (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).
Optional parameters
Section titled “Optional parameters”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=5sdb:5432 tls=false timeout=5sreplica:6432 tls=true timeout=1sGo 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.
Optional interfaces and type switches
Section titled “Optional interfaces and type switches”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 helloint string of 4 bytes error: EOF something elseis 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.
Enums and exhaustive switches
Section titled “Enums and exhaustive switches”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 falsehi no page at /xThe 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).
Channels and select
Section titled “Channels and select”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") }}60timed outChannels 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.
Collection operations
Section titled “Collection operations”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 50Go 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.
Read-only parameters and shadowing
Section titled “Read-only parameters and shadowing”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.
Where Lyth looks like Go
Section titled “Where Lyth looks like Go”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.
What Lyth spells differently
Section titled “What Lyth spells differently”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.
What Lyth does not change
Section titled “What Lyth does not change”- 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, andrecover. - Packages, modules,
go.mod, build tags, cross-compilation, and the race detector:lythdrives thegocommand. - Performance: across 18 benchmarks, Lyth and hand-written Go show no measurable difference in time, allocations, or memory (benchmarks).
In numbers
Section titled “In numbers”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.