DEV Community

Luan Nico
Luan Nico

Posted on

Five features I wish Dart had

The Dart programming language has recently undergone a major version update, 2.12, which brought sound null-safety to the ecosystem. With the recent announcement of Flutter 2.0, supporting web and desktop alongside mobile, this new version of the language has been gaining a lot of traction as project maintainers such as myself start or continue to migrate their packages to support it.

And the first thing I have to say about it is that it's great. I already loved Dart before, and this was definitely one of the most important things on the roadmap. I particularly love typed languages; after many years working with Javascript, which I also really like, I came to the realization that the extra cost of typing pays off (and the recent popularity of Typescript helps to substantiate this point). And Dart has built-in type safety, which is great. But after a lot of time using Kotlin and Swift, that not only offer types but null-safe types, I realized, on the same vein, how important this feature is for writing great, resilient applications, help developers avoid mistakes, code quickly and safely.

And I realize one big burden (on the other side) of types is the extra verbosity it inherently introduced. For example, whenever I coded in Java in the past I was very happy with the security and stableness types provided but it just felt like a lot of extra work compared to JS. But it doesn't have to be that much, and Kotlin, Swift, and the newer versions of Java showed me that, if you bring (1) yes, types but also (2) a lot of (sometimes small) language improvements, syntax sugars, helping features, you can get the benefits of types with very little overhead. And that is where I think they shine. Now, the same thing is true for null safety. Imagine if we didn't have the Elvis operator in Dart, for example. Would be much more painful to write proper null safe code, up to a point it starts to compete with the benefits in the first place. Granted the pros and cons are in different areas (ease of coding vs stability and fewer bugs), and that is why this is ultimately a matter of opinion. But the better the language is at providing the right features that allow developers to write pretty, understandable code easily and quickly (like by adding the Elvis/null aware operators), the more attractive it becomes to be on that side of the scale.

And, again, I really like Dart and all it has to offer, and I am totally in favor of type and null safety being on the language. But having spent some time migrating packages and apps, I believe there are a few things that would make everyone life's much better. These are relatively small, non-disruptive, non-breaking, incremental changes that are not necessarily directly related to null safety but that, especially if you worked with languages like Kotlin and Swift before (my main sources of inspiration), you might miss when migrating code. The fact that null safety inevitably adds some extra level of boilerplate helped expose these needs more clearly to me, in a way that I hadn't necessarily craved them before.

My goal with this article is far from bashing Dart (I think I made it more than clear how I feel about it!) or to mourn the unchangeable fate of the universe, or anything like that. In fact, I currently use Dart in my free time purely because I like it. But rather my goal is for this to be a proposal, both for the community and (hopefully!) for the Dart team, a set of ideas that can be picked from, studied, mix and matched. A showcase of, in my opinion, great things other languages have to offer (for those unfamiliar). A collection of wishes and anecdotes.

And I should try to finally get to my clickbait-y title quickly because I've been vaguely rambling for a while putting a lot of trust in your patience with my digressions. And I swear it's coming and they are going to be very concrete, specific things (now I am just building anticipation). But the last thing I wanted to mention before getting right into it, and there is no way I can omit this reference, is the library Dartlin.

Full disclaimer, Dartlin was created by my friend Jochum, and I have contributed to it after I learned of its existence (since, as you can imagine by this article, I loved the idea). It is basically a Kotlin inspired set of features, built in the library level (using for example things like Extension Functions) that aim to bring great features from Kotlin to Dart. I will mention on some of these features how the proposed effect could be accomplished with Dartlin. However, no matter how clever Jochum gets at the library level to replicate some features, it can never be as good, as simple, easy to use as a native feature. Also bear in mind adding extra functions, lambdas, et cetera to emulate non-native concepts has a performance overhead, so I definitely endorse Dartlin but I recommend making sure it's not causing an application critical bottleneck (and there could be a whole other article about what really matters for performance of a given app versus premature optimizations).

