Functional Options With() ...

Cascading Functional Options in Go: An Elegant Pattern for API Design

This is part of my series on the njones/socketio server implementation. I occasionally employ techniques that might be unconventional, and I’m curious about their prevalence in the wider Go community. If you’ve used similar approaches, please leave a comment—I’d love to gauge how common these patterns actually are.

In the world of Go, functional options have become a popular pattern for configuring objects, thanks to their flexibility and readability. But what happens when you have nested components that each need their own configuration? Today, I’d like to share a pattern I’ve implemented in the socketio package that allows cascading functional options to internal objects.

The Traditional Approach vs. My Approach

In the njones/socketio library, we have two main components: the higher-level socketio protocol built on top of the lower-level engineio protocol. Typically, users interact primarily with the socketio layer, even though the engineio layer has various configuration options as well.

Usually it looks something like this:

import egn "/path/to/package/engineio"
import skt "/path/to/package/socketio"

eio := egn.NewEngineIO(/* add engineio functional options */)
sio := skt.NewSocketIO(eio, /* add socketio functional options */)

This works perfectly fine and mirrors how many libraries are structured. But I decided to take a slightly different approach that simplifies the API. My implementation allows users to do this instead:

import egn "/path/to/package/engineio"
import skt "/path/to/package/socketio"

sio := skt.NewSocketIO(eio.WithOption(), /* add socketio functional options */)

Yes, you can pass an engineio option directly into the socketio constructor, and it will be applied to the underlying engineio object that’s created automatically. It’s like ordering a burger and specifying how you want your fries cooked—the kitchen knows which instruction applies to which item. I think approach reduces the mental overhead for my users, although it does require clear documentation to indicate that options can be passed through components.

How It Works: The Self-Referential Interface

To implement this approach, I created what I call the “SSRIP: self-referential interface pattern”. First, I set up an internal option package with the following definitions:

// internal/option package
package option

type Option func(OptionWith)
type OptionWith interface{ With(...Option) }

What this means is that our functional option takes an object that has a With method, which itself accepts functional options. It’s a bit like those nested Russian dolls, except each doll can contain multiple smaller dolls, and they all somehow fit inside each other. Actually, that’s not a great analogy—it’s more like recursion explained through recursion.

To make this work, both the Socket and Engine structs need to implement a With method:

// /path/to/package/engineio
func (e *Engine) With(opts ...Option) {
    for _, opt := range opts {
        opt(e)
    }
}

// /path/to/package/socketio
func (s *Socket) With(opts ...Option) {
    for _, opt := range opts {
        opt(s)
    }
}

But wait, how can both packages refer to the same Option type? That’s handled through type aliases in each package:

// option.go in each package
import "/path/to/internal/package/option"

type Option = option.Option
type OptionWith = option.OptionWith

Okay, now that’s cleared up, we can continue the cascade.

Cascading Options to Internal Objects

With this setup, we can create a new SocketIO instance that automatically initializes and configures an EngineIO instance:

func NewSocketIO(opts ...Option) *Socket {
    sio := &Socket{
        /* set stuff up */ 
        eio: NewEngineIO(opts...) // assuming there's a field like this
    }
    sio.With(opts...)
    return sio
}

/* inside the EngineIO package */
func NewEngineIO(opts ...Option) *Engine {
    eio := &Engine{ /* set stuff up */ }
    eio.With(opts...)
    return eio
}

This approach allows options to cascade down to internal components without requiring the user to configure each component separately, then pass it in! It’s the DRY principle at its finest—much like how you’d rather grab just one robe after a shower instead of running to the linen closet multiple times for a towel while dripping water all over. Robe, towel, dry , get it?

Targeting Specific Components

But how do we ensure that options only apply to the appropriate component? We do that by checking the type of the object in the option function:

func WithTimeout(duration time.Duration) Option {
    return func(with OptionWith) {
        if obj, ok := with.(*Socket); ok {
            obj.timeout = duration // assuming there's a field like this
        }
    }
}

This pattern allows options to selectively apply themselves only to the components they’re designed for. Other options will silently skip objects they’re not meant for, which keeps the code clean and prevents runtime errors. It’s like a bear and a duck sitting on the same log in a pond - they’re sharing the same interface with the water, but the bear is only interested in catching fish while the duck just wants to paddle. Neither gets in the other’s way, no problem, right?

Okay, so there is one thing that could look like a potential problem, it’s that options silently fail if applied to unsupported structs. Changkun Ou discusses this approach and mentions that it could lead to safety issues. However, I prefer the silent failure approach for my code. There is a fix, and that is, if you want to provide feedback about incompatible options, you can either use generics as suggested in Changkun’s post or implement runtime checks with error returns.

Adding Error Handling

If you prefer more explicit error handling, you can modify the interface slightly:

// internal/option package
package option

type Option func(OptionWith) error
type OptionWith interface{ With(...Option) error }

Then update your implementation accordingly:

func NewSocketIO(opts ...Option) (*Socket, error) {
    sio := &Socket{}
    eio, err := NewEngineIO(opts...)
    if err != nil {
        return nil, err
    }
    sio.eio = eio
    
    err = sio.With(opts...)
    if err != nil {
        return nil, err
    }
    return sio, nil
}

func (s *Socket) With(opts ...Option) error {
    for _, opt := range opts {
        if err := opt(s); err != nil {
            return err
        }
    }
    return nil
}

This approach provides more explicit feedback when options are incompatible, though it does make the API slightly more complex. It’s a classic trade-off between silent simplicity and explicit verbosity.

Benefits and Trade-offs

The cascading functional options pattern offers several advantages. It provides a cleaner API by allowing users to configure multiple components through a single function call. This simplifies the user experience and reduces the surface area of your API, making it more intuitive to use.

However, this approach does come with some trade-offs. The self-referential interface can be a bit mind-bending at first glance, and the pattern relies on type assertions which are checked at runtime rather than compile time. Additionally, without explicit error handling, incompatible options will fail silently, which could lead to subtle configuration issues if not properly documented.

The socketio and engineio packages can share a common option mechanism, yet remain fully independent. Each package can define its own options, who’s configuration intent can be identified by the package they’re defined in. I think this gives you the best of both worlds: a unified interface for users and decoupled implementation for developers.

Despite these trade-offs, I’ve found this pattern to be an elegant solution for my socketio library with nested components. Have you implemented similar patterns in your Go code? I’d love to hear about your experiences and alternative approaches in the comments.

Further Reading

The functional options pattern has been evolving in the Go community for years. For recent perspectives, check out:

  1. Dysfunctional options pattern in Go by Redowan Delowar
  2. Golang Functional Options are named args on steroids by Vladimir Mihailenco