In the previous article we discussed the Builder pattern. There we noticed a drawback that we can't perform validation when assigning parameters. As a result, we are deprived of the ability to build a chain of methods. Let's look at another more interesting pattern - Functional options. There are different implementations with slight differences, but the basic idea is as follows:
- You create a non-exportable structure containing the configuration: options.
- Each option is a function that returns the same type:
type Option func(options *options) error.
For example, the WithEmail function, which takes a string of email address as an argument and returns an Option type, which is a function with instructions on how to update the structure.
Below I will give a specific example from a CRM system that allows you to create a new contact:
package main
import (
"errors"
"fmt"
"net/mail"
"strings"
)
type Contact struct {
ID string
Name string
Phone string
Email string
Company *CompanyInfo
Address string
Notes string
}
type CompanyInfo struct {
Name string
Position string
}
type ContactOptions struct {
Phone string
Email string
Company *CompanyInfo
Address string
Notes string
}
type ContactOption func(*ContactOptions) error
func WithPhone(phone string) ContactOption {
return func(options *ContactOptions) error {
if len(phone) < 10 {
return errors.New("Phone number must be at least 10 digits")
}
options.Phone = phone
return nil
}
}
func WithEmail(email string) ContactOption {
return func(options *ContactOptions) error {
_, err := mail.ParseAddress(email)
if err != nil {
return errors.New("Invalid email address")
}
options.Email = email
return nil
}
}
func WithCompany(name string, position string) ContactOption {
return func(options *ContactOptions) error {
if strings.TrimSpace(name) == "" || strings.TrimSpace(position) == "" {
return errors.New("Both company name and position must be provided")
}
options.Company = &CompanyInfo{
Name: name,
Position: position,
}
return nil
}
}
func WithAddress(address string) ContactOption {
return func(options *ContactOptions) error {
options.Address = address
return nil
}
}
func WithNotes(notes string) ContactOption {
return func(options *ContactOptions) error {
options.Notes = notes
return nil
}
}
func CreateContact(id string, name string, opts ...ContactOption) (*Contact, error) {
options := ContactOptions{}
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
contact := &Contact{
ID: id,
Name: name,
Phone: options.Phone,
Email: options.Email,
Company: options.Company,
Address: options.Address,
Notes: options.Notes,
}
return contact, nil
}
func main() {
contact, err := CreateContact("1", "John Doe", WithPhone("1234567890"), WithEmail("john.doe@gmail.com"), WithCompany("ABC Corp", "Manager"), WithAddress("123 Main St"), WithNotes("Important client"))
if err != nil {
fmt.Println("Failed to create contact:", err)
return
}
fmt.Println(contact)
}
Here we see many Builder-like methods that populate our structure with the necessary data. For example, WithNotes, WithAddress, WithCompany all return a closure. That is, an anonymous function that binds variables outside its scope. In our case, these are notes, company, and address. The function corresponds to the Option type and contains the data validation logic. For each field in this case we need to write our own function, which by standards starts with the With prefix. And this function must contain the same logic: data validation if necessary and structure updating.
Let's take a look at the CreateContact function. We pass options as variable arguments. Therefore, to change the structure, we must iterate over these options.
It all starts by creating an empty structure
options := ContactOptions{}
Then, we iterate over each Option argument, execute it as a function, this function in turn performs the structure change.
for _, opt := range opts {
err := opt(&options)
if err != nil {
return nil, err
}
}
Once we have assembled the structure, we can implement the final logic for creating the contact.
contact := &Contact{
ID: id,
Name: name,
Phone: options.Phone,
Email: options.Email,
Company: options.Company,
Address: options.Address,
Notes: options.Notes,
}
Because CreateContact accepts options as an argument, developers can now very conveniently use our API by passing various optional parameters, with the identifier and name being mandatory:
contact, err := CreateContact("1", "John Doe", WithPhone("1234567890"), WithEmail("john.doe@gmail.com"), WithCompany("ABC Corp", "Manager"), WithAddress("123 Main St"), WithNotes("Important client"))
We can still create a contact without any additional data, passing only the ID and name.
This pattern is functional options. It provides a convenient and API friendly way to work with options. While the builder pattern may be a viable option, it has some minor drawbacks that make the functional options pattern an idiomatic way to handle this problem in Go. Note also that this pattern is used in various Go libraries such as gRPC.