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)
case None()
}
Use Option anywhere you would reach for nil in Go. It forces the caller to acknowledge the absence case.
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 still_none = 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[int]())
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 |
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)
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((o) => validateOrder(o))
.FlatMap((o) => chargePayment(o))
.FlatMap((o) => createReceipt(o))
.RecoverWith((e) => {
logError(e)
return Failure[Receipt](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] |
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 |
|---|---|
| ```gala val result = divide(10, 2) .Map((x) => x * 2) .FlatMap((x) => divide(x, 3)) .Recover((e) => 0) ``` | ```go result, err := divide(10, 2) if err != nil { result = 0 } else { result = result * 2 result, err = divide(result, 3) if err != nil { result = 0 } } ``` |
The GALA version reads top-to-bottom. The happy path is the main path. Error handling is a single Recover at the end.
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), you are already in Go’s error model. Converting to Try at the boundary is useful; converting back and forth repeatedly is not.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.