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] |
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"
type Config struct {
var Host string
var Port int
}
val codec = Codec[Config](AsIs())
val config = Config{Host: "localhost", Port: 8080}
val jsonStr = codec.Encode(config).Get()
// => {"Host":"localhost","Port":8080}
val pretty = codec.EncodePretty(config).Get()
// => {
// "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, the accumulator type [Receipt] is repeated at every link, 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[Receipt]((o) =>
validateOrder(o).FlatMap[Receipt]((valid) =>
chargePayment(valid).FlatMap[Receipt]((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[int](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), 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.