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:
- Easy to change without breaking user code.
- Simple for the common use-cases.
- Easy to understand without having to read documentation.
- 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
- Easy to add new parameters.
- Easy for a casual reader to know what's going on.
- User only has to set the parameters they care about.
Cons
- If you don't want to set any parameters, you still have to pass in an empty struct. The common use-case is still more difficult than it needs to be.
- If you want to set lots of parameters, it can get unwieldy.
- In Go, it's hard to distinguish between something that's unset or has been specifically set to a zero-value. Other languages have the same problem with null values.
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
- Easy to add new parameters.
- Easy to deprecate old parameters by outputting a warning when they're used.
- Easy for a casual reader to know what's going on.
- Functions can do anything, so parameters could go beyond specifying values. For example, you could load configuration from disk.
Cons
- If the API author isn't careful, they can create parameters that interfere with each other or do unsafe things like change global state.
- A little more complicated for the API author to set up than plain old function arguments.
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
- No need for the user to worry about specifying all parameters. The common use-case is simple.
- Easy to add new parameters.
Cons
- A lot of boilerplate to set up.
- While the common use-case is simple, if you want to tweak one parameter you may have to specify loads of other ones you don't care about.
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
- Can add new parameters without breaking user code.
- Easy for a casual reader to know what's going on.
- Function completion on the builder tells you what parameters are available.
Cons
- Lots and lots of boilerplate for the library author.
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
- Easy to add new parameters.
- Easy for a casual reader to know what's going on.
- It has little to no boilerplate for the API author.
Cons
- Some oddities around setting default parameter values that we'll touch on later.
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.
Top comments (11)
Hey Sam, really digging this!
Just a quick tip. I noticed that you have another post in this series:
API Design: Errors
Sam Rose ・ Apr 3 '19 ・ 4 min read
You could also edit both this post and the other to include "series: whatever name you'd like for your series" in the front matter. This'll connect your two posts with a cool little dot scroll option at the top of each post that lets the reader easily flip between posts in the series.
Umm yeah, no idea what this option is actually called, so chose to call it a "dot scroll" ... but anyway, it looks like this:
... in my posts here & here.
Not a must-do, just a nice-to-have in case you wanna!
Side note, not sure if you've ever read Kin Lane (apievangelist.com/) but I'm thinking you might dig some of his stuff. He talks about APIs from a number of angles - design, politics, you name it... though he's currently on hiatus. Still, he's extremely prolific and has a backlog of entries that go on for ages. Definitely worth a read.
What about a more SOLID solution that is a little bit more compromising
Maybe because I like to make the
Get
function a little bit cleaner and not having to deal with handling ConfigurationNice!
While there's optional parameter(which might be risky if we put default values in some edge cases.). There's named parameters. which is more readable to the user. think of it as a superset of optional parameters where you don't have to initialize a value. some might think that this can be a verbose. some which might be helpful.
I didn't even know what named/optional parameters were until I used flutter.
Named parameters can be used in methods/functions, and class properties as well.
For sure! I talk about these in the second half of the post. 😀
From what I've understood from optional parameters is that, optional parameters have default values, while named parameters don't need. but in Dart, when declaring a named parameters/arguments, they use
@required
annotation that a specific argument should have a value declared when using it. Great Article by the way. Sorry if I differentiate optional and named parameters. This was something Dart clarifies me of using it when developing flutter apps.Really good article, thank you Sam!
Thank you so much! I'm glad you enjoyed it. If you don't mind, could you spare a few minutes to tell me things you like and didn't like? I'm looking to level up my writing. :)
ahha nothing I didn't like, the builder in Java is very verbose :D
BTW a little trick by more recent Python versions. You explained the problem with optional parameters, because you can do things like this:
And for a reader it's not easy to know if
3
is referred to either parameter, or at least is not explicit at all which makes life complicated with many arguments to specify.Since recently you can do this:
The compiler now forces you to specify the name of each optional argument
Oh that's super nice, I had no idea that was possible!
Yeah, it can also become a catch all for all unnamed optional parameters.