Now, with all that out of the way, finally, here are 5 features I wish Dart had (natively), in no particular order:

1. Kotlin's let

A very famous construct in Kotlin is the let function. Basically, it is one of the more famous so-called Scope Functions. And it's particularly useful for null-safe code. The implementation is deceptively simple; it's an extension function on Any that invokes a lambda where the first argument is this callee. Seems useless? Well, one simple use case where it has a small role could be this:

 string.split(",").let { Point(it[0], it[1]) }
Enter fullscreen mode Exit fullscreen mode

Instead of having to name a variable with a meaningless identifier, this one-liner is easy to read and a quite nice use of let. But where it shines, as I mention, is in the NS world, when paired with ?.. But let's get there through an example. Imagine you are converting Dart function on the codebase to null-safe Dart:

int addTwo(int arg) {
    if (arg == null) return null;
    return arg + 2;
}
Enter fullscreen mode Exit fullscreen mode

Seems like a totally reasonable function, but actually, in the null-safe world, it's a bad pattern. Because once you convert it:

int? addTwo(int? arg) {
    if (arg == null) return null;
    return arg + 2;
}
Enter fullscreen mode Exit fullscreen mode

You see the issue. This function is a black hole! It's destroying information. Imagine you have a totally legit, verified, safe and sound int variable. Non-null certified.

  final totallySafe = 1;
  final result = addTwo(totallySafe); // oops, result is `int?`
Enter fullscreen mode Exit fullscreen mode

If the argument is already a nullable variable there is no issue, but if your parameter is non-null, that precious information is lost forever. So normally, you should not take a nullable parameter in a function if (1) the output of a null parameter is always null and (2) the only case where null is returned is with the null parameter. You are much better off having:

int addTwo(int arg) {
    return arg + 2;
}
Enter fullscreen mode Exit fullscreen mode

And let the callee deal with its nulls. Witch is fine if the variable you have is non-null. But on the other hand,

  final int? a = maybeGetInt();
  final int? result = a != null ? addTwo(a) : null;
Enter fullscreen mode Exit fullscreen mode

Is a bit of a mouthful. So imagine you could do:

  final int? a = maybeGetInt();
  final int? result = a?.let(addTwo);
Enter fullscreen mode Exit fullscreen mode

Cool huh? When you think about it it's an extension of ?.; where ?. allows you to optionally call a property of an object, ?.let allows you to optionally call a function with an object. You can also use it with a lambda:

  final bread = wheat?.let((it) => mill.process(it));
Enter fullscreen mode Exit fullscreen mode

Basically, you can look at it as a map operation if you look at the optional as a monad. And map could possibly be a better name in the Dart world, though it would conflict with the List.map.

But the best part of this list item is that let, as I first said, is just an extension function. So this is one case where Dartlin is able to simply add it. No caveats, no hacks. It will work just like the examples! Just drop in the dependency on your pubspec.yaml and import the control flow sub library.

2. Swift's if let

Dart (since the beginning) has had type promotion (also known as smart cast in other languages). In simple terms, if you assert a type to be of a more specific subtype, you don't need to cast it in that context. For example:

Animal animal = getAnimal();
if (animal is Dog) {
    animal.bark(); // smart cast to Dog
}
Enter fullscreen mode Exit fullscreen mode

Which is great when you think about null-safety is just an extension on the type system. Dog? is just a supertype of Dog. So, similarly, when you do:

Dog? dog = maybeGetDog();
if (dog != null) { // really this is just check for `dog is Dog`
    dog.bark(); // smart cast to Dog
}
Enter fullscreen mode Exit fullscreen mode

