API Design: Optional Parameters
samwho keyboard logo

API Design: Optional Parameters

When we write functions, it's common to want to give the user options to suit a range of use-cases. There are good ways and bad ways of doing it, and this post is going to explore them.

# Guiding principles

API design is hard. A good API needs to be:

  1. Easy to change without breaking user code.
  2. Simple for the common use-cases.
  3. Easy to understand without having to read documentation.
  4. Helpful when things go wrong.

We'll use these principles to help us understand good and bad design decisions in our case study.

# The case study

Let's imagine we're writing an HTTP client library in Go.

package http

struct Response {
  StatusCode int
  Headers map[string]string
  Body string
}

func Get(url string) (Response, error) {
  // ...
}

The body of the function isn't super important. We're aiming to provide a simple API to the user, and what we've got so far is great but it suffers from a lack of flexibility. We can't set headers, we have no concept of timeouts, it'd be nice to be able to follow redirects easily. In short: we're missing a lot of vital functionality.

# Go: adding parameters the wrong way

Let's go about this the naive way.

func Get(url string, followRedirects bool, headers map[string]string) (Response, error) {
  // ...
}

This isn't the most pleasant function to call:

rsp, err := http.Get("http://example.com", false, nil)

If you come across that without knowing the function signature, you'll be wondering what the false and nil represent. If you have to call the function, you'll feel resentful having to pass parameters you don't care about. The common use-case gets more difficult the more parameters you need to pass.

It's a nightmare to change. Adding or removing parameters will break user code. Adding more functions to achieve the same thing but with different sets of parameters will get confusing.

This is, in short, a mess.

# Go: adding parameters a good way

What if we put the parameters in to a struct?

struct Options {
  FollowRedirects bool
  Headers map[string]string
}

func Get(url string, options Options) (Response, error) {
  // ...
}

rsp, err := Get("http://example.com", Options{
  FollowRedirects: true,
  Header: map[string]string{
    "Accept-Encoding": "gzip",
  },
})

# Pros

# Cons

# Go: adding parameters a better way

This one is a little more complex, but bear with it.

type options struct {
  followRedirects bool
  headers map[string]string
}

type Option = func(*options)

var (
  FollowRedirects = func(o *options) {
    o.followRedirects = true
  }

  Header = func(key, value string) func(*options) {
    return func(o *options) {
      o.headers[key] = value
    }
  }
)

func Get(url string, os ...Option) (Response, error) {
  o := options{}
  for _, option := range os {
    option(&o)
  }

  // ...
}

Some examples of using this:

rsp, err := http.Get("http://example.com")

If you want to specify parameters:

rsp, err := http.Get("http://example.com",
  http.FollowRedirects,
  http.Header("Accept-Encoding", "gzip"))

# Pros

# Cons

The pros outweigh the cons. The flexibility and clean API surface will pay for themselves, provided you keep check on what you're doing inside of Option functions.

This is my go-to for optional parameters in languages that don't have first-class support for them.

# "But what about other languages?"

You may be thinking: "but Java has method overloading, wouldn't that work perfectly for optional parameters?"

It's a good question, we can get around the need to specify a default Options struct when we don't want to change anything. Let's explore how this might look using method overloading in Java.

# The case study in Java

Here's the original case study again, but restated in Java.

public final class Http {
  public static final class Response {
    public final int statusCode;
    public final InputStream body;
    public final Map<String, String> headers;
  }

  public static Response get(String url) throws IOException {
    // ...
  }
}

Calling it would look something like this:

try {
  Response res = Http.get("https://example.com");
} catch (IOException e) {
  // ...
}

# Java: the method overloading approach (bad)

Let's now use method overloading to get around having to specify a default Options struct.

public final class Http {
  public static Response get(String url) throws IOException {
    return get(url, null);
  }

  public static Response get(String url, Map<String, String> headers) throws IOException {
    return get(url, headers, false);
  }

  public static Response get(String url, boolean followRedirects, Map<String, String> headers) throws IOException {
    // ...
  }
}

This is commonly referred to as a "telescopic function," because as you add new parameters the whole thing gets longer, like extending a telescope.

Here's an example of using it:

public final class Main {
  public static final void main(String... args) throws IOException {
    Response res;
    res = Http.get("https://example.com");
    res = Http.get("https://example.com", true);
    res = Http.get("https://example.com", true, Map.of("Accept-Encoding", "gzip"));
  }
}

# Pros

# Cons

This isn't an ideal solution. It improves on the previous, but isn't what I would recommend you use in your own code.

# Java: the Good Solution

public final class Http {
  private static final class Options {
    boolean followRedirects;
    Map<String, String> headers;

    Options() {
      this.followRedirects = false;
      this.headers = new HashMap<>();
    }
  }

  public static Consumer<Options> followRedirects() {
    return o -> o.followRedirects = true;
  }

  public static Consumer<Options> header(String key, String value) {
    return o -> o.headers.put(key, value);
  }

  public static Response get(String url, Consumer<Options>... os) throws IOException {
    Options options = new Options();
    for (Consumer<Options> o : os) {
      o.accept(options);
    }

    // ...
  }
}

And an example of using this API:

