Go’s if err != nil pattern is explicit, but it is also verbose. A function that chains three fallible operations needs three separate error checks, each with its own early return. The happy path drowns in boilerplate.
Libraries like IBM/fp-go bring Option and Either to Go, but they’re constrained by Go’s syntax — resulting in deeply nested generic calls and verbose type annotations. GALA solves this at the language level: Option[T], Either[A, B], and Try[T] are first-class sealed types with clean syntax, Map/FlatMap chaining, pattern matching, and full type inference. Errors propagate automatically through the chain; you only handle them when you are ready.
Option[T] represents a value that may or may not exist. It is a sealed type with two variants:
sealed type Option[T any] {
case Some(Value T)
default case None()
}
Use Option anywhere you would reach for nil in Go. It forces the caller to acknowledge the absence case. None() is its default case, so an Option nobody set is None. For pointers the same idea is built in: &x is a Ptr[T] that is never nil, and a pointer that may be absent is an OptionPtr[T] (see Immutability).
val present = Some(42) // Option[int] containing 42
val absent = None[int]() // Option[int] with no value
Transform the contained value without unwrapping. If the Option is None, the operation is skipped:
val opt = Some(10)
val doubled = opt.Map((x) => x * 2) // Some(20)
val none = None[int]()
val stillNone = none.Map((x) => x * 2) // None[int]
// FlatMap chains operations that return Option
val result = opt.FlatMap((x) => if (x > 5) Some(x) else None())
Extract the value with a fallback:
val value = opt.GetOrElse(0) // 42
val fallback = None[int]().GetOrElse(0) // 0
// OrElse provides a fallback Option
val primary = None[string]()
val backup = Some("default")
val result = primary.OrElse(backup) // Some("default")
Keep the value only if a predicate holds:
val positive = Some(42).Filter((x) => x > 0) // Some(42)
val negative = Some(-1).Filter((x) => x > 0) // None[int]
OnSome and OnNone run side effects and return the original Option for chaining:
val opt = Some(42)
opt.OnSome((v) => { Println(s"Found: $v") })
.OnNone(() => { Println("Not found") })
.Map((i) => i * 2)
Because Option is a sealed type, matching is exhaustive — no case _ needed:
val msg = opt match {
case Some(v) => s"Got: $v"
case None() => "Empty"
}
| Method | Description |
|---|---|
IsDefined() / IsEmpty() |
Check the state |
Get() |
Get value or panic |
GetOrElse(default) |
Get value or return default |
OrElse(alternative) |
Return this if Some, otherwise alternative |
OnSome(f) / OnNone(f) |
Execute side-effect, return original |
Map(f) |
Transform value if Some |
FlatMap(f) |
Chain operations returning Option |
Filter(predicate) |
Keep Some if predicate holds |
Exists(predicate) |
True if Some and the predicate holds (None: false) |
ForAll(predicate) |
True if None or the predicate holds (None: true) |
ForEach(f) |
Apply procedure if nonempty |
Either[A, B] represents a value that is one of two types. By convention, Left carries the error and Right carries the success value:
sealed type Either[A any, B any] {
case Left(LeftValue A)
case Right(RightValue B)
}
val success = Right[string, int](42)
val failure = Left[string, int]("not found")
Operations are biased toward Right. If the value is Left, the operation is skipped and the error propagates:
val result = Right[string, int](5)
.Map((x) => x * 10) // Right(50)
.FlatMap((x) => Right[string, int](x + 1)) // Right(51)
val error = Left[string, int]("oops")
.Map((x) => x * 10) // Left("oops") — skipped
Chain multiple operations. The first Left short-circuits the rest:
val chained = Right[string, int](3)
.Map((x) => x * 10)
.FlatMap((x) => Right[string, int](x + 1))
// Right(31)
OnRight and OnLeft run side effects and return the original Either:
val logged = Right[string, int](42)
.OnRight((v) => { Println(s"Success: $v") })
.OnLeft((e) => { Println(s"Error: $e") })
.Map((x) => x * 2)
Exhaustive matching — no default case needed:
val msg = result match {
case Left(code) => s"Error code: $code"
case Right(s) => "Result: " + s
}
Try[T] wraps a computation that may succeed or fail with an error. It catches panics and turns them into Failure values:
sealed type Try[T any] {
case Success(Value T)
case Failure(Err error)
}
// Direct construction
val success = Success(42)
val failure = Failure[int](fmt.Errorf("oops"))
// Wrapping a failable computation — catches panics
val result = Try(riskyDivide(10, 0)) // Failure
val safe = Try(riskyDivide(10, 2)) // Success(5)
// Function reference sugar — no lambda needed for zero-arg functions
val dir = Try(os.TempDir)
A Go function that returns a value and an error is already a Try in GALA —
no wrapping needed. A Go call becomes one GALA value like this:
| The Go function returns | In GALA the call is a | Example |
|---|---|---|
a value and an error (T, error) |
Try[T] |
strconv.Atoi(s) is a Try[int] |
| several values and an error | Try of a Tuple |
net.SplitHostPort(addr) is a Try[Tuple[string, string]] |
only an error |
the error itself |
os.Remove(path) is an error |
import "strconv"
val port = strconv.Atoi("8080") match {
case Success(n) => n
case Failure(_) => 80
}
val timeout = strconv.Atoi("30s").GetOrElse(30) // not a number: 30
error, write Try(os.Remove(path)): it is
a Try[Void] that fails when the error is non-nil.Try(...) around a (T, error) call gives the same Try[T], and also turns a
panic inside the call into a Failure.Try where the plain value is expected is a compile-time error
(GALA-E0049): take the value with a match,
.GetOrElse(default), or .Get() (panics on failure).val n, err = strconv.Atoi(text) gives
the raw int and error.Transform success values. Failures propagate untouched:
val doubled = Success(21).Map((n) => n * 2) // Success(42)
val chained = Success(10).FlatMap((n) => divide(n, 2)) // Success(5) or Failure
Handle failures gracefully:
val recovered = Failure[int](fmt.Errorf("oops")).Recover((e) => 0)
// Success(0)
val recoveredWith = Failure[int](fmt.Errorf("oops")).RecoverWith((e) => Success(0))
// Success(0)
OnSuccess and OnFailure run side effects and return the original Try:
val logged = Success(42)
.OnSuccess((n) => { Println(s"Got: $n") })
.OnFailure((e) => { Println(s"Error: ${e.Error()}") })
.Map((x) => x * 2)
val value = failure.GetOrElse(0) // 0
val alternative = failure.OrElse(Success(100)) // Success(100)
val opt = Success(42).ToOption() // Some(42)
val either = Success(42).ToEither() // Right(42)
Exhaustive — no default case needed:
val msg = result match {
case Success(n) => s"Got: $n"
case Failure(e) => s"Error: ${e.Error()}"
}
Try enables elegant pipelines where errors short-circuit the chain:
func processOrder(id int) Try[Receipt] =
fetchOrder(id)
.FlatMap(validateOrder)
.FlatMap(chargePayment)
.FlatMap(createReceipt)
.RecoverWith((e) => {
logError(e)
Failure(e)
})
| Method | Description |
|---|---|
Try(f) |
Execute f, catch panics as Failure |
IsSuccess() / IsFailure() |
Check the state |
Get() |
Get value or panic |
GetOrElse(default) |
Get value or return default |
OrElse(alternative) |
Return this if Success, otherwise alternative |
OnSuccess(f) / OnFailure(f) |
Execute side-effect, return original |
Map(f) |
Transform value if Success |
FlatMap(f) |
Chain operations returning Try |
Filter(predicate) |
Keep Success if predicate holds |
Recover(f) |
Recover from Failure with a value |
RecoverWith(f) |
Recover from Failure with a new Try |
ToOption() |
Convert to Option |
ToEither() |
Convert to Either[error, T] |
GALA’s json package integrates with Try[T] for safe JSON handling. A Codec[T] serializes and parses with zero reflection; every operation returns Try — no unchecked errors. The naming strategy (AsIs, SnakeCase, CamelCase, KebabCase) controls how field names map to keys.
import . "martianoff/gala/json"
struct Config(Host string, Port int)
val codec = Codec[Config](AsIs())
val config = Config(Host = "localhost", Port = 8080)
val jsonStr = codec.Encode(config).GetOrElse("")
// => {"Host":"localhost","Port":8080}
val pretty = codec.EncodePretty(config).GetOrElse("")
// => {
// "Host": "localhost",
// "Port": 8080
// }
val parsed = codec.Decode(jsonStr)
// parsed: Try[Config]
val host = parsed.Map((c) => c.Host).GetOrElse("unknown")
A codec doubles as a pattern-matching extractor — it parses JSON inside match expressions:
val result = inputStr match {
case codec(c) => s"Host: ${c.Host}, Port: ${c.Port}"
case _ => "invalid config"
}
This combines Try-based safety with pattern matching — the codec’s extractor returns None on parse failure, so the case _ branch handles malformed input.
The real power of monadic error handling is composition. Instead of checking errors at every step, you build a pipeline and handle errors at the end:
val name = user.Name
.Map((n) => strings.ToUpper(n))
.GetOrElse("ANONYMOUS")
Compare this to the Go equivalent:
| GALA | Go |
|---|---|
|
|
The GALA version reads top-to-bottom. The happy path is the main path. Error handling is a single Recover at the end.
bind and alsoMap/FlatMap chains are great for a single linear pipeline. bind/also do-notation earns its keep on two shapes they handle badly: reusing an earlier value several steps later (a graph), and combining independent steps — where also unlocks error accumulation and concurrency that a FlatMap chain cannot express at all. Inside a function whose result is a monad, bind name = expr unwraps and names each step; the block lowers to the same FlatMap chain.
The final Receipt needs both the original order and the payment, so a FlatMap chain must nest — each value is trapped in a closure and the block ends in a pile of closing parens:
Before — nested FlatMap (o survives only via the deepening indentation):
func processOrder(id int) Try[Receipt] =
fetchOrder(id).FlatMap((o) =>
validateOrder(o).FlatMap((valid) =>
chargePayment(valid).FlatMap((payment) =>
Success(Receipt(o.Id, payment)))))
After — a flat bind block (every value stays in scope, reads top-to-bottom):
func processOrder(id int) Try[Receipt] {
bind o = fetchOrder(id)
bind valid = validateOrder(o)
bind payment = chargePayment(valid)
Success(Receipt(o.Id, payment)) // `o` still in scope; first Failure short-circuits
}
bind is fail-fast: over Try/Option/Either the first failure wins. also marks independent clauses, and over Validated (in the validation package) it accumulates every error instead of stopping at the first:
import . "martianoff/gala/validation"
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))
}
makePerson("", "", -1).GetErrors().Size() returns 3 — all three failures at once, not just the first. A FlatMap chain cannot do this: it is fail-fast, so it stops at the first error. Swap the alsos for binds and you’d get 1.
Over Future, an also group runs its clauses concurrently rather than threading each through the next:
func total() Future[int] {
bind a = compute(2)
also b = compute(3) // independent — all three run at once
also c = compute(4)
Future(a + b + c)
}
Bound names are immutable vals, and bind/also work over any user-defined monad that provides a FlatMap method. See the language reference for the full specification.
GALA’s monadic types are not always the right tool. Honest trade-offs:
if err != nil is perfectly clear. Wrapping it in Try adds indirection without benefit.Option, Either, and Try allocate wrapper structs. In hot loops processing millions of items, Go’s zero-cost error returns may be preferable.(T, error) is a Try[T] by default. When the results go straight back into other Go code, bind them Go-style (val v, err = call()) instead of converting back and forth.The sweet spot for monadic error handling is multi-step pipelines where several operations can fail, and you want to keep the code linear and composable.