Designing Intuitive Dart APIs: Null, Default, and Provided Arguments

Anthony Oleinik
Level Up Coding
Published in
6 min readJan 17, 2023

--

Before we start: this is a reduced form example. Some of the examples may have parts of the Widget boilerplate cut out, and we use a simple example that doesn’t make much sense in pratice. At the end, I’ll give a real example of a real scenario in the Flutter codebase.

Scenario: we’re writing a Flutter app. We want to make a custom widget, PlatformName . The PlatformName widget should be a simple wrapper over a Text widget: the text inside the Text widget should just show the platform name. PlatformName should take a parameter, platformName Where the user can override the default platform name. here are the specs for the widget:

  • If the consumer of the widget’s API (we’ll refer to them as the ‘user’ of the widget, not to be confused with the ‘end user’ of the app) doesn’t provide an override (just lets platformName be default), we should default to the defaultTargetPlatform variable, which knows what platform we’re on. Here’s what that looks like:

https://gist.github.com/6ee01f9f99bb10ca71e4200e74fa2c30

  • If the user does provide an override, we should utilize the override: just display what the user inputs.
  • As a final caveat, there are cases in our fictitious Flutter universe where we don’t know the platform. In this case, we should say “Unknown”. This is represented by the user passing in null .

This is perhaps the most intuitive API. null means exactly what null should mean: the lack of a known value. No argument means exactly what no argument should mean: we didn’t fill out the field, so give us the default behavior.

Everything makes sense here. But we do have a glaring problem: how the hell do you implement this?

The Problem with the Implementation

If you’re looking at the previous example and thinking that it’s trivial to implement, I implore you to try and do it. (If you figure it out on the first go: you’re much smarter than me — with great power comes great responsibility. Use your knowledge wisely.)

The problem with the implementation is that the “null” case and the “default” case butt heads. Here’s how I often see default cases implemented:

But the problem here is that we lose the differentiation between null and default. by treating the null case as the default case, we lose the functionality of having null mean “We don’t know the platform!”. A user will be running this version of Flutter on their Chevy Equinox 2019 — since this architecture isn’t handled in their apps business logic, they want it to display "Unknown"; instead, the fallback to defaultTargetPlatform is triggered and the app displays "Linux". Therein lies the bug.

We cannot use Dart’s default parameters here (in the way that is intuitive): this is because default parameters cannot be non-constant, and defaultTargetPlatform is non-constant.

If this works, we would have all the required functionality: if the user passes in null, then we switch the member variable to "Unknown" . The problem is that dart gives us an error: “The default value of an optional parameter must be constant”.

Here are some other solutions that are sub-optimal before I explain my favorite solution, with the least amount of trade-offs:

  • Have a second constructor variable that says platformIsUnknown: This works but we now have two sources of truth and we grow our cases by 2x. We also introduce illegal runtime states: platformIsUnknown == false && targetPlatform == null is undefined behavior, but is modeled at the type level (i.e. compiler says “LGTM!”).
  • Forcing the user to input “Unkown” or default behavior: this solution just sucks. You’re throwing your hands in the air and pushing complexity onto your user!
  • Multiple constructors: This could work, but in my experience, it makes for very non-DRY code. It works for the case where there is only one configuration parameter, but once you increase it to more, you end up with an exponentially growing number of constructors. There are other reasons to avoid this pattern and most of them are pushing complexity onto the end user.

The (Two) Solutions

The crux of our problem is that it’s difficult to differentiate between a default variable and a null variable. The default cannot be null since it would be impossible to tell if the user wanted it to be null, or left it default, and it cannot be it’s final value since the final value is non-constant. How do we get the intended behavior?

How can we work around the two truths in the previous paragraph? Which one can we bend, and work around? We do have something of a conditional in the previous statement — “it cannot be it’s final value since the final value is non-constant”. We actually don’t care if the default value is it’s final value — we only care that we can tell that it is its default value.

Here is one such solution, as well as test cases. This works because the value gets “tied down” to the sentinel value, and then we can verifiably know that if the default value is there, the user left it default (hold the case where, by coincidence, the user wants it to display the same text as the sentinel value. But that is such a one in a billion case that I don’t think it’s worth worrying about.)

Is there another solution to this issue, though? there is. If the value that we want to differentiate the three states on is a more complex object (consider a configuration object, where we can completely disable the feature by passing in null, we can make a custom configuration by passing in a custom configuration, and where we can let it default to the default configuration by doing neither of these.) then we can actually utilize a pattern that I previously dismissed: a disabled flag.

Instead of having the disabled flag be on the consumer object, though, we can have it be on the configuration object itself.

This increases readability: instead of some magic flag to disable functionality, we explicitly sayConfiguration.disabledConfig . The null looks nice, but explictly saying “use the disabled config” looks even nicer.

We gain some readability but lose some discoverablilty. It may not be clear to the programmer that to disable a configuration, they have to find some factory constructor. Passing in null is much more intuitive — but the intention of null is not as clear as explicitly saying “disable XYZ”. Another huge gain here is that the functionality is far more constrained, and controled by the developer; if they add more features to the consumer object, it can be expressed through its configuration object without worry of backwards compatibility (“default” behavior remains default, and “disabled” behavior means no behavior at all). In contrast, strings are just strings, and thus, any additional functionality has to be added onto the consumer object (our PlatformName widget), and the sentinel flag pattern must be repeated. Beauty is in the eye of the beholder on this one.

Note that even if the flutter team uses this pattern every once in a while, it’s not suitable to say that this is the de facto pattern. The best pattern is the one that prioritizes:

  1. The consumer of the API first: these are your end users and their happiness is what keeps projects ticking. Sacrificing some internal readability may be worth it in the long run if it cleans up the external facing API.
  2. The writer of the codebase second: it is important to factor in maintinance cost. freezed facilitates this pattern in a way that is far too complex for a human. It requires piping through factory constructors, sentinel objects, and multiple copies of constructors to work. Freezed can do this becuase the code is auto generated and rarely ever looked at by a human; the complexity internally facilitates a huge gain in the external API.

That means that it could be either of the proposed solutions, or maybe ones that I dismissed or didn’t mention. Software engineering and design patterns are about adding tools to your arsenal to most effectively solve problems.

That’s it! Hopefully this helps you think a little bit about how your users think, and designing API’s that last a lifetime. If I missed any solutions, or something wasn’t clear, please leave a comment! I’ll try to get to it ASAP.

Level Up Coding

Thanks for being a part of our community! Before you go:

🚀👉 Join the Level Up talent collective and find an amazing job

--

--