opts

package module
v0.0.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 5, 2025 License: MIT Imports: 4 Imported by: 13

README

⎡🅾🅿🆃🆂⎦

Effortless Functional Options: Simplify Configuration, Minimize Boilerplate.


Lightweight library crafted to streamline and automate Golang's Functional Option Pattern. Would you like to know more? Read the post - Next-Gen Functional Options in Golang: Effortless Configuration, Zero Boilerplate.

Inspiration

The Functional Option Pattern in Go offers several compelling benefits, making it a strong choice as my go-to standard for development. By reducing complexity, it eliminates the need to manage configuration structures cluttered with a mix of optional and mandatory fields. Instead, it provides a clean, functional approach to handling optional parameters. Option functions encapsulate complex configuration logic, such as validation or type translation, which keeps the core struct simpler and less prone to errors. Additionally, the pattern’s flexibility supports backward-compatible evolution without requiring multiple overloaded constructors, enabling configurations that are both concise and easy to understand. Finally, options are functions, they provide stronger type checking and can validate values more dynamically when applied.

There is no consensus within the Go community on the Option Pattern, as it presents several common challenges. These challenges align with my own observations from using the pattern regularly. Notably, the issues can be grouped into several categories:

  • Complexity and Boilerplate: Implementing the Option Pattern leads to significant boilerplate code. Each option requires a separate function, making the code more verbose, harder to maintain and unit test.
  • Mandatory Parameters: Enforcing mandatory parameters in an Option Pattern setup can be awkward. Common solutions (like post-configuration validation or combining options with required constructor arguments) add complexity and may limit the pattern's elegance.
  • Compatibility with Dependency Options: When two or more libraries are chained, mapping options between dependencies can be tricky. This requires additional layers to translate options, adding more complexity.
  • Discoverability: As options are applied through functions rather than struct fields, it can be harder for users to see all available configurations. Users may need to reference documentation to find all options.

⎡🅾🅿🆃🆂⎦ is a lightweight library crafted to streamline and automate the creation of functional options. By abstracting over struct fields (leveraging capabilities like those in golem/optics), it eliminates the primary issue of boilerplate code. This approach makes defining functional options nearly as straightforward as using a struct-based configuration, reducing complexity while preserving flexibility.

Getting Started

The latest version of the library is available at main branch of this repository. All development, including new features and bug fixes, take place on the main branch using forking and pull requests as described in contribution guidelines. The stable version is available via Golang modules.

Use go get to retrieve the library and add it as dependency to your application.

go get -u github.com/fogfish/opts

Quick example

Example below is most simplest illustration on how to eliminate boilerplate with Functional Option Pattern.

package main

import (
  "fmt"

  "github.com/fogfish/opts"
)

// Configuration type
type Client struct{ host string }

// Configuration option
var WithHost = opts.ForType[Client, string]()

// Factory creates configuration instance
func New(opt ...opts.Option[Client]) (*Client, error) {
  c := Client{}

  // apply configuration options to type
  if err := opts.Apply(&c, opt); err != nil {
    return nil, err
  }

  return &c, nil
}

func main() {
  c, err := New(WithHost("example.com"))
  if err != nil {
    panic(err)
  }

  fmt.Printf("==> %+v\n", c)
}

Functional Option Pattern

The purpose of the Functional Option Pattern is a transformation of a configuration type using a sequence of functions. Given an initial configuration object C₀, a set of optional parameters is represented as a sequence of functions {ƒ₁,ƒ₂,…,ƒₙ} that each map a configuration C to a modified configuration C′. In the plain Golang code, the pattern is defined as (also see the excellent post about Functional Option Pattern.)

// Configuration type
type Client struct { … }

// Category of optional parameters
type Option func(*Client) error

// Instance of optional parameter
func WithOptA() Option { … }

// Apply optional parameters on initial configuration
func New(opts ...Option) (*Client, error) { … }

As we conclude, the pattern is verbose. Each option requires a separate function, making the code harder to maintain. The library defines automation receipts to streamline the pattern usage.

