Golang Option Type — Option, Either, and Try for Go

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]: Some and None

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).

Creating Options

val present = Some(42)          // Option[int] containing 42
val absent = None[int]()        // Option[int] with no value

Map and FlatMap

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())

GetOrElse and OrElse

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")

Filter

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]

Side Effects

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)

Pattern Matching on Option

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"
}

Key Methods

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]: Left and Right

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)
}

Creating Either Values

val success = Right[string, int](42)
val failure = Left[string, int]("not found")

Map and FlatMap on Right

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

Chaining

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)

Side Effects

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)

Pattern Matching on Either

Exhaustive matching — no default case needed:

val msg = result match {
    case Left(code)  => s"Error code: $code"
    case Right(s)    => "Result: " + s
}

Try[T]: Success and Failure

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)
}

Creating Try Values

// 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)

Try from Go Calls

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

Map and FlatMap

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

Recover and RecoverWith

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)

Side Effects

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)

Safe Extraction

val value = failure.GetOrElse(0)              // 0
val alternative = failure.OrElse(Success(100)) // Success(100)

Conversion

val opt = Success(42).ToOption()       // Some(42)
val either = Success(42).ToEither()    // Right(42)

Pattern Matching on Try

Exhaustive — no default case needed:

val msg = result match {
    case Success(n) => s"Got: $n"
    case Failure(e) => s"Error: ${e.Error()}"
}

Railway-Oriented Programming

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)
        })

Key Methods

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]

Json: Type-Safe JSON with Try

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.

Serialization

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
// }

Deserialization

val parsed = codec.Decode(jsonStr)
// parsed: Try[Config]

val host = parsed.Map((c) => c.Host).GetOrElse("unknown")

Json Pattern Matching

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.


Chaining: Composing Operations into Pipelines

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:

GALAGo
val result = divide(10, 2)
    .Map((x) => x * 2)
    .FlatMap((x) => divide(x, 3))
    .Recover((e) => 0)
result, err := divide(10, 2)
if err == nil {
    result, err = divide(result*2, 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.


Monadic Binding: bind and also

Map/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 “graph” case — reuse an earlier value later

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
}

Error accumulation — report ALL invalid fields

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.

Concurrency — run independent clauses in parallel

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.


When Go’s Error Handling Is Fine

GALA’s monadic types are not always the right tool. Honest trade-offs:

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.


Further Reading