However, you can't be too clever with it. Smart-cast only works for local variables and parameters. It just cannot work for fields, because:

  • fields can be changed by other functions called in your context
  • fields can potentially be changed by other threads (not currently a problem in Dart because it's single-threaded)
  • fields can be functions with custom getters that can return arbitrary results

There are many threads and issues on GitHub about this, asking for smart cast to be, well, smarter. But there is no way to do it safely. It's just too complex for the compiler to check these things. Kotlin also does not provide smart cast for fields. And I don't think this feature is worth having an unsound type system (going back to my initial rambling). But we don't need to. Swift offers a very elegant solution to this problem, which I think is just cleaner than smart casting altogether: if let.

In Swift you can do:

if let dog = animal as Dog {
    dog.bark()
}
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can use the same variable name (for, say, a null check):

if let animal = animal { // short for `let animal = animal as Animal`
    animal.live() // animal is not nil here!
}
Enter fullscreen mode Exit fullscreen mode

Basically, you don't need smart cast if you can easily declare a new local variable. Instead of doing:

// field: var Animal animal;
final animal = animal; // I am just copying a field to a local variable
if (animal is Dog) {
    animal.bark();
}
Enter fullscreen mode Exit fullscreen mode

We could do something like:

// field: var Animal animal;
if (final dog = animal as Dog) {
    dog.bark();
}
Enter fullscreen mode Exit fullscreen mode

or

// field: var Animal? animal;
if (final animal = animal) { // basically omitting the `as Animal` part for brevity since it's just a null check
    animal.bark();
}
Enter fullscreen mode Exit fullscreen mode

That is clear, concise, and doesn't do any magic; it's explicit that it's creating a copy because of the variable declaration syntax, which also can save on performance if it's an expensive getter. It is also perfectly clear the scope in which the copy lives. No need to jeopardize the type safety with clever heuristics that work 99% of the time to introduce smart cast to fields!

Note as well that the for statement already allows you to declare new variables that only exist within its block, so this is quite intuitive (despite uncommon in other languages). This doesn't introduce any new keywords, and it's just an extension to the capabilities of the if statement.

I'm not suggesting smart casting to be removed altogether by the way, of course it can be left as an even easier alternative for local variables and parameters only. In fact, let me abuse this list item to share a small nit that I have related to smart cast: for some reason, it doesn't work for this. If you think about it, just like a local variable or parameter, this can never change types during the execution of a method. You might be thinking that is useless because this can never be null (it can, in a very specific scenario that I leave to you as a puzzle to figure out). But where this would really come in handy would be for mixins; imagine being able to do:

if (this is MixinA) {
    this.doMixinAThing(); // glamorous!
}
Enter fullscreen mode Exit fullscreen mode

Of course the addition of if let would mitigate this pain, but still it's just one tiny nit with the existing type promotion.

3. Kotlin's ?: hidden power

The famous "Elvis" operator is present in many languages in several different forms, and nowadays it's basically an umbrella term for "null-aware operators using question marks". There is the almost omnipresent ?., to optionally access properties, and many others, but one of the most iconic has to be the coalesce operator, that I like to read as "or else", which is ?? on Dart and Swift and ?: on Kotlin. In very simple terms, it's equivalent to "left operand OR ELSE right operand (if left operand is null)". For example, in Dart:

String? a = null;
print(a ?? "foo"); // will print foo, similar to (a != null ? a : "foo")
Enter fullscreen mode Exit fullscreen mode

Simple and effective. However, in Kotlin, the ?: has a special feature that I think no other language has (that I know of). More eagle-eyed observers might have noticed that I didn't mentioned Swift's guard let on the previous list item. That's because I believe Kotlin has it figured out. We don't need to introduce a new keyword guard if we allow the right operand of ?: to throw or return. How does it work? You can do something like this:

// assuming class Animal(val tail: Tail?)

fun process(animal: Animal): Bool {
    val tail = animal.tail ?: return false

    ...
}
Enter fullscreen mode Exit fullscreen mode

It's so sweet that if you know the regular ?:/?? operators you can figure out what it does. And you can throw too! Imagine doing this on dart:

    var foo = parseFoo(arg) ?? throw 'Invalid foo $arg';
Enter fullscreen mode Exit fullscreen mode

There are so many possibilities! And this is just a small extension over the existing coalesce operator that is very intuitive if you already know its regular usage.

4. Null-safe methods on collections

As I said I have been converting a lot of packages to null-safety recently, and this issue got me puzzled for a while; a package had the following non-safe code:

  final element = list.firstWhere(test, orElse: () => null);
Enter fullscreen mode Exit fullscreen mode

Seems like a pretty reasonable code, regardless of safety. But it breaks down when you notice that the new null-safe signature of firstWhere returns T (and not T?). Which makes sense if you don't want to add a lot of ! to your code; sometimes you just know the element will be there, and otherwise throwing an error is ok. But sometimes you expect it might not be there. And I could not for the life of me find a simple small one-liner to do what I wanted in the first place!

That is why, especially now with NS, we need new collection methods. If you look at Swift and Kotlin for inspiration, we can find some very useful additions (in particular in Kotlin).

For this example, take a look at Swift's first:

func first(where: (Self.Element) -> Bool) -> Self.Element?
Enter fullscreen mode Exit fullscreen mode

It just returns nil instead of throwing. If we had that on Dart, plus our improved ?? operator, you could do:

  list.firstWhere(test) ?? orElse;
  list.firstWhere(test) ?? throw 'error';
Enter fullscreen mode Exit fullscreen mode

Which just covers both use cases with a single function. But better not to change the definition and behavior of the existing Dart functions to avoid confusion. So we can look at Kotlin instead. firstWhere is just called first (with optional lambda) and it does throw. But there is a firstOrNull version, that returns null if not find. We could have that with an optional lambda:

T? firstOrNull([bool Function(T)? test]) { ... }
Enter fullscreen mode Exit fullscreen mode

Now we have all 4 options! The existing first and firstWhere, and the proposed firstOrNull to cover both other cases. Similarly, we could have a singleOrNull function with optional test lambda, identical to Kotlin's.

Another one that is extremely handy is getOrNull: just takes an index and returns null if out-of-bounds (instead of throwing). Again, on the NS land, returning null, especially when you pair it with NS-operators, is a blessing.

Swift's maxBy/minBy can also be handy, as it's a bit more clearer and less verbose than trying to replicate with reduce. And particularly useful for safety is the compactMap (or mapNotNull on Kotlin) that maps and filters null values in one batch, meaning that it can chain to T instead of T? (if you just do filter((e) => e != null) the compiler doesn't know the subsequent sequence/stream is NS).

The great news about this one item list as well is that these are just proposals to add functions to the SDK, which, as we already learned, can be easily done with extension functions. That is where Dartlin comes to rescue again, providing all the aforementioned functions (with the Kotlin nomenclature) and even more (like the handy associate or groupBy). Another useful bit of code can be found on my ordered_set package's Comparing class, to be used alongside functions that take sorting orders. So you can get all these goodies right now, but would be even better to have them standardized into the SDK.

5. Statements as expressions

This one is pretty straightforward. Unlike Swift, Dart, or JS, in a few languages, like Kotlin and Rust, if and switch statements are expressions. For me, that is just a bonus that brings no hindrance. It's like adding sauce to your pasta. And it's optional of course. Use it if you want.

Now, you are definitely thinking right now, we have had the ternary operator (a ? b : c) since the primeval C language. And yes, the reason why Kotlin's if in particular is an expression is because Kotlin doesn't have the ternary operator. And they have their reasons, which you can read on the discussion thread about it. So that is it? List item 5 is a hoax? Not so fast.

First of all, I like the ternary. I'm not saying, at all, to remove it from Dart. But it has a few small issues that could be complemented by having both. When starting with Kotlin most people miss the ternary, but once you realize the language has if statements as expressions, you barely miss it anymore. What's the difference?

    final a = isConditionTrue ? 1 : 2;
    final a = if (isConditionTrue) 1 else 2;
Enter fullscreen mode Exit fullscreen mode

In that case pretty much nothing (I don't think small preferences of expressiveness vs succinctness are enough to propose Dart to change). Where the if gets the better of the regular ternary though, is when the expression is not so short. If it doesn't fit in a line. Imagine this code:

int i;
if (isConditionTrue) {
    // several lines
    i = result;
} else {
    // several lines
    i = result;
}
Enter fullscreen mode Exit fullscreen mode

Because we don't have if-expressions, I am forced to declare that variable i as var instead of final, even though I don't plan on modifying it. I could get all the benefits from the compiler by marking it as final if the compiler was able to understand this:

final i = if (isConditionTrue) {
    // ...
} else {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

And yes, that can be done with the ternary, but if the bodies of one or both of the paths are big, you can get multi-line (or worse, nested) ternaries, which are particularly hard to understand and format in a pretty way. You could extract the blocks to functions, but sometimes to do that you are forced to pick very long names because those blocks weren't easily describable (which is why they weren't functions, to begin with). And with long names on the 3 operators (plus the output variable name in this case), you can easily get past your preferred line length even with the succinct 2-character operator.

And that is not the only reason Kotlin opted for statements-as-expressions instead of ternary; while the ternary can replace the if statement, Kotlin has an extremely powerful when statement, similar to the famous switch. And even though Dart's switch is fairly inflexible, being able to do this:

final result = switch(enum) {
    VALUE -> 1
    VALUE -> 2
    VALUE -> 3
}
Enter fullscreen mode Exit fullscreen mode

That is a very type-safe and concise way of mapping values, which would be just terrible to write with nested ternaries (and not type-safe when using a Map, because switches guarantee exhaustion).

Now, I would love to see more options for Dart's switch, so it could be as powerful as Kotlin's or even Haskell's case. But that is a much bigger change, that would require a lot of thought and profound decisions, since other than enums, who would more benefit from compile-time guaranteed exhaustion and pattern matching could be things like sealed classes or union types. And I think that is definitely something worth exploring, but not in this article; I am focusing on small, (hopefully) not very controversial changes that are for the better, in my opinion.

And finally the last statement that I would like to mention is the try. Having try as expressions is incredibly handy and cannot be replaced by any other feature Dart currently has. It leads to much cleaner and readable code, for example:

  final variable = try {
      parseInput(arg);
  } catch (ex) {
      logger.log(ex, 'Failed to parse input $arg');
      getDefaultValue();
  }
Enter fullscreen mode Exit fullscreen mode

Again, preventing you from having non-final variables that you then have to guess if they have been initialized or not -- have the compiler deal with that for you.

This list item is a language-level proposal, not an API change, so you would think there would be no package dependency mention here. Well, turns out Dartlin provides if, when, and try as expressions via functions. I won't delve much into how it works but if you are curious (I know you are), check out the readme for more details. This is also possibly the biggest change of the bunch because it introduces this concept of implicitly returning the last expression on the block of these statements, but I believe it's quite intuitive to pick up and would make a good addition as well.


And with that, I conclude this article. I mentioned 5 (relatively) small and (hopefully) simple features that I wish Dart had. Features that are optional, backwards compatible, and, IMHO, don't stray too further from what Dart already has to offer. Some can be emulated with different levels of fidelity using external libraries, like Dartlin, or clever workarounds, but in general, I believe adding these (or a subset, at least) to the language should be seriously considered, especially in the new Null-Safe world. Finally, I would like to thank all the people on the Dart team that helped make NS a reality and all the awesome package maintainers and contributors that are tirelessly converting their and other people's code to make sure everyone in the community has an even better experience with this awesome language. And I hope you liked this short read and maybe even learned something new and cool about the sprawling, creative world of programming languages.

Top comments (2)

Collapse
 
lucianojung profile image
Luciano Jung

Hey, i found this github repo of a dart based programming language candy a few weeks ago:
github.com/candy-lang/candy
They want to implement several features you meantioned in your Post and get inspiration from Kotlion, Dart and Rust.

Collapse
 
luanpotter profile image
Luan Nico

I wasn't aware of Candy, thanks for sharing! It looks really cool and promising from the docs, I will definitely be trying it out :)