Defining functional options

The functional option is a type func(A) Option[S] that transforms S. The library provides generators that automatically produce these functions, eliminating the need for clients to manually implement them.

opts.ForType[S, A] leverages the type hint A to generate an instance of the functional option for S. This type hint allows opts to infer the configuration target, enabling type-safe options without manual specification. By automatically aligning types, opts.ForType simplifies configuration and minimizes potential type mismatches, making option creation both streamlined and error-resistant.

type Host string

type Client struct {
  host Host
}

var WithHost = opts.ForType[Client, Host]()

opt.ForName[S, A] uses both the type hint A and the specified attribute name to generate a functional option instance for S. By combining type and attribute name, opt.ForName enables precise, type-safe configuration that targets specific fields within S. One disadvantage of opt.ForName is that it can be more verbose, as it requires specifying both the type and attribute name. However, this added detail enhances clarity and reduces potential errors in complex configurations.

type Client struct {
  host string
}

var WithHost = opts.ForType[Client, string]("host")

"Complex" configuration logic

In 99% of cases, optional parameters function as simple setters. However, there are times when you need to perform more "complex" operations—such as validation, type conversion, or other preprocessing—before setting the value. Both opts.ForType[S, A] and opts.ForName[S, A] can optionally accept a configuration function of the form func(*S, A) error, allowing clients to define custom logic. This function enables additional processing, such as validation or transformation, before the value is set, giving clients fine-grained control over complex configurations. Note that the config function is executed after S has been configured, ensuring any dependent fields or values are available for the custom logic.

type Client struct {
  n0 float64
  nL float64
}

var WithN = opts.ForName("n0", func(c *Client, n float64) error {
  if c.n0 < 0.0 {
    return fmt.Errorf("invalid n0")
  }
  
  if c.nL == 0.0 {
    c.nL = c.n0 * 2
  }

  return nil
})

Apply configuration

According to the Functional Option Pattern, a constructor like New(opt ...Option[S]) is used to accept a sequence of functional options. The client is responsible for applying each option to the configuration type. To simplify this, the library provides an opts.Apply helper, which automatically unwraps and applies the list of options, streamlining the configuration process.

func New(opt ...Option) (*Client, error) {
  c := Client{}
  if err := opts.Apply(&c, opt); err != nil {
  }
  return c, nil
}

Mandatory Parameters

Some configurations require mandatory parameters, meaning the setup should fail if any of these parameters are missing. To support this, the library provides an opts.Required helper function, allowing clients to specify which configuration parameters are essential. By using opts.Required, clients can enforce the presence of critical parameters, ensuring that configurations are validated and any missing mandatory options are detected early, preventing incomplete or invalid setups.

var WithHost = opts.ForType[Client, Host]()

func (c *Client) checkRequired() error {
  return opts.Required(c, WithHost(""), /* ... */)
}

func New(opt ...Option) (*Client, error) {
  // ...
  return c, c.checkRequired()
}

Presets and defaults

Certain use cases are often broad enough to be supported with pre-defined options. For configurations, this might involve bundling a set of options together to create a preset tailored to a particular use case. Presets are particularly useful for enabling a service to operate seamlessly across multiple environments. opts.Join groups options into single unit

var (
  WithTestEnv = opts.Join(WithHost("localhost"), WithPort(8080))
  WithLiveEnv = opts.Join(WithHost("example.com"), WithPort(443))
)

Dependency injections

It's common for one library to rely on the functionality of another. In Go, using interfaces and dependency injection is the recommended approach for managing these dependencies. However, in certain edge cases, it can be simpler to handle initialization directly within a top-level constructor, passing configuration options to encapsulate dependencies effectively. The library has helper opts.Use for generating functional options to configure instances of S with attributes of type A, where A itself is also configurable through Option[T] and factory f.

type Client struct { *http.Stack }

// The param accepts http.Option and uses http.New function to config Client
var WithHttp = opts.Use[Client](http.New)

c := New(WithHost("127.1"), WithHttp(http.Timeout(5*time.Seconds)))

