Since I had to write some Rust for an internal project, I started to love the std::option::Option
type.
This is why I created the typact
library.
The goal behind the module is to provide an easy to use and hard to misuse option type.
What is an option type? #
Let’s take a look at the
std::option::Option
enum in Rust:
pub enum Option<T> {
None,
Some(T),
}
Where None
represents no value and Some(T)
represents some value of type T
.
For example, imagine you wrote a application which loads a config file and you want to
make the log_level
field optional.
You could write the following code in rust to represent the config structure:
pub struct Config {
pub log_level: Option<String>,
}
This would allow the user to write the following config:
log_level: null
The user could even drop the log_level
statement.
TL;DR: an option type allows you to define an optional value.
The
std::option::Option
type and
typact.Option
provide helper methods to make it
easier to work with optional values.
For example the
UnwrapOr
method.
It allows one to either get the value provided by, e.g., the user or the default value.
Let’s take the Rust Config
example from above. In our example application we define the info
level
as the default log level:
fn do_something(cfg: Config) {
let log_level = cfg.log_level.unwrap_or("info".to_string());
println!("{}", log_level);
}
Now we can sure that log_level
always has a value.
Please note that the rust example is simplified and you probably want to use an enum.
For sake of simplicity, a String
was used.
We can do the same in GoLang:
import "go.l0nax.org/typact"
type Config struct {
LogLevel typact.Option[string]
}
func doSomething(cfg Config) {
logLevel := cfg.LogLevel.UnwrapOr("info")
println(logLevel)
}
Why? #
Optional values are common if you’re communicating with other services. For example, let’s say you have a REST endpoint that expects a body like this:
{
"full_name": <string>,
"email": <string>,
"phone": <optional string>
}
In TypeScript you could define the following interface:
interface PersonRequest {
full_name: string;
email: string;
phone?: string;
}
In Go you are forced to use a string pointer or assume an empty string is equal to undefined:
type PersonRequest struct {
FullName string `json:"full_name,omitempty"`
Email string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
}
I don’t like either solution (pointer or zero value). And sometimes, you want to be able to distinguish between undefined and the zero value. I.e., the zero value is a valid value. In this case, you are forced to use a pointer.
And I really like the convenience of using helper methods like Or
or AndThen
.
Lets have a look at the following example:
type Options struct {
// ...
BaseURL typact.Option[string]
// ...
}
func doSomething(opts Options) {
// ...
url := opts.BaseURL.
Or(typact.Some("https://default.domain")).
AndThen(func(base string) typact.Option[string] {
if strings.HasSuffix(base, "/api/pubsub/v1") {
return typact.Some(base)
}
return typact.Some(base + "/api/pubsub/v1")
}).
Unwrap()
// ...
}
In my opinion using the Or
and AndThen
methods it is much clearer to read and easier to
understand what is going on.
You could argue that this is the case when defining that if BaseURL
is empty, it is not provided.
If you want to maintain consistency throughout your code base and, let’s say you decided to use pointer
and nil
as an indicator that a value has not been provided, you would need to write the following code:
// toPtr returns a pointer to v.
//
// NOTE: We can use that this helper function has been defined
// somewhere or you are using the great https://github.com/samber/lo library.
func toPtr[T any](v T) *T {
return &v
}
func doSomething(opts Options) {
var url string
if opts.BaseURL == nil {
url = "https://default.domain/api/pubsub/v1"
} else {
url = *opts.BaseURL
if !strings.HasSuffix(url, "/api/pubsub/v1") {
url += "/api/pubsub/v1"
}
}
}
Please note that the non-pointer version is much shorter than both of the examples above:
func doSomething(opts Options) {
url := opts.BaseURL
if url == "" {
url = "https://default.domain/api/pubsub/v1"
} else if !strings.HasSuffix(url, "/api/pubsub/v1") {
url += "/api/pubsub/v1"
}
}
In my opinion, even the zero value approach is harder to read compared to the typact.Option
approach.
Quick example #
The module can be retrieved by executing
$ go get go.l0nax.org/typact
You can find the full module documentation here on pkg.go.dev.
Additionally, the typact.Option
type can be used to represent a nullable column in a database table.
Please note that this library was created before database/sql.Null
was introduced with go 1.22.0
(see the
GitHub Issue).
Nevertheless, typact.Option
provides full DB compatibility with the additional helper methods.
Future ideas #
As described in the
Why? section, the option type might allow a project to
prevent the one billion dollar mistake. I.e., any pointer wrapped in an option type
that contains a value will never be nil
.
Yes, it will never be able to throw an error at compile time like in Rust – at least not until the Go team decides to introduce an option type with such capabilities - but with a linter detecting such misuse of the option type, we could reduce the probability of introducing such a bug into the codebase.
For now, I’m unsure if this is a good idea.