public final class Main {
  public static final void main(String... args) throws IOException {
    Response res;
    res = Http.get("https://example.com");
    res = Http.get("https://example.com", Http.followRedirects());
    res = Http.get("https://example.com", Http.followRedirects(), Http.header("Accept-Encoding", "gzip"));
  }
}

This has all of the pros of when we saw it in Go, and still manages to look nice in Java. It also gets around the problem we saw in the previous section of the user that may only want to specify one parameter.

While the above is nice, it's not what you're going to see in the wild when using Java. It's far more likely you'll see "builders."

# Java: the idiomatic solution using builders

public final class HttpClient {
  private final Map<String, String> headers;
  private final boolean followRedirects;

  private HttpClient(Map<String, String> headers, boolean followRedirects) {
    this.headers = headers;
    this.followRedirects = followRedirects;
  }

  public static final class Builder {
    private Map<String, String> headers = new HashMap<>();
    private boolean followRedirects = false;

    private Builder() {}

    public Builder withHeader(String key, String value) {
      this.headers.put(key, value);
      return this;
    }

    public Builder followRedirects() {
      this.followRedirects = true;
      return this;
    }

    public Client build() {
      return new Client(headers, followRedirects);
    }
  }

  public static Builder builder() {
    return new Builder();
  }

  public Response get(String url) throws IOException {
    // ...
  }
}

And an example of using it:

public final class Main {
  public static final void main(String... args) {
    HttpClient client =
      HttpClient.builder()
        .withHeader("Accept-Encoding", "gzip")
        .followRedirects()
        .build();

    Response res = client.get("https://example.com");
  }
}

This might feel like a bit of a departure from passing in optional parameters to a method. Creating new objects in Java is cheap and preferred over long method signatures.

# Pros

# Cons

This is the way I would recommend you support optional parameters in Java. It may feel like a lot of work, but there are libraries that can help reduce the boilerplate. Also IDEs like IntelliJ have tools to help you generate most of the boring stuff.

# Why is this more idiomatic than the other method?

Builders predate lambdas in Java.

# "But what about languages that support optional parameters?"

Another great question. In these cases, it makes the most sense to use what the language offers you.

# Python

class Http:
  @staticmethod
  def get(url, follow_redirects=False, headers=None):
    pass

res = Http.get("https://example.com")
res = Http.get("https://example.com", follow_redirects=True)
res = Http.get("https://example.com", headers={"Accept-Encoding": "gzip"})

# Ruby

class Http
  def self.get(string, follow_redirects: false, headers: nil)
  end
end

res = Http.get("https://example.com")
res = Http.get("https://example.com", headers: {"Accept-Encoding": "gzip"})
res = Http.get("https://example.com", follow_redirects: true, headers: {"Accept-Encoding": "gzip"})

# Pros

# Cons

# Cautionary words for dynamic languages

# Beware **kwargs

Both Ruby and Python have the ability to "glob" their named arguments in to dictionaries:

class Http
  def self.get(string, **kwargs)
    # kwargs[:headers]
    # kwargs[:follow_redirects]
  end
end

res = Http.get("https://example.com")
res = Http.get("https://example.com", headers: {"Accept-Encoding": "gzip"})
res = Http.get("https://example.com", follow_redirects: true, headers: {"Accept-Encoding": "gzip"})
class Http:
  @staticmethod
  def get(url, **kwargs):
    # kwargs["headers"]
    # kwargs["follow_redirects"]
    pass

res = Http.get("https://example.com")
res = Http.get("https://example.com", follow_redirects=True)
res = Http.get("https://example.com", headers={"Accept-Encoding": "gzip"})

I would recommend against using these. Being explicit makes it more clear to the reader what they can pass in, and it also gives you the author an opportunity to set sensible defaults.

# Beware mutable default values

You may have wanted to write this a few sections ago:

class Http:
  @staticmethod
  def get(url, follow_redirects=False, headers={}):
    pass

Note the difference in headers={} from above, where we wrote headers=None in Python and headers=nil in Ruby.

The problem with this in Python is that the empty dictionary isn't created every time the method gets called. It's created once when the class is defined, and so is shared between invocations. This isn't true in Ruby.

Here's an example:

class Http:
  @staticmethod
  def get(url, follow_redirects=False, headers={}):
    if "counter" not in headers:
        headers["counter"] = 0
    headers["counter"] += 1
    print(headers)

Http.get("https://example.com")
Http.get("https://example.com", headers={"counter": 100})
Http.get("https://example.com")

This outputs:

{'counter': 1}
{'counter': 101}
{'counter': 2}

Equivalent in Ruby:

class Http
  def self.get(url, follow_redirects: false, headers: {})
    headers[:counter] ||= 0
    headers[:counter] += 1
    puts headers
  end
end

Http.get("https://example.com")
Http.get("https://example.com", headers: { counter: 100 })
Http.get("https://example.com")

Outputs:

{:counter=>1}
{:counter=>101}
{:counter=>1}

Even though the problem isn't present in Ruby, I like to avoid it regardless.

# Conclusion

I hope this post has given you some food for thought, and shown you some nice techniques you hadn't considered and why they're nice.

If you know other methods to achieve this goal that I haven't explored here, I'd love to read about it.

powered by buttondown