Functional Option compatibility

What if an application already defines a functional option using pure functions, yet requires complete control over each option and its parameters? For example, migrating a complex setup to align with this library’s types may present challenges, or perhaps a unique option needs multiple parameters. This library provides a straightforward way to convert a pure function into the required option type, supporting smooth integration while allowing detailed customization.

func WithAssumedRole(conf aws.Config, role, externalID string) Option {
  return opts.Type(func(*Client) error {
    // use conf, role & external id to assume new role
  })
}

Practical tips

When designing a Go library, the choice between using the Option Pattern and Structs with Fields for configuration or optional parameters depends on various factors. Here are practical tips to help you make an informed decision:

Option PatternStructs
Flexibility and Extensibility ✅ Provides high flexibility for future changes. Since options are typically functions that modify internal fields, adding new options in the future won’t require changes to existing struct definitions or method signatures. ☣️ Using structs with fields offers less flexibility for future extensions. Once a struct is defined with specific fields, adding new configuration options often requires creating a new struct or modifying the existing one, which could lead to breaking changes in the API.
Readability and Usability ✅ May be less readable if there are too many options, as users may not immediately know what fields are being set without referencing the documentation. However, for complex configurations, the option pattern can make code more expressive and readable by allowing named options. ✅ Provides clearer readability because users can see all configuration fields in one place. This makes it easier for users to understand what configurations are available.
Safety and Type Checking ✅ Since options are functions, they can provide stronger compile-time type checking and can validate values more dynamically when applied. For example, you can define each option function to accept specific types or check for valid ranges. ☣️ With structs, you risk users incorrectly setting fields, especially if some fields are related. You can use custom types for stricter typing, but it is harder to enforce constraints on field values directly through the struct.
Complexity ✅ Off-the-shelf implementation requires more boilerplate code. Each option function needs to be defined, and you need a method to apply these options to the final configuration. This increases code complexity and verbosity, especially if many options are needed. However, the ⎡🅾🅿🆃🆂⎦ library reduces the complexity ✅ Simpler to implement as you define a struct and set its fields directly. This is often more straightforward and reduces the maintenance overhead compared to defining multiple option functions.
Encapsulation ✅ Supports better encapsulation. Since options are applied via functions, you can keep your internal state private, exposing only the necessary configuration APIs to the user. This can help prevent misuse by hiding certain internal details from the API user. ☣️ Users may directly modify struct fields, which can lead to unintended consequences if not carefully managed. While fields can be made private, that might limit usability or complicate the API.
Default Values and Optional Parameters ✅ Makes it easy to provide default values and only override those that are explicitly set by the user. It enables a “fluent” API style that is expressive and easy to customize without requiring many constructors. ☣️ You can set defaults within the struct directly or through constructor functions, but it can become unwieldy if there are many optional parameters or if defaults need to be conditionally set based on other fields.
Documentation and Discoverability ☣️ It can be harder for users to discover all available options, especially if the options are spread across various files or if the documentation doesn’t clearly list them all together. However, well-named option functions can improve discoverability. ✅ All fields are typically visible within the struct definition, which can make it easier for users to quickly understand all configurable parameters.

How To Contribute

The library is MIT licensed and accepts contributions via GitHub pull requests:

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request

The build and testing process requires Go.

build and test library.

git clone https://github.com/fogfish/opts
cd opts
go test ./...

commit message

The commit message helps us to write a good release note, speed-up review process. The message should address two question what changed and why. The project follows the template defined by chapter Contributing to a Project of Git book.

bugs

If you experience any issues with the library, please let us know via GitHub issues. We appreciate detailed and accurate reports that help us to identity and replicate the issue.

License

See LICENSE

Documentation

Overview

Package opts is the helper library for Functional Option Pattern. It solves common challenges developers meet, while using this pattern at scale. Notably, it addresses:

Boilerplate: Implementing the Option Pattern leads to significant boilerplate code. Each option requires a separate function, making the code more verbose, harder to maintain and unit test. The library automates declaration of options using generics.

