| Related pages: Why GALA? | Code Examples | Immutable Collections | Mutable Collections | Streams | Strings | Time Utilities | Dependency Management |
GALA (Go Alternative LAnguage) is a modern programming language that transpiles to Go. It combines Go’s efficiency and simplicity with features inspired by Scala and other functional languages, such as immutability by default, pattern matching, and concise expression syntax.
Note: This is the complete language specification. For the full, continuously updated source, see the GALA.MD file on GitHub. The content below is a faithful copy of that specification.
GALA files use the .gala extension. Every file must start with a package declaration, followed by an empty line. All GALA files in the same directory must belong to the same package.
GALA supports Go-style imports, including aliases and dot imports. Import declarations must also be followed by an empty line.
package main
import (
"fmt"
m "math"
. "net/http"
)
A GALA package can span multiple .gala files. Types, sealed types, and functions defined in one file are available in all other files of the same package. Each file must declare the same package name.
shapes/
types.gala # struct Point, sealed type Shape
ops.gala # func (p Point) String(), func Describe(s Shape)
In Bazel, list all source files in srcs:
gala_library(
name = "shapes",
srcs = ["shapes/types.gala", "shapes/ops.gala"],
importpath = "myapp/shapes",
)
gala_binary(
name = "app",
srcs = ["main.gala", "helpers.gala"],
)
GALA distinguishes between immutable and mutable variables.
val)Variables declared with val are immutable. They must be initialized at declaration time.
val x = 10
val a, b = 1, 2
// x = 20 // Compile error: cannot assign to immutable variable
var)Variables declared with var are mutable and can be reassigned.
var y = 20
y = 30 // OK
A declaration whose only name is _ binds nothing, so val _ = expr and
var _ = expr, with or without a type, and _ := expr are an error,
GALA-E0060. Write the
expression as a statement; if the value matters, bind it to a name and use it.
Inside a lambda with no result, a call that returns only an error cannot be a
bare statement, so wrap it: FromError(file.Close()).
func main() {
val _ = os.Remove("out.tmp") // error: GALA-E0060
}
func main() {
os.Remove("out.tmp") // the error is dropped
}
_ among several names (val n, _ = strconv.Atoi(s), val (_, b) = pair)
is not affected.
Inside functions, := declares immutable variables.
func main() {
z := 40
// z = 50 // Compile error: cannot assign to immutable variable
}
GALA supports both Go-style block functions and Scala-style expression functions.
func add(a int, b int) int {
return a + b
}
A block-bodied function with a result type returns its trailing expression, exactly as
a block lambda or a match arm does — the return is optional. The trailing value takes
its type from the result type, as a return value would, and a trailing if/else
carries the value in its branches:
func logged[U any](o Option[U]) Option[U] {
Println("visited")
o // same as `return o`
}
func sign(n int) string {
if (n < 0) { "negative" } else { "non-negative" }
}
Prefer the implicit trailing value. return stays valid, both at the end and for early
exits such as if (n < 0) { return None() }. The body must end in a value, a return, or
a diverging call such as Panic(...). A
body that can finish without a value — a trailing Println(...), an assignment, an if
with no else — is a compile error. A value computed only to be thrown away (a bare
name, literal or operator expression as a statement, including the trailing statement
of a function with no result type) is rejected as evaluated but not used.
func square(x int) int = x * x
func greet(name string) = Println(s"hello $name")
The result type is never inferred. Without one the function is void, like func f() { <expr> }: a call is made and its result discarded, and a match or if runs its branches as statements. A plain value (func answer() = 42) is rejected as evaluated but not used; write the result type to return it, func answer() int = 42. The same holds for each branch of that match or if, and for the branches of any match or if-expression used as a statement: a branch may make a call, assign or do nothing, but a plain value in one (func pick(c bool) = if (c) 1 else 2) is evaluated but not used.
Named functions and methods are declared only at the top level of a file. A
function local to a body is a lambda bound to a val; a lambda states its
result type after the parameter list, exactly where a function does:
import . "martianoff/gala/collection_immutable"
func report(scores Array[int]) string {
val clamp = (s int) int => if (s < 0) 0 else s
return scores.Map((s) => s"${clamp(s)}").MkString(", ")
}
A recursive local helper is declared through var and then assigned, so the
lambda can refer to it:
var fact func(int) int
fact = (n int) => if (n <= 1) 1 else n * fact(n - 1)
A named func inside a body is rejected with
GALA-E0052, whose hint spells out the equivalent
lambda. A lambda takes no type parameters, so a generic helper stays at the top
level.
A function, method or lambda parameter is an immutable binding, like a val:
reassigning it — with =, a compound assignment such as +=, or ++/-- —
is a compile error, cannot assign to immutable variable data, whose hint is
declare it `var data` to reassign it. Mark a parameter var to make it
reassignable. Like a val, it has no address: &data is
GALA-E0076 unless it is declared var.
Writing val is allowed but redundant: val label string means
exactly the same as label string. A method’s receiver is never reassignable
(see Receivers).
func process(data string, val label string, var count int) {
// data = "new" // Error: cannot assign to immutable variable data
// label = "other" // Error: the same — `val` is the default
count = count + 1 // OK: a `var` parameter
}
Whatever its keyword, a parameter is a plain Go parameter of its declared type
in the generated code — func process(data string, label string, count int)
above — so a call passes its arguments as they are and Go code can call the
function directly. Receivers and lambda parameters are plain Go parameters
too.
Function calls support named arguments. Named arguments can appear in any order — the compiler reorders them to match the function signature.
func divide(dividend int, divisor int) int = dividend / divisor
divide(divisor = 4, dividend = 20) // 5 — reordered to divide(20, 4)
divide(20, 4) // 5 — positional works too
Named arguments work with struct construction, Copy() method overrides, and regular function calls.
Parameters can have default values. When a function is called without providing a defaulted argument, the default expression is injected at the call site. Default parameters must come after required parameters.
func connect(host string, port int = 8080, tls bool = true) string =
s"$host:$port tls=$tls"
// Positional: omit trailing defaults
Println(connect("localhost")) // localhost:8080 tls=true
Println(connect("localhost", 3000)) // localhost:3000 tls=true
Println(connect("localhost", 3000, false)) // localhost:3000 tls=false
// Named arguments + defaults: skip any defaulted parameter
Println(connect("localhost", tls = false)) // localhost:8080 tls=false
Println(connect(host = "localhost", port = 443)) // localhost:443 tls=true
Default expressions are evaluated at each call site (not once at definition time):
func log(msg string, ts time.Time = time.Now()) {
Println(s"[$ts] $msg")
}
A default is evaluated at the call site but resolved where it is written: its
names mean what the imports of the file that declares it make them mean, never
what the caller’s imports do. A default in a file that dot-imports pa names
pa’s Point, even where the calling file dot-imports another package with a
Point of its own. A name that only the caller’s imports resolve is undefined
(GALA-E0023), reported at the default in its own
file. This holds for a call in another package and for one in another file of
the same package.
Expression functions also support defaults:
func greet(name string, greeting string = "Hello") string = s"$greeting, $name!"
A default is checked against the parameter’s declared type exactly as an explicit
argument would be. For a function-typed parameter, that means a lambda default
takes its parameter and result types from the declaration, and nil is a valid
default:
func shout(s string, decorate func(string) string = (x) => x + "!") string = decorate(s)
func run(hook func() int = nil) int = if (hook == nil) 0 else hook()
struct Backend(
Name string,
Origin func() Tuple[int, int] = () => (0, 0),
CursorReport func(int, int) = (r, c) => {},
)
The compiler validates defaults at compile time:
Functions are first-class values:
func apply(x int, f func(int) int) int = f(x)
func main() {
val double = (x int) => x * 2
val result = apply(5, double) // result = 10
}
When a parameter’s expected type is a zero-arg function type (func() T or
void func()), you can pass a plain expression where a lambda is expected — it
is automatically lifted into () => expr and evaluated lazily each time the
thunk is called. This is what lets Try and Future read like direct calls:
func runTwice(body func() int) int = body() + body()
func main() {
val a = runTwice(compute()) // sugar — lifted to () => compute()
val b = runTwice(() => compute()) // explicit form; identical
val f = Future(loadFromDB(id)) // same as Future(() => loadFromDB(id))
val r = Try(parseConfig(path)) // same as Try(() => parseConfig(path))
}
The type parameter is inferred from the lifted expression’s own type
(Future(compute()) where compute() int yields Future[int]). The lift is
purely additive: an argument that is already a function value is passed through
untouched, and multi-argument function parameters still require an explicit lambda.
func sum(numbers ...int) int {
var total = 0
var i = 0
for ; i < numbers.Size() ; {
total = total + numbers[i]
i = i + 1
}
return total
}
type Box[T any] struct { Value T }
func (b Box[T]) GetValue() T = b.Value
func (b Box[T]) Transform[U any](f func(T) U) Box[U] = Box[U](Value = f(b.Value))
A receiver can never be rebound. p = ..., a compound assignment and
for _, p = range ... are compile errors, cannot assign to receiver p
(p++ is cannot increment/decrement receiver p), whose hint is a receiver cannot be rebound; copy it into a local `var` (`var c = p`) if you need a mutable copy.
This holds for value and pointer receivers alike; there is no var receiver,
and val on a receiver is allowed but redundant. Like a parameter, a receiver
has no address: &p is GALA-E0076. A pointer receiver
is already a pointer, and the address of one of its fields (&p.count) is a
plain *T.
Mutation through the receiver is unaffected: a method may write the
receiver’s var fields and call its mutating methods. Through a pointer
receiver the change reaches the caller; through a value receiver it changes
the method’s own copy.
struct Counter(var N int)
func (c *Counter) Bump() {
c.N = c.N + 1 // OK: writes a `var` field through the receiver
}
func (c Counter) Clamped() Counter {
// c = Counter(0) // Error: cannot assign to receiver c
var out = c // a changing copy is a local `var`
if (out.N < 0) {
out = Counter(0)
}
out
}
struct Person(name string, age int, var score int)
A field takes no Go struct tag (GALA-E0085): JSON and YAML keys are set on Codec[T].
type Person struct {
Name string // Immutable
age int // Immutable
var Score int // Mutable
}
val p1 = Person{Name: "Alice", Age: 30} // Named fields (Go-style)
val p2 = Person("Bob", 25) // Positional (Functional-style)
val p3 = Person(age = 20, name = "Charlie") // Named arguments
A named argument that names no field is GALA-E0045, for a shorthand or block-form struct, and for a Go struct whose fields the Go type info lists. A struct declared in a hand-written .go file of the package itself (package main included) is built with named arguments as a Go composite literal, like an imported Go struct: Bag(Items = go_interop.SliceOf("a"), Score = (n) => n * 2).
Outside its own package, a struct is constructible by call syntax only when every field the call sets is exported. For the shorthand form that is every field, since an omitted one takes its default in the same literal, so lib.Box(1) for struct Box(N int, seen bool = false) is GALA-E0043. Call a constructor function the package exports, or start from the zero value lib.Box{}.
For Go callers, which build a struct as a composite literal and so skip its defaults, an exported shorthand struct whose fields are all exported gets a constructor New<T>(...): each field without a default is a parameter, and each field with one is an option, With<T><Field>(v), applied over its default. struct Cfg(Name string, Tries int = 3) gives Go NewCfg("a", WithCfgTries(5)). It is not generated under a name the package already declares. See the Go interop guide.
val p1 = Person("Alice", 30)
val p2 = p1.Copy(age = 31) // p2 is Person("Alice", 31)
val same = p1.Equal(p2) // false
A Copy override can be any expression, lambdas included. A lambda’s parameter types come from the field’s declared type, as with a named constructor argument: calc.Copy(Op = (x) => x + 1).
== and !=a == b means a.Equal(b), and a != b means !a.Equal(b), for every type with an Equal(other T) bool method: every struct, sealed type and tuple, Option, Either and Try, the immutable collections, and any type that declares its own Equal. Values compare by value, as Scala’s case classes do: ListOf(1, 2) == ListOf(1, 2) is true, and so is == on two structs whose List fields hold the same elements.
Where Go’s == gives the same answer — a type of primitives only, through its fields, cases and type arguments, with no Equal of its own anywhere in it (struct Point(x int, y int), Option[int], (int, string)) — the comparison stays Go’s == and costs nothing extra. A NaN equals nothing either way.
== keeps Go’s meaning where there is no Equal: pointers (*T, Ptr[T], and so the mutable collections) compare by identity, interfaces as Go compares them, a type parameter (T comparable) with Go’s == (its type is not known statically; call Equal(a, b) for value equality), Go types such as time.Time with Go’s ==, and nil checks as in Go. A struct holding a pointer compares with Equal, which compares what it points to.
Inside a type’s own Equal, == on that same type, or on a type that holds it by value (Option[T], a tuple, a List[T]), is Go’s ==, since lowering it would call the method being defined; on a type Go cannot compare it is GALA-E0084. == on values Go cannot compare and that have no Equal — a function, a Go slice or map, a Go type holding one, a T any — is GALA-E0084 too, and so is == on a struct, sealed type or tuple holding a function unless it declares its own Equal. With a literal on the left, 0 == a calls a.Equal(0). Go code that compares GALA values with its own == keeps Go’s meaning.
type X Y declares an alias: a second name for an existing type, not a new type. The alias and its target are the same type and are interchangeable everywhere — a value of one is a value of the other, with no conversion and no separate identity.
type MyString string
type Millis int64
type Handler func(Request) Response
type Coord Point // Point is a struct in this package
Aliases transpile to Go type aliases (type X = Y), which is what makes them transparent: methods on the target are available through the alias, and an alias declared in one file is visible to every file in the same package.
An alias can take type parameters. Conv[int, string] below is func(int) string, so a lambda in its place is typed by it:
type Conv[A any, B any] func(A) B
struct Step[A any, B any](In A, Run Conv[A, B])
func main() {
val step = Step(In = 3, Run = (x) => s"<$x>") // x is int, the result string
Println(step.Run(step.In))
}
A generic alias transpiles to a Go generic alias (type Conv[A any, B any] = func(A) B), which Go accepts from 1.24 on, so the go.mod files gala build generates declare go 1.24.
A generic alias names every instance of its target at once. It can take any number of parameters, with constraints (comparable, an interface), fix some of the target’s arguments, or name another alias:
type Result[T any] Either[AppError, T] // fixes the error type
type Pairs[K comparable, V any] Array[Tuple[K, V]]
type StrMap[V any] HashMap[string, V] // partially applied
type Again[T any] Result[T] // alias of an alias
Result[int] is Either[AppError, int]: constructors take their type arguments from it (func parse(s string) Result[int] = if (s == "") Left(AppError(1)) else Right(s.Size())), a match on it sees Either’s variants, and it has Either’s methods, codec encoding and shareability at a concurrency boundary. A generic alias takes no methods at all, whatever it names, and neither does an alias of one (type IntResult Result[int]) — Go has no methods through a generic alias (GALA-E0048). A wrong number of type arguments or an argument that breaks a constraint is reported by the Go compiler, mapped to the GALA line.
| Use | Example | Notes |
|---|---|---|
| Annotate a type | val s MyString = "hello" |
The alias stands wherever the target does |
| Convert | Millis(v), MyString(s) |
The ordinary type conversion — Millis(v) is int64(v) |
| Construct, when the target is a struct | Coord(1, 2); Twin(1, 2) for type Twin[T any] Pair[T] |
Reaches the target’s fields, so named and positional construction both work. A generic alias infers its type arguments from the fields and the expected type, as the struct does (Twin(1, 2) is a Twin[int]) |
| Carry a generic instantiation | type IntPair Pair[int] then IntPair(1, 2) |
The alias names the instantiation; do not re-apply type arguments. It takes no methods — see below |
| Take methods, when the target is a plain local type | func (c Coord) Sum() int = c.X + c.Y |
Legal because the receiver base type Point is declared in this package |
It cannot take a method unless its target is a plain type declared in this package. The receiver base type is the target, and Go accepts a method only on a plain, locally declared type — so a built-in, an imported type, an unnamed composite (slice, map, func), an instantiated type and any generic alias are all GALA-E0048:
type Millis int64
func (d Millis) Value() int64 = int64(d) // built-in target
type Dur time.Duration
func (d Dur) Ticks() int64 = 1 // imported target
type Handler func(Request) Response
func (h Handler) Name() string = "h" // unnamed composite
type IntPair Pair[int]
func (p IntPair) First() int = p.A // instantiated type
type Box[T any] Cell[T]
func (b Box[T]) Show() string = "box" // generic alias, whatever it names
The alias chain is followed to its end, so type A int64; type B A makes a method on B illegal for the same reason. A pointer target is fine when it points at a local type — type PP *Point puts the method on Point.
Declare an opaque type when you need methods of your own:
opaque type Millis int64
func (m Millis) Seconds() float64 = float64(m) / 1000.0
It does not create a distinct type. An alias cannot be matched, overloaded or type-switched apart from its target, and it does not make an illegal assignment illegal — MyString and string are one type. For a separate identity, declare an opaque type.
Note: Type aliases are generally not recommended. Prefer using the original type directly — it keeps code clearer and avoids indirection. Type aliases are mainly useful for Go interop scenarios where you need to bridge between GALA and existing Go type names, or when mixing
.galaand.gofiles in the same package (where GALA type aliases avoid dot-import conflicts with Go type alias declarations).
To give a value a name of its own, choose between an alias and a struct with one field. They differ in what the compiler checks and in what a codec writes:
type UserID int64 |
struct UserID(Value int64) |
|
|---|---|---|
| Distinct type | No — an int64 is accepted where a UserID is expected |
Yes — an int64 is not a UserID |
| Methods of its own | No — GALA-E0048 | Yes |
| Arithmetic and ordering | Yes — id + 1, a < b |
No — Go rejects + and < on a struct; use .Value |
HashMap key |
Yes, hashed as its target | Only with a Hash() uint32 method (Hashable); without one, Put panics with cannot hash type main.UserID: ... must be a primitive or implement std.Hashable interface |
JSON / YAML field, SnakeCase() |
The bare value: {"user_id":42}, user_id: 42 |
An object: {"user_id":{"value":42}}, user_id: over a nested value: 42 |
package main
import (
"martianoff/gala/json"
"martianoff/gala/yaml"
)
type UserID int64
struct AccountID(Value int64)
struct Login(UserID UserID, AccountID AccountID)
func main() {
val login = Login(42, AccountID(7))
Println(json.Codec[Login](json.SnakeCase()).Encode(login).Get())
// {"user_id":42,"account_id":{"value":7}}
Println(yaml.Codec[Login](yaml.SnakeCase()).Encode(login).Get())
// user_id: 42
// account_id:
// value: 7
}
Pick the alias to keep a plain value on the wire; pick the struct for a type a bare int64 cannot pass for, or one with methods. The two encode differently, so switching breaks documents already written: {"user_id":42} read as the struct form is Failure(json at pos 11: expected '{').
An opaque type combines the two: a type a bare int64 cannot pass for, with methods and operators, that is still written as the bare value.
opaque type X Y declares a new, distinct type whose values are represented exactly like values of Y, its underlying type. It transpiles to a Go defined type (type X Y), so it costs nothing at run time.
package main
opaque type UserID int64
opaque type Millis int64
func (m Millis) Seconds() float64 = float64(m) / 1000.0
func lookup(id UserID) string = s"user ${int64(id)}"
func main() {
val id = UserID(42) // construct: a conversion
val raw = int64(id) // unwrap: a conversion
val wait = Millis(1500) + 500 // the underlying type's operators, on Millis
Println(lookup(id), raw, wait > Millis(1000), wait.Seconds())
// user 42 42 true 2
}
Distinct. UserID, int64 and another opaque type OrderID int64 are three types. A value never converts implicitly between an opaque type and its underlying type, or between two opaque types — at an argument, a val / var declaration or assignment, a return, or a constructor field — and GALA reports GALA-E0064 with the conversion to write:
package main
opaque type UserID int64
func lookup(id UserID) string = s"user ${int64(id)}"
func main() {
val raw int64 = 42
Println(lookup(raw)) // error: write lookup(UserID(raw))
}
Untyped constants still mix, as in Go: id == 42, lookup(7), val zero Millis = 0 and wait + 500 are all fine.
Conversion is unrestricted. UserID(n) and int64(id) are ordinary conversions, legal in every package, so an opaque type keeps IDs and units apart but does not hide its representation: any caller can build a UserID from any int64. A value that must be impossible to build without a check is a struct with a private field and a constructor function (see the comparison below). Converting one opaque type straight into another, OrderID(userID), is GALA-E0063; when the change of kind is intended, go through the underlying type: OrderID(int64(userID)).
Operators come from the underlying type and work on the opaque type itself: +, -, <, == on two Millis give a Millis (or a bool). Whether Millis * Millis means anything is up to you.
Methods. An opaque type starts with no methods: those of its underlying type are not inherited — opaque type Timeout time.Duration has no .Seconds(). Declare methods in GALA, or in a hand-written .go file of the same package. A String() string method makes the type a fmt.Stringer, used by Println and string interpolation.
Hash and Compare are generated, so an opaque type works as a HashMap or HashSet key and in TreeSet, TreeMap and Sorted(): Hash() uint32 for every opaque type, and Compare(other T) int (which makes it an Ordered[T]) for every one except those over bool. Each is skipped when the type already declares it, in GALA or in a .go file of the package. == is Go’s, unless the type declares Equal(other T) bool, which == then calls. Hover, completion and gala doc list the generated methods, marked synthesized.
package main
import . "martianoff/gala/collection_immutable"
opaque type UserID int64
func main() {
val names = EmptyHashMap[UserID, string]().Put(UserID(2), "bo").Put(UserID(1), "al")
Println(names.Get(UserID(1)), ArrayOf(UserID(3), UserID(1), UserID(2)).Sorted())
// Some(al) Array(1, 2, 3)
}
Underlying types. An opaque type is declared over a scalar; anything else is GALA-E0062:
| Underlying type | Allowed |
|---|---|
bool, string, every integer and floating-point kind, rune, byte |
Yes |
| An alias that names one of those | Yes |
A Go named scalar (time.Duration, os.FileMode) |
Yes — its methods are not inherited |
| Another opaque type | No — declare it over the underlying type instead |
| A struct, a sealed type, a GALA collection | No — a distinct type over it loses all of its methods |
| A Go slice, map, pointer or channel; an interface; a function type | No |
A bare type parameter (opaque type Box[T any] T) |
No |
Encoding. A JSON or YAML codec writes an opaque type as its underlying value — as a struct field, inside an Option, Array or List, and as a HashMap key (over string) or value — including an opaque type declared in another package or instantiated with phantom type arguments, and as the root value (json.Value[UserID]().Encode(UserID(42)) is 42). struct User(Id UserID) is {"id":42}.
Pattern matching. case UserID(n) unwraps an opaque value and matches its
one sub-pattern against the underlying value: n binds an int64,
UserID(0) compares it with a literal, UserID(_) ignores it. Any other number
of sub-patterns is GALA-E0065. On an any, interface or type-parameter
subject the pattern first checks the value is a UserID — a plain int64 is
not — and a phantom-typed one must spell its type arguments,
case Id[User](n). Literal patterns, stable identifiers (val Admin Role = 1
then case Admin) and type patterns (case u: UserID) work as for any type. An opaque type is not sealed,
so a match on one ends in case _.
package main
opaque type UserID int64
func describe(id UserID) string = id match {
case UserID(0) => "nobody"
case UserID(n) if n < 0 => s"invalid $n"
case UserID(n) => s"user $n"
case _ => "unreachable"
}
func main() {
Println(describe(UserID(0)), describe(UserID(7))) // nobody user 7
}
Phantom type parameters. An opaque type may take type parameters that its
underlying type does not use, so one declaration gives each entity its own ID
type. Id[User] and Id[Order] are both an int64 at run time, but neither
passes for the other (GALA-E0064), and converting one
into the other directly is GALA-E0063:
package main
struct User(Name string)
struct Order(Total int)
opaque type Id[T any] int64
func (i Id[T]) Next() Id[T] = i + 1
func orderTotal(id Id[Order]) int = int(id) * 10
func main() {
val ada = Id[User](1)
Println(ada.Next(), orderTotal(Id[Order](7)), orderTotal(Id[Order](int64(ada))))
// 2 70 10
}
A bare type parameter cannot be the underlying type itself:
opaque type Box[T any] T is GALA-E0062.
An opaque type is declared at the top level of a file; opaque is a keyword. Its zero value is the underlying zero value (UserID(0)); use Option[UserID] for “no ID”.
type UserID int64 |
opaque type UserID int64 |
struct Email(v string) |
|
|---|---|---|---|
| Distinct from the underlying type | No — it is int64 |
Yes (GALA-E0064) | Yes |
| Methods of its own | No (GALA-E0048) | Yes | Yes |
| Operators and ordering | Yes | Yes, on the opaque type | No |
| Who can build one | Anyone — it is an int64 |
Anyone: UserID(n) in every package |
Only its own package; others call the constructor function it exports |
| Unwrapping | Nothing to unwrap | int64(id) |
Only through a method its package exports |
HashMap key, Sorted |
Yes | Yes (generated Hash / Compare) |
Only with Hash / Compare methods |
| JSON / YAML | The bare value | The bare value | An object, decodable only through Validate (private fields) |
| Run-time cost | None | None | A struct, with an Immutable box per field |
Use an alias to give an existing type a second name (mostly for Go interop), an opaque type for IDs, units and other values that must not be mixed up, and a struct with a private field for a value that must be valid by construction — an email address, a non-empty name.
sealed type Shape {
case Circle(Radius float64)
case Rectangle(Width float64, Height float64)
case Point()
}
val c = Circle(3.14)
val desc = c match {
case Circle(radius) => f"radius=$radius%.2f"
case Rectangle(w, h) => f"$w%fx$h%f"
case Point() => "point"
}
A variant is a constructor and a pattern, not a type: Circle(3.14) is a Shape. Naming a variant where a type is expected — func radius(c Circle), Array[Some[int]], case c: Circle — is GALA-E0061; take the sealed type and match on the variant.
A variant has at most 10 fields, the widest std tuple its extractor returns them in (GALA-E0072); group related fields into a struct.
Generic sealed types:
sealed type Result[T any] {
case Ok(Value T)
case Err(Error error)
}
Where GALA code needs a value of a type and none is written, it uses the
type’s default value: an uninitialized var x T, and a field a
construction leaves out, in a brace literal or a call.
default case, built with its fields’
declared defaults (Option’s is None); a sealed type without a default
case has none.sealed type Light {
case Off()
default case On(Power int = 30, Room string = "Living")
}
struct Home(Name string, L Light, Rooms int = 1)
func main() {
var l Light
val h = Home{Name: "flat"}
Println(l, h.L, h.Rooms) // On(30, Living) On(30, Living) 1
}
A type with no default value (Try, Either, a sealed type without a default
case, or a struct holding one) must be given a value: GALA never creates one
implicitly (GALA-E0075). A field private to another package’s struct is that
package’s to set, with a constructor it exports; a construction elsewhere
leaves it at Go’s zero value. Only a value Go creates by
itself (a fresh slice slot, a missed map lookup, decoding) holds a Go zero
value no default case built.
type Shaper interface {
Area() float64
}
struct Rect(width float64, height float64)
func (r Rect) Area() float64 = r.width * r.height
A method spec of an interface cannot declare type parameters of its own, as in Map[T any](f func(string) T) T: a Go interface method cannot have them, and a call through an interface is dispatched at run time, so there is no set of instantiations to generate (GALA-E0086). Put the type parameter on the interface (type Named[T any] interface { ... }), or write the operation as a generic function that takes the interface value.
An interface literal builds a value of an interface from functions, one per method, without declaring a struct. Call the interface by name and give each method as a named argument:
package main
struct User(Id int, Name string)
type UserRepo interface {
Find(id int) Option[User]
Count() int
}
func main() {
val repo = UserRepo(
Find = (id) => When(id == 1, User(1, "Ada")),
Count = () => 1,
)
Println(repo.Find(1), repo.Count())
}
(id) => ... needs no annotation. A method value such as mem.Find works too.Box[string](Get = () => "s"), or from the expected type.(int, error)) takes a lambda returning the GALA value of those results, a Try[int]. A variadic method’s last parameter reaches the function as a slice.fmt.Stringer(String = () => "hi"), io.Writer(Write = ...), sort.Interface(...).context.Context’s channel).A literal is an ordinary interface value: like any interface value it cannot be captured by a Future body (GALA-E0037), and it compares equal only to itself. Interface literals are how tests fake dependencies: see the Dependency Injection guide.
val status = if (score > 50) "pass" else "fail"
A default case is required unless the arms cover every value: all variants of a sealed type, both true and false, or an unguarded arm that matches anything — case _, a plain binding (case n), or a tuple pattern made only of wildcards, bindings and nested such tuples (case (_, _, err)). A variant is covered only when its cases match every value of its fields: case Some(0) covers part of Some, and a guarded case covers nothing (GALA-E0002).
val result = x match {
case 1 => "one"
case 2 => "two"
case n => s"Value is $n"
case _ => "other"
}
// Boolean exhaustive match
val desc = flag match {
case true => "enabled"
case false => "disabled"
}
A match used as a statement (its value discarded) is side-effect dispatch: its arms need not share a type, and each arm is a statement. An arm may make a call, assign or do nothing, but an arm that is, or in braces ends in, a plain value (a literal, name, operator expression or lambda) is rejected as evaluated but not used.
A capitalized identifier in a case pattern that names a value in scope (a local or package val/var, a parameter, a binding of an enclosing arm, or a const/var from a hand-written .go file of the same package) compares with == instead of binding. A qualified name such as math.MaxInt8 always compares. The arm compares as == does: a value with an Equal method — a struct, a collection, an Option — matches when it is Equal to the subject, and a value that cannot be compared at all, such as a function, is GALA-E0084. Lowercase identifiers never compare with a value: they bind (unless they name a variant of the matched sealed type or a zero-field extractor), so compare against a lowercase value with a guard. A name cannot appear twice in one pattern (case (X, X) is rejected), and a zero-field variant or extractor of the same name takes precedence.
type Environment string
val Development Environment = "development"
func mode(env Environment, limit Environment) string = env match {
case Development => "dev" // env == Development
case x if x == limit => "limit" // lowercase value: use a guard
case _ => "other"
}
p1 | p2 | … matches when any alternative matches, at the top of a case or nested inside an extractor or tuple; a sealed match is exhaustive when its alternatives cover every variant. Alternatives bind no names: case Some(n) | None() is rejected (GALA-E0071), so use _ or one case per alternative. In a pattern | is always an alternative, also inside parentheses; match a bitwise OR with a guard (case n if n == (FlagA | FlagB)) or a named val. Parenthesize an alternative that uses +, - or ^: case (1 + 1) | 3.
package main
func mode(m string) string = m match {
case "debug" | "development" => "dev"
case "prod" | "production" => "prod"
case _ => "unknown"
}
func size(o Option[int]) string = o match {
case Some(1 | 2 | 3) => "small"
case Some(_) | None() => "other"
}
func main() {
Println(mode("debug"), size(Some(2)))
}
In a pattern on a sealed variant or a struct, Field = p matches p against the field of that name, as a named argument fills it at construction. Positional sub-patterns come first, and a field given neither way matches anything:
package main
sealed type Shape {
case Circle(Radius int)
case Rect(Width int, Height int)
}
func height(s Shape) int = s match {
case Circle(Radius = r) => 2 * r
case Rect(Height = h, Width = _) => h
}
func describe(s Shape) string = s match {
case Rect(1, Height = 1) => "unit square"
case Rect(Height = 0) => "flat"
case _ => "other"
}
func main() {
Println(height(Rect(1, 2)), describe(Rect(5, 0)))
}
A field left out counts as _ for exhaustiveness, so Circle(Radius = r) and Rect(Height = h) cover Shape. Naming a field that does not exist, matching a field twice, writing a positional sub-pattern after a named one, or naming fields of an extractor with its own Unapply or of a sequence is GALA-E0073.
val res = x match {
case s: string => s"Found string: $s"
case i: int => s"Found int: $i"
case _ => "Unknown type"
}
A match on a value of a type parameter or an interface type whose arms are type patterns without a guard, _ arms and bindings is one Go type switch: the value is boxed once, and that box is not a heap allocation, so a generic func hash[T any](v T) that dispatches on case i: int hashes an int without allocating. An arm of an interface type (case h: Hashable) still boxes the value it binds. The arms still match in order, and a value held in an Immutable, or the error of a recovered panic, is matched by what it holds, as with any type pattern.
A wildcard type argument, case w: Wrap[_], matches any instantiation of a generic struct, at the top level or nested in another pattern (case Failure(e: Tagged[_]), case Some(w: Wrap[_]), a tuple element, a struct field, a sequence element). The binding has the type of the value being matched: its fields can be read when that type is an instantiation, and an interface’s methods when it is an interface such as error. A value typed any is the exception: Wrap[_] asserts to Wrap[any] there, and matches only a Wrap[any].
A struct pattern also matches a value whose static type is an interface — any, error, an interface the program declares, a Go interface — or a type parameter. The arm matches when the value holds that struct, and binds its fields:
struct NotFound(Key string)
func (e NotFound) Error() string = s"not found: ${e.Key}"
func describe(err error) string = err match {
case NotFound(k) => s"missing $k"
case _ => err.Error()
}
A struct the interface can never hold — one missing a method of the interface, or declaring it with a pointer receiver — is a compile error rather than an arm that never matches. A generic struct names its type arguments, case Box[int](v), since Go can only check for one instantiation.
A sealed variant or an extractor works the same way: the arm first checks that the value holds the type its Unapply takes. A zero-field variant of a non-generic sealed type needs no parentheses here either — case Square is case Square(), at the top level, nested (case Wrap(Square)) or with a guard, also when it is qualified (case shapes.Square) or dot-imported. A lowercase variant name still binds on such a subject.
sealed type Shape {
case Circle(R int)
case Square()
}
func describe(v any) string = v match {
case Circle(r) => s"circle $r"
case Square => "square"
case _ => "something else"
}
The bare form stops there. case Circle without its fields is GALA-E0039, as on a Shape subject — write case Circle(_). So is case None, or any variant of a generic sealed type: an interface value can only be checked for one instantiation, so write it, case None[int]() or case Some[int](n), match on a typed subject such as Option[int], or use a lowercase name to bind the value.
type Even struct {}
func (e Even) Unapply(i int) Option[int] = if (i % 2 == 0) Some(i) else None[int]()
val parity = 42 match {
case Even(n) => s"$n is even"
case _ => "odd"
}
A type with a method MatchView() S, where S is a sealed type, can be matched with S’s variants. When a case names one, the match reads the subject through MatchView(), once:
package main
// A maybe-absent int stored without Option's tag: a negative value is absent.
struct MaybeInt(raw int)
func (m MaybeInt) MatchView() Option[int] = When(m.raw >= 0, m.raw)
func describe(m MaybeInt) string = m match {
case Some(n) => s"some $n"
case None => "none"
}
func main() {
Println(describe(MaybeInt(7)), describe(MaybeInt(-1)))
}
The match is a match over S: binding types, guards, nested patterns, alternatives and exhaustiveness (GALA-E0002) are those of an S value, and a bare case None is the variant, not a binding. The method is recognized by shape — no arguments, no type parameters, a sealed result — so it works for Option, Either, Try and your own sealed types, for generic types and pointer receivers. A match whose cases name none of the view’s variants is an ordinary match over the subject. Once a match reads the view, a case that only applies to the subject (its own extractor, a literal, a typed pattern of another type) or a binding of the whole subject is GALA-E0079. Partial functions { case Some(n) => n } read the view of their argument the same way. A sub-pattern decides for itself: case Some(Some(n)) over an Option[MaybeInt] or case (Some(a), None) over two MaybeInts reads the view at that position, while any other sub-pattern there (case (Some(a), b)) matches the value itself; exhaustiveness counts the view’s variants for such a position, or the value’s own constructors when a pattern of the value’s own type shares it. MatchView is an ordinary method call: a small one is inlined by Go and allocates nothing. Keep it cheap and free of side effects, since a sub-pattern calls it each time a case tests that position.
val res = x match {
case i: int if i > 100 => "Large integer"
case Person(name, age) if age < 18 => s"$name is a minor"
case _ => "Other"
}
val arr = ArrayOf(1, 2, 3, 4, 5)
val res = arr match {
case Array(head, tail...) => s"Head: $head, Tail size: ${tail.Size()}"
case _ => "Empty"
}
bind / also)Inside a function whose result type is a monad M, bind name = expr unwraps M and binds the success value; the block lowers to a FlatMap chain, so every bound name stays in scope for later statements. also marks an independent clause: a bind+also group lowers through Zip{N} when the monad provides it — Validated (in the validation package) accumulates all errors, Future runs the clauses concurrently — and falls back to the sequential FlatMap chain for fail-fast monads (Try/Option/Either).
// bind: each value stays in scope; the first Failure short-circuits.
func processOrder(id int) Try[Receipt] {
bind o = fetchOrder(id)
bind valid = validateOrder(o)
bind payment = chargePayment(valid)
Success(Receipt(o.Id, payment))
}
// also over Validated: report ALL invalid fields, not just the first.
func makePerson(name string, email string, age int) Validated[string, Person] {
bind n = vName(name)
also e = vEmail(email)
also a = vAge(age)
Valid(Person(n, e, a))
}
A Go call that returns (T, error) is already a Try[T] (see Go functions that return several results), so bind n = strconv.Atoi(text) works directly inside a Try block.
Bound names are immutable vals. bind/also work over any user-defined monad that defines FlatMap[U](f func(T) M[U]) M[U]; a type without FlatMap is rejected with a clear compiler error. See bind / also notation for the full specification and the user-monad extension guide.
for i := 0; i < 10; i++ {
Println(i)
}
for count < 5 {
count++
}
for _, v := range items {
Println(v)
}
break leaves the innermost enclosing for loop and continue advances it, as in Go. Both also work in an arm of a match used as a statement inside the loop:
for i := 0; i < 6; i++ {
i match {
case 1 => continue // skips the rest of this iteration
case 4 => { break } // leaves the loop
case _ => Println(i)
}
}
// Prints 0, 2, 3
A return in such an arm likewise returns from the enclosing function.
So do break, continue and return in an arm of a match, or a branch of an if-expression, that a local val or var is initialized with — one name, or a tuple destructuring val (a, b) = ... — or a variable is assigned (val x = i match { case 2 => break ... }): the other arms give x its value.
They must reach a loop written around them in the same function. Outside any loop, inside a lambda (a separate function, even when written in a loop), inside an arm of a match or a branch of an if-expression whose value is used otherwise (Println(i match { case 2 => break ... })), or used as a value, break and continue are an error: GALA-E0059. A return in such a value is GALA-E0069.
val f = (x int) => x * x
// Inferred parameter types
val doubled = opt.Map((x) => x * 2)
// Inferred from a val of function type
val apply = (h func(string) string) => h("x")
val shouted = apply((s) => s + "!")
// Void closures
opt.ForEach((x) => { Println(x) })
A lambda parameter is immutable unless it is declared var, the same rule as
for a function parameter (see Parameters): reassigning it is
cannot assign to immutable variable x, and &x is
GALA-E0076.
val clamp = (var n int) int => {
if (n < 0) {
n = 0 // OK: a `var` lambda parameter
}
n
}
val total = Some(5).Map((var n) => {
n += 10 // `var` works on an inferred parameter too
n
})
val pf = { case 1 => "one" case 2 => "two" }
val r1 = pf(1) // Some("one")
val r2 = pf(5) // None[string]
// Use with Collect
val evenDoubled = numbers.Collect({ case n if n % 2 == 0 => n * 2 })
func identity[T any](x T) T = x
type Box[T any] struct { Value T }
A call that writes only its leading type arguments infers the rest from the arguments — for a generic function, a generic struct constructor and a companion Apply alike: Pair[int64](1, "one") is a Pair[int64, string]. A generic struct construction takes the type parameters its arguments leave undetermined from its expected type — the result type it is returned as, an annotated val, a parameter, a field — when that names the same struct, directly or through an alias: with struct Tag[T any](Name string), func label() Tag[int] = Tag("x") is a Tag[int]. Any type parameter still undetermined is an error naming it.
val x = Some(10)
val y = None[int]()
val msg = x match {
case Some(v) => s"Got: $v"
case None() => "Empty"
}
val result = x.Map((i) => i * 2)
val value = x.GetOrElse(0)
val big = x.Exists((i) => i > 5) // true; None never satisfies Exists
val allBig = y.ForAll((i) => i > 5) // true; None always satisfies ForAll
val t = (1, "hello")
val (a, b) = t // a = 1, b = "hello"
var (lo, hi) = (3, 9) // var destructures into mutable variables
lo = lo * 2 // lo = 6
A destructuring declaration takes each name’s type from the tuple, so it has exactly one initializer and no type annotation; anything else is GALA-E0056.
GALA functions return one value; to return several, return a Tuple and destructure it at the call site (val (q, r) = divmod(17, 5)).
Many Go functions return more than one result — most often a value and an
error that says whether the call worked. GALA expressions are always one
value, so a call like that is presented as one GALA value, automatically,
wherever it is used as a value:
| The Go function returns | A call of it is a | Example | Its GALA value |
|---|---|---|---|
a value and an error (T, error) |
Try[T] |
os.ReadFile(path) |
Try[[]byte] |
| two or more values and an error | Try of a Tuple |
net.SplitHostPort(addr) |
Try[Tuple[string, string]] |
two values (A, B) |
Tuple[A, B] |
math.Modf(x) |
Tuple[float64, float64] |
| three to ten values | Tuple3 … Tuple10 |
strings.Cut(s, "=") |
Tuple3[string, string, bool] |
only an error |
the error itself |
os.Remove(path) |
error (nil when it worked) |
A Try is Success(value) when the call worked and Failure(err) when it
returned an error, so the usual Try tools apply directly:
import (
"os"
"strconv"
"strings"
)
val port = strconv.Atoi("8080") match { // Try[int]
case Success(n) => n
case Failure(_) => 80
}
val size = os.ReadFile("app.conf").Map((data) => data.Size()).GetOrElse(0)
val (key, value, found) = strings.Cut("port=8080", "=") // Tuple3 destructuring
This holds in every position that takes one value: a val, a match subject,
a function or method argument, a lambda or function result, an if or match
branch, a struct field, a receiver (strconv.Atoi(s).Map(...)). Passed to a
Go function such as fmt.Println, the GALA value is what is passed:
fmt.Println(strconv.Atoi("7")) prints Success(7).
A Go function that returns only an error gives you that error as a value;
wrap the call in Try(...) to treat a non-nil error as a failure:
Try(os.Remove(path)) is a Try[Void]. FromError(os.Remove(path)) means the
same.
Try(goCall()) is the call’s own Try — the conversion is not applied twice —
so Try(strconv.Atoi(s)), Try(() => strconv.Atoi(s)) and strconv.Atoi(s)
are all a Try[int]. Going through Try(...) also turns a panic inside the
call into a Failure. Any other by-name parameter takes the call’s value like
an ordinary argument: Future(os.ReadFile(path)) is a Future[Try[[]byte]]
that completes with the Try.
Taking the results one by one. A binding of several names (no parentheses) still receives Go’s results as they are, which is the way to hand them straight back to Go code:
import (
"os"
"strconv"
)
val data, err = os.ReadFile("app.conf") // data []byte, err error
var n, parseErr = strconv.Atoi("42") // var: raw values, reassignable
The sole argument of a Go function whose parameters take the results one for
one is passed the same way, as Go does: template.Must(tmpl.Parse(text)).
Using the Try where the plain value is expected is a compile-time error, GALA-E0049, which names the call and the ways to the value:
error[GALA-E0049]: `data` holds the result of `os.ReadFile(...)`, which can fail, so it is a `Try[[]byte]`; `[]byte` is expected here
= hint: take the value with `.Get()` (panics on failure), `.GetOrElse(default)`, or `match { case Success(v) => ... case Failure(e) => ... }`; or bind the results Go-style: `val v, err = os.ReadFile(...)`
A statement whose value is not used — sb.WriteString("x"), fmt.Fprintf(w, ...)
on its own line — calls the Go function as it is. So does a branch of an
if-expression, or an arm of a match, whose value is not used:
if (verbose) fmt.Println(msg) else log.Print(msg) on its own line is an if
statement.
The other direction: a function that Go calls with several results. A
function or method may declare Go’s result list, two or more types in
parentheses, in place of its result type. The body computes the results’ one
GALA value by the same table — a Try[T] for (T, error), a Tuple[A, B] for
(A, B), a Try[Tuple[A, B]] for (A, B, error) — and Go receives the results
(a Failure(err) is the zero value and err). This is how a GALA type
implements a Go interface whose methods return several results, such as
io.Writer or json.Marshaler:
import (
"encoding/json"
"errors"
"fmt"
. "martianoff/gala/go_interop"
)
struct LineCounter(var Lines int)
// io.Writer: Write(p []byte) (n int, err error)
func (c *LineCounter) Write(p []byte) (int, error) {
c.Lines = c.Lines + 1
Success(p.Size())
}
struct Celsius(Degrees int)
// json.Marshaler: MarshalJSON() ([]byte, error)
func (c Celsius) MarshalJSON() ([]byte, error) =
if (c.Degrees < -273) Failure(errors.New("below absolute zero"))
else Success(ToBytes(s"\"${c.Degrees}C\""))
func divmod(a int, b int) (int, int) = (a / b, a % b)
var counter = LineCounter(0)
fmt.Fprintf(&counter, "hello %s\n", "go") // calls Write
val data = json.Marshal(Celsius(21)) // calls MarshalJSON
In GALA, a call of such a function is one value, exactly like a call of a Go
function with those results: divmod(17, 5) is a Tuple[int, int],
counter.Write(b) a Try[int], and val n, err = counter.Write(b) takes the
results one by one. A GALA interface may declare such a method, and a function
type may have such results: func() (int, error). A call of any value of such
a type — a val, a struct field, the result of another call — is one value
too.
A lambda passed where a function with several results is expected — a Go
callback such as func() (T, error), or a parameter of that function type —
works the same way: its value is spread over the results, and a body that is a
Go call with those results returns them as they are. So does a placeholder
lambda (strconv.Atoi(_)). With no parameters, the lambda can be written as
its bare body: sync.OnceValues(strconv.Atoi(s)). A
generic Go function takes its type arguments from the lambda’s results:
import (
"strconv"
"sync"
)
val port = sync.OnceValues(() => strconv.Atoi("8080")) // func() (int, error)
val p, err = port() // 8080, nil
val next = port().Map((n) => n + 1) // Success(8081)
val e = Right[int, string]("success")
val msg = e match {
case Left(code) => s"Error code: $code"
case Right(s) => "Result: " + s
}
val result = Try(riskyDivide(10, 0)) // by-name sugar: runs () => riskyDivide(...) lazily
val parsed = Try(strconv.Atoi("42")) // Success(42) — the same Try[int] as strconv.Atoi("42") itself
val dir = Try(os.TempDir) // function reference sugar (bare zero-arg call)
val msg = result match {
case Success(n) => s"Got: $n"
case Failure(e) => s"Error: ${e.Error()}"
}
When Try turns a panic into a Failure, it also keeps the stack trace of the
panic, taken while the panicking frames are still on the stack. PanicStack(err)
reads it back: Some(stack) for the error of a Failure made from a panic (or
an error that wraps one), None for any other error, such as FromError(err).
The frames point at .gala source lines, so a program that uses Try as a
safety boundary can log where the panic happened:
Try(handle(request)).OnFailure((err) => {
Println(s"request failed: $err")
PanicStack(err).ForEach((stack) => Println(stack))
})
The stack travels with the error without changing what it says: err.Error()
and printing are the panic’s own message, patterns (case e: NotFound,
case NotFound(key)) and errors.Is / errors.As see the panic’s own error,
and two Failures of the same panic value are Equal. The Failure holds a
wrapper of that error, so test it with errors.Is(err, io.EOF) or a pattern
rather than ==, and with errors.As rather than a type switch in Go code.
The stack is captured only when a panic is recovered; a Try that succeeds
costs nothing extra.
A Go function that returns (T, error) already gives a Try[T] when its call
is used as a value (see Go functions that return several
results): a non-nil error is the
Failure.
import "strconv"
val result = strconv.Atoi("42") match {
case Success(n) => s"Parsed: $n"
case Failure(err) => s"Error: ${err.Error()}"
}
// result: "Parsed: 42"
A Go function returning two or more values and an error gives a Try of a
Tuple, which a tuple pattern takes apart:
import "net"
// net.SplitHostPort returns (string, string, error): a Try[Tuple[string, string]]
val result = net.SplitHostPort("localhost:8080") match {
case Success((host, port)) => s"host=$host port=$port"
case Failure(err) => s"Error: ${err.Error()}"
}
// result: "host=localhost port=8080"
Writing Try(...) around such a call gives the same Try — never a Try inside
a Try — and additionally turns a panic inside the call into a Failure. It is
also how a Go function returning only an error becomes a Try[Void]:
| Go Return Signature | The call | Try(call) |
|---|---|---|
(T, error) |
Try[T] |
Try[T] |
(A, B, error) |
Try[Tuple[A, B]] |
Try[Tuple[A, B]] |
(A, B, C, error) |
Try[Tuple3[A, B, C]] |
Try[Tuple3[A, B, C]] |
error |
error |
Try[Void] |
import . "martianoff/gala/concurrent"
val async = Future[int](expensiveComputation()) // by-name: runs () => expensiveComputation() async
val result = async.Await() // Returns Try[int]
val doubled = async.Map((v) => v * 2)
Prefer GALA collections over Go slices for most use cases. See Immutable Collections.
import . "martianoff/gala/collection_immutable"
import . "martianoff/gala/go_interop"
// PREFERRED: GALA collections
val nums = ArrayOf(1, 2, 3, 4, 5)
val doubled = nums.Map((x) => x * 2)
// GO INTEROP: When you need []T (SliceOf lives in go_interop)
val goSlice = SliceOf(1, 2, 3)
val name = "Alice"
val age = 30
Println(s"Hello $name!") // Hello Alice!
Println(s"$name is $age years old") // Alice is 30 years old
Println(s"Next year: ${age + 1}") // Next year: 31
Println(s"Price: $$99") // Price: $99
// Explicit format specs
Println(f"$count%04d items") // 0007 items
Println(f"Total: $$$price%.2f") // Total: $19.99
val asterisk = '*'
val space = ' '
val newline = '\n'
val n = int64(42)
val f = float64(10)
val r = rune(65) // 'A'
val s = string(r) // "A"
Bare Go builtins are not part of GALA’s surface: calling one is a transpile error (GALA-E0035). Each has a GALA-native form or a sanctioned interop wrapper:
| Forbidden builtin | Use instead |
|---|---|
len(x) |
x.Size() (logical size; characters for strings) or x.ByteSize() (string bytes) |
cap(x) |
go_interop.SliceCap(x) |
new(T) |
go_interop.New[T]() (pointer), or a zero value / Option[T] |
append(s, v) |
go_interop.SliceAppend(s, v) / SliceAppendAll(s, more), or an Array/List |
make([]T, n) |
go_interop.SliceWithSize / SliceWithCapacity; MapEmpty for maps |
delete(m, k) |
go_interop.MapDelete(m, k), or HashMap.Remove(k) |
close(ch) |
go_interop.CloseChan(ch) (or CloseSignal for a signal channel) |
panic(v) |
go_builtins.Panic(v) — but prefer Option / Try / Either |
recover() |
not available — Try captures panics |
GALA reserves some of Go’s keywords itself (func, type, struct, interface, map, if, else, for, range, return, case, var, import, package). The others — break, chan, const, continue, default, defer, fallthrough, go, goto, select, switch — are still reserved as names, because every name a GALA program declares is a name in the Go it compiles to. Using one as a name is a transpile error (GALA-E0055) in every position: a val, var or := binding, a parameter or lambda parameter, a pattern binding, a struct field, a function or method, a type or type parameter, the package name, and an import alias. The error points at the declaration.
val default = 8080 // GALA-E0055: "default" is a Go keyword
struct Job(select bool) // GALA-E0055
Pick another name, such as defaultPort or selected. GALA does not rename the identifier for you: a renamed struct field or exported function would be visible to Go code, JSON field names and reflection under a name you never wrote. A bare break or continue statement in a for loop is loop control and is not affected; a bare defer, go, goto, fallthrough, select or chan statement is GALA-E0036.
Go’s predeclared identifiers — int, string, error, len, min, max and so on — are not keywords. A local binding may shadow one, exactly as in Go.
A stored immutable value — a struct field or a package-level val — is a std.Immutable[T] in the generated Go. A local val is a plain Go variable: GALA rejects writing it, writing a field through it and & of it, and a pointer-receiver method called on it runs on a copy.
A package-level val is a std.Immutable[T] in the generated Go but reads as a plain T everywhere — its own file, sibling files, and other packages (qualified, aliased, or dot-imported). A package-level var stays a plain, reassignable variable.
// package colors
val Green = NamedColor(2)
var Hits = 0
// package main
Println(colors.ToSgr(colors.Green)) // Green reads as colors.Color
colors.Hits = colors.Hits + 1 // OK: a var
// colors.Green = NamedColor(3) // ERROR: cannot assign to immutable variable colors.Green
fmtGo’s reflection-based encoders and fmt see an immutable value as the value it holds, not the std.Immutable[T] wrapper: encoding/json and YAML libraries using the MarshalYAML/UnmarshalYAML convention (gopkg.in/yaml.v2, v3) encode and decode it as a T, and %v prints the value. The keys are the field names: GALA has no struct tags (GALA-E0085), so other keys come from Codec[T] in the json and yaml packages. A json.Decoder’s DisallowUnknownFields and UseNumber do not reach inside the field. Encoders and fmt only look inside exported fields: a lower-case field is skipped by encoders and printed by %v as its wrapper.
Immutable[T]A shorthand struct field declared Immutable[T] is the same field as one declared T: it takes a T (positionally, by name, as a Copy override or as its default), wrapped once, and reads as a T. So is one declared with an alias of Immutable[T] (type Count Immutable[int]), wherever the alias is declared. A var field, an explicit val field or a block-form field declared Immutable[T] holds the Immutable[T] it names.
struct Counter(Label string, Hits Immutable[int64] = 1)
val c = Counter(Hits = 3, Label = "named")
Println(c.Hits + 1) // 4: Hits reads as an int64
var data = 42
val ptr1 = &data // immutable pointer binding
*ptr1 = 100 // OK: can modify data through pointer
// ptr1 = &other // ERROR: cannot reassign immutable pointer
&x is a pointer (a Ptr[T]), and a pointer can write, so only something that can change has an address: a var, a var field of a var, a field of a pointee, a composite literal (&Config(...)). The address of a val, a parameter not declared var, a receiver, an immutable field or a field reached through a val is GALA-E0076. Pass the value itself, or declare the binding var where Go needs a *T.
val data = 42
val ptr = &data // error: GALA-E0076
Immutable linked data is a sealed type (case End() / case Cons(head int, tail IntList)), which needs no pointers. A type that holds itself by value any other way, such as struct Node(next Option[Node]), is GALA-E0077.
valA method with a pointer receiver (a GALA func (c *Counter) Bump(), or Go’s url.URL.String) can be called on a val, a field reached through one, a call result or a literal. Go only calls such a method on an addressable value, so it runs on a fresh copy and whatever it assigns to the receiver’s own fields is lost. A var, a function parameter and a pattern binding are addressable, so there the method changes the variable itself.
struct Counter(var n int)
func (c *Counter) Bump() int {
c.n = c.n + 1
return c.n
}
func main() {
val c = Counter(n = 1)
Println(c.Bump()) // 2 — bumped a copy
Println(c.n) // 1 — the val is unchanged
var m = Counter(n = 10)
m.Bump()
Println(m.n) // 11
}
The copy is shallow: writes through a map, slice or pointer the value holds reach data the val shares. A value that must not be copied is rejected instead with GALA-E0053: any sync or sync/atomic type, a type whose pointer has Lock()/Unlock(), a noCopy marker, a struct holding one of them, strings.Builder and bytes.Buffer. Hold such a value in a var or behind a pointer (&sync.Mutex{}).
Ptr[T] and OptionPtr[T]Ptr[T] is a pointer that is never nil; OptionPtr[T] is a pointer that may be absent; a plain *T is the nullable Go interop type. Ptr[T] is a compile-time marker that erases to *T in generated Go, so it has the fields and methods of what it points to, can be a receiver (func (c Ptr[Counter]) Bump()), and passes to Go as a *T. &x and &Struct(...) are Ptr[T] values.
struct Counter(var N int)
func (c Ptr[Counter]) Bump() {
c.N = c.N + 1
}
func main() {
var c = Counter(0)
val p = &c // Ptr[Counter]
p.Bump()
Println(c.N) // 1
}
A Ptr[T] fills a *T slot as it is. nil, or a *T that may be nil, never fills a Ptr[T], and a Ptr is not compared with nil (GALA-E0080). A pointer from Go becomes a Ptr once, at the boundary: RequirePtr(p) panics with a GALA message on nil, and OptionPtrOf(p) gives an OptionPtr[T] that is empty on nil. A Ptr has no default value (GALA-E0075), a codec rejects it (GALA-E0050), and a typed pattern cannot test for one. A var that later takes a Go pointer, or nil, is declared with the nullable type: var current *Node = &head.
A method declared on Ptr[T] is called only on a value that is never nil: an addressable T (reached as &x), a val, a Ptr[T] or RequirePtr(p). Calling it on a *T that may be nil, taking it as a method value from one, matching such a *T through a sealed view whose MatchView has a Ptr receiver, passing it where an interface with that method is expected, or making it the type argument (written, or inferred from an argument, the expected type or the function type a function reference fills) of a type parameter whose constraint has that method is GALA-E0080, for a method of another package as for one of the same package; the receiver of a *T method is such a pointer too. A method declared on *T is still callable on any *T.
Where Go can be the caller, a Ptr parameter is checked on entry: in an exported function or method (and an exported method checks its Ptr receiver), and in a lambda with a Ptr[T] parameter filling a function slot whose parameter is a *T (a Go callback). Calls between GALA functions are not checked. Not covered: Go writing a struct field, reflection, Go generic code’s var zero T, a Ptr converted to any, a *T held as any or as an interface without a Ptr-receiver method and then tested for one that has one, a function value taking a Ptr (a named function or a lambda bound to a val) passed as a func(*T), and p, err = goCall().
OptionPtr[T] is a std struct holding a *T, the size of one pointer, whose zero value is empty. OptionPtrOf(p), SomePtr(ptr) and EmptyOptionPtr[T]() build one; IsDefined, IsEmpty, Get, GetOrElse, OrElse, Map, FlatMap, ForEach and ToOption mirror Option, and every pointer they hand out is a Ptr[T]. Match one directly with case Some(p) / case None(): its MatchView() gives it the sealed view Option[Ptr[T]], so the match is checked for exhaustiveness.
struct Node(Value int, Next OptionPtr[Node])
func sum(n Ptr[Node]) int = n.Value + n.Next.Map((next) => sum(next)).GetOrElse(0)
func main() {
val tail = &Node{Value: 3}
val head = &Node(1, SomePtr(&Node(2, SomePtr(tail))))
Println(sum(head)) // 6
}
GALA supports Go-style imports with aliases and dot imports.
import m "math"
import . "martianoff/gala/examples/mathlib"
func main() {
val res = m.Sqrt(16.0)
val sum = Add(10, 20) // from mathlib via dot import
}
A few std names are reserved and cannot be redeclared, because the language gives them built-in meaning: Option, Either, Try, Immutable, Tuple…Tuple10, Traversable, Iterable, Sendable, Ptr, EmbeddedFS, the companions Some, None, Left, Right, Success, Failure, and helpers such as Copy and Equal. A type, struct or top-level function under one of them is an error, and a sealed variant named like a companion draws a warning.
A package-level declaration whose name a dot-imported package also exports is rejected with GALA-E0066, because Go forbids it; import that package under a name, or rename the declaration.
A bare name written as a value that std declares as a type and a dot-imported package declares as a function or val is ambiguous, and is rejected with GALA-E0087: Void() beside import . "example.com/demo/util", where util declares func Void(). Write util.Void() through a plain import, or std.Void through import "martianoff/gala/std". In a type position (Try[Void]) the name is std’s type.
Visibility is controlled at two levels. Identifiers use casing, as in Go: a PascalCase type, function, field or method is exported from its package; a camelCase one is not.
Packages use internal directories. A package under a directory named internal is importable only from the tree rooted at that directory’s parent — Go’s rule, applied to GALA import paths:
mylib/
mylib.gala package mylib — public
internal/
detail/detail.gala package detail — private to example.com/mylib
sub/
internal/
deep/deep.gala package deep — private to example.com/mylib/sub
a/b/c/internal/d is importable only from the tree rooted at a/b/c. Only whole path elements count — a directory named internalize is an ordinary public package. There is no exemption for the standard library.
This lets a library share code across its own packages without adding it to the API it must keep supporting: consumers call what the parent package exports and cannot reach internal/detail, so it can be renamed or deleted freely. A forbidden import is rejected with GALA-E0041.
Publishing an internal package means moving it out of the internal directory, which changes its import path — so it is worth deciding deliberately. For an application, the whole module is already private to you; reach for internal where there is a real API boundary to defend, not as a default layout.
See Dependency Management for managing external packages.
embed val readme = "README.md" // string
embed val static EmbeddedFS = "static/*" // EmbeddedFS
val content = static.ReadString("static/index.html")
GALA provides a test framework with 22 assertions, panic recovery, timing, table-driven test support, and benchmarking.
package main
import . "martianoff/gala/test"
func TestAddition(t T) T {
val x = 1 + 1
return Eq(t, x, 2)
}
func TestDouble(t T) T {
return RunCases[int, int](t,
(sub T, input int, expected int) => Eq(sub, input * 2, expected),
Case[int, int](Name = "zero", Input = 0, Expected = 0),
Case[int, int](Name = "positive", Input = 5, Expected = 10),
)
}
func main() {
RunBenchmarks(
BenchFunc(Name = "BenchmarkAdd", Func = (b B) => {
for i := 0; i < b.N; i++ {
val x = 1 + 1
}
}),
)
}
val over var - Use mutable variables only when necessaryvar only when its body reassigns it; a receiver is never rebound, so copy it into a local var instead (see Parameters)Copy() for updates - person.Copy(age = 31)match over if-else chains for type/value dispatchcase Some(x) => not if opt.IsDefined() { x := opt.Get() }Some(42) not Some[int](42)s"..." over fmt.Sprintf - s"Hello $name" not fmt.Sprintf("Hello %s", name)Array or List from collection_immutableOption[T] for nullable values, Try[T] for operations that may failtype UserID int64 is just int64; opaque type UserID int64 is distinct, takes methods, and still encodes as the bare value (see Alias, opaque type or private-field struct)// run directly above them - hover and gala doc show it, and it is carried into the generated Go, so go doc, gopls, pkg.go.dev and annotation tools such as swag read it too. Only declaration docs are carried: comments inside function bodies, trailing comments and //go:, //line and // +build directives are notGALA provides a module system similar to Go modules. See Dependency Management for the full guide.
gala mod init github.com/user/project
gala mod add github.com/example/utils@v1.2.3
gala mod add github.com/google/uuid@v1.6.0 --go
gala mod tidy
string, Str wrapper, and StringBuilderA basic IntelliJ IDEA plugin is available in ide/intellij.
bazel build //ide/intellij:pluginThe plugin provides syntax highlighting, file type recognition (.gala), and basic code structure support.
Full specification: This page contains a summary of the GALA language specification with all major features documented. For the complete, unabridged specification with every detail and edge case, see the GALA.MD source file on GitHub.