Composability and Type Safety
Union types in mkunion are designed to be highly composable, allowing you to build sophisticated type-safe abstractions.
This guide explores how to create and compose fundamental union types like Option
and Result
, demonstrating patterns that eliminate null pointer exceptions and provide exhaustive error handling.
Core Union Types: Option and Result
Let's start by implementing two of the most popular union types from functional programming:
- Option[T]: Represents a value that may or may not be present, eliminating null references
- Result[T, E]: Represents either a success value (Ok) or an error value (Err), providing type-safe error handling
Basic Implementation
// package "github.com/widmogrod/mkunion/f"
// Result type - explicit error handling without exceptions
//
//go:tag mkunion:"Result[A, E]"
type (
Ok[A any, E any] struct{ Value A }
Err[A any, E any] struct{ Error E }
)
// Option type - represent nullable values explicitly
//
//go:tag mkunion:"Option[A]"
type (
None[A any] struct{}
Some[A any] struct{ Value A }
)
import (
"fmt"
. "github.com/widmogrod/mkunion/f"
)
type User struct{ Name string }
type APIError struct {
Code int
Message string
}
// FetchResult combine unions for rich error handling
type FetchResult = Result[Option[User], APIError]
// handleFetch uses nested pattern matching to handle result
func handleFetch(result FetchResult) string {
return MatchResultR1(result,
func(ok *Ok[Option[User], APIError]) string {
return MatchOptionR1(ok.Value,
func(*None[User]) string { return "User not found" },
func(some *Some[User]) string {
return fmt.Sprintf("Found user: %s", some.Value.Name)
},
)
},
func(err *Err[Option[User], APIError]) string {
return fmt.Sprintf("API error: %v", err.Error)
},
)
}
The example shows a common pattern: an API fetch that might fail (Result) and might return no data (Option):
// FetchResult combine unions for rich error handling
type FetchResult = Result[Option[User], APIError]
This type precisely captures three states:
- Success with data:
Ok[Some[User]]
- Success with no data:
Ok[None[User]]
- API failure:
Err[APIError]
Handling Nested Unions
// handleFetch uses nested pattern matching to handle result
func handleFetch(result FetchResult) string {
return MatchResultR1(result,
func(ok *Ok[Option[User], APIError]) string {
return MatchOptionR1(ok.Value,
func(*None[User]) string { return "User not found" },
func(some *Some[User]) string {
return fmt.Sprintf("Found user: %s", some.Value.Name)
},
)
},
func(err *Err[Option[User], APIError]) string {
return fmt.Sprintf("API error: %v", err.Error)
},
)
}
Creating Composed Values
func TestCreatingUnions(t *testing.T) {
// Success with user
fetchSuccess := MkOk[APIError](MkSome(User{Name: "Alice"}))
// Success but user not found
fetchNotFound := MkOk[APIError](MkNone[User]())
// API error
fetchError := MkErr[Option[User]](APIError{
Code: 500,
Message: "Internal Server Error",
})
assert.Equal(t, "Found user: Alice", handleFetch(fetchSuccess))
assert.Equal(t, "User not found", handleFetch(fetchNotFound))
assert.Equal(t, "API error: {500 Internal Server Error}", handleFetch(fetchError))
}
Summary
Union type composition in mkunion provides:
- Type Safety: Impossible states are unrepresentable
- Exhaustiveness: The compiler ensures all cases are handled
- Composability: Simple types combine into sophisticated abstractions
- Clarity: Error paths and edge cases are explicit in types
By mastering Option and Result composition, you can build robust applications that handle errors gracefully and eliminate entire classes of runtime failures. The key is to start simple, build a library of helper functions, and gradually compose more sophisticated types as your domain requires.
Next steps
- Phantom Types - Learn benefits of phantom types