Mandatory Parameters: Enforcing mandatory parameters in an Option Pattern setup can be awkward. The library implements declarative approach to reuse the same instance of Option type to configure and validate, ensuring type safety and making the code easier to maintain.

Compatibility with Dependency Options: When two or more libraries are chained, mapping options between dependencies can be tricky. This requires additional layers to translate options, adding more complexity. Discoverability: As options are applied through functions rather than struct fields, it can be harder for users to see all available configurations. Users may need to reference documentation to find all options.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Apply

func Apply[S any](s *S, opts []Option[S]) error

Apply sequence of options over the configuration type `S`.

func New(opt ...Option) (*Client, error) {
	...
	if err := opts.Apply(&c, opt); err != nil {
		return nil, err
	}
}

func FMap

func FMap[S, T any](f func(*S, T) error) func(T) Option[S]

FMap is a helper function for generating functional options to configure attributes within instances of `S` using input type 'T'.

func ForName

func ForName[S, A any](attr string, config ...func(*S, A) error) func(A) Option[S]

ForName is helper function to generate functional option for configuring attribute of type `A` at instances `S`.

Clients typically use:

type Client { host string }

var WithHost = opts.ForName[Client, string]("host")

By default, ForName creates a simple setter. The API also supports defining complex configuration logic within option functions. You can provide a config function that performs validations, type conversions, or creates new instances.

var WithHost = opts.ForName[Client, string]("host", func(c *Client, opt string) error {
	// e.g. validate input & return error
})

func ForType

func ForType[S, A any](config ...func(*S, A) error) func(A) Option[S]

ForType is helper function to generate functional option for configuring attribute of type `A` at instances `S`.

Clients typically use:

type Client { host string }

var WithHost = opts.ForType[Client, string]()

By default, ForType creates a simple setter. The API also supports defining complex configuration logic within option functions. You can provide a config function that performs validations, type conversions, or creates new instances.

var WithHost = opts.ForType[Client, string](func(c *Client, opt string) error {
	// e.g. validate input & return error
})

func From

func From[S any](f func(*S) error) func() Option[S]

From is helper function for building default options

func Required

func Required[S any](s *S, opts ...Option[S]) error

Required checks that mandatory parameters are defined within instance of `S`.

func Use added in v0.0.2

func Use[S, A, T any](f func(...Option[T]) (A, error)) func(...Option[T]) Option[S]

Use is a helper function for generating functional options to configure instances of `S` with attributes of type `A`, where `A` itself is also configurable through `Option[T]` and factory `f`.

Let's consider example when configurable type uses another configurable type.

type Client struct { http.Stack }

var WithHttp = opts.Use[Client](http.New)

Types

type Option

type Option[S any] interface {
	// contains filtered or unexported methods
}

Option is an abstract type for configuring instances of `S`. The library provides ForType, ForName and Opt helpers to create concrete functional options. These helpers eliminate the need for boilerplate code when defining new options.

Clients are encouraged to define type aliases for improved readability and ease of use:

type Option = opts.Option[Client]

func Join

func Join[S any](opts ...Option[S]) Option[S]

Join multiple options to single one, creating defaults and presets.

func Opt

func Opt[S, A any](attr string, value A, config ...func(*S, A) error) Option[S]

Opt is a helper function for generating functional options to configure attributes of type `A` within instances of `S`. Opt and ForName are complementary: ForName returns a functional option instance that can be assigned to variables. However, one drawback is that options created this way appear in the "Variables" section of documentation. If clients want all functional options to appear under the "Option" type for clearer documentation, they may need to...

type Option = opts.Option[Client]

func WithHost(host string) Option { return opts.Opt[Client, string]("host", host) }

Using Opt may add some verbosity but helps organize the documentation more effectively.

type Type added in v0.0.3

type Type[S any] func(*S) error

Type is a foundational type for declaring functional options. Use this type for complete control over option configuration.

func WithAssumedRole(conf aws.Config, role, externalID string) Option {
  return opts.Type(func(*Client) error { ... })
}

Directories

Path Synopsis
examples

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL