Generics in Dart, or, Why a JavaScript programmer should care about types

(This is Part 5 in an ongoing series on Dart. Check out Part 4, Maps and hashes in Dart.)

Intro

Dart is, by most accounts, designed to be a familiar programming language. One of Dart's most interesting (and daring features for a mainstream language) is optional typing. Optional static types allow you to choose when to add the static types.

Dart has optional typing because it's a web programming language, and it's designed to scale from interactive scripts (which don't necessarily need lots of declared types) to complex, large web applications (which probably do need lots of declared types, which serve as a documentation annotation for tools and humans.)

An example of untyped Dart code:

smush(a, b) => a + b;

main() {
  var msg = "hello";
  var rcpt = "world";
  print( smush(msg, rcpt) );
}
> helloworld

As you can see in the example above, the var keyword is used to denote a variable. var is synonymous with the Dynamic, or "unknown", type. The above code is is not statically typed, yet it runs just fine.

Note the smush(a, b) method, which smushes two objects together and returns the result. Looking at this method signature, and even its definition, it's not clear exactly what that intent is or what kind of objects it works with. The original programmer's intent isn't clear, which is a problem if the development team is large or if the application itself is complex.

Nothing is stopping a developer from passing in two numbers to smush, as the example below illustrates:

smush(a, b) => a + b;

main() {
  var x = 3;
  var y = 4;
  print( smush(x, y) );
}
> 7 // technically true, but is this what we wanted to allow?

In some sense, this is the beauty of dynamic scripting languages. However, Dart is designed to support large, complex apps as well, and when you add hundreds of classes, thousands of functions, and tens of developers, this kind of ambiguity can be costly.

The typical JavaScript developer would probably first try to solve this problem by just remembering what the intent of smush is, but this doesn't scale. Second, the function would be renamed to something more clear. Third, comments would probably be added, with a note like "this methods takes two strings and concatenates them." I reckon the combination of a renamed function name and descriptive comments would get you pretty far, but they still don't express your intent to the program.

But we can do better than longer function names and more comments, for we want to say less and do more! We want our code to be our comments, and have our systems work for us and understand us!

We must demand a better solution. We must demand that our tools scale to meet our complex app needs. We must demand that our language allow us to express our intent tersely and with minimal friction!

Enter types!

Luckily, Dart gives us a safety net as we scale up our program: optional static types. By adding in simple annotations to our program code, we can express certain expectations and assertions in a way that both a human and the machine can quickly and easily grok. Once our system understands these type assertions, a whole new world of early error detection is now available. Every error caught in development is one less error caught by our users.

Let's declare that the smush method is expecting Strings:

smush(String a, String b) => a + b;

main() {
  var x = 3;
  var y = 4;
  print( smush(x, y) ); // in Checked Mode, this results in a Failed type check
}

When two numbers are passed to smush, and the program is run in Checked Mode (aka "type assertions turned on"), the system will halt execution with a "Failed type check" error.

This is awesome! Our tools caught an error for us, simply because we added some short annotations. Our tools are now working for us.

By simply adding the type declarations to the smush signature, the program understands the developer's intent and expectations and can check them for us. No comment has that power, and even a descriptive and clear function name doesn't have that power.

Think of those type annotations as assertions and documentation, all rolled up in one.

We can go further and give types to our variables x and y. This has the added benefit of generating warnings with or without Checked Mode. For example:

smush(String a, String b) {
  return a + b;
}

main() {
  num x = 3;  // note the type
  num y = 4;
  print( smush(x, y) ); // two warnings are generated here, re: type mismatch!
}
> 7 // yet the program still runs! types don't affect the program semantics

Giving type annotations to x and y, and keeping the type annotations in the smush method, allows our program to detect and warn us about this type mismatch.

This is super awesome! We don't even need to run in Checked Mode for our program to let us know something is afoot. All we had to do was add an annotation that was probably shorter than any comment you would have added to clarify just what x and y were supposed to be.

Optional types give us the powerful, yet terse, expressiveness when we need them for larger, more complex web apps, and we can omit them for our simple one page scripts. Or heck, type everything and catch nearly all type warnings early!

Yo dawg, I heard you like types, so I added types to your collections, so you can put Strings in your Lists of Strings

We've shown the helpfulness and expressiveness of types for variable declarations and function signatures. Let's take it one step farther and add another type annotation which can further tell your program, and your fellow developers, what your intentions are (and catch errors early!)

As covered in previous posts, Dart has support for collections such as Lists (arrays) and Maps (hashes). Lists store ordered collections of objects that can be retrieved via an index, and Maps store key/value pairs.

A simple example of a List:

main() {
  var digits = [0,1,2,3];
}

Adding a new element to a List is also simple:

main() {
  var digits = [0,1,2,3];
  digits.add(4);
}

However, even though I'm clearly expressing a List of numbers, the following code will still execute:

main() {
  var digits = [0,1,2,3];
  digits.add("four"); // executes, but is this what we want?
}

A couple of options spring to mind. We could rename digits to digitsOfNumbers, but that's verbose. Worst of all, not matter what we rename the variable to, we still can't express our intent to the program.

We must demand better! We must be able to tell our program that collections of things only hold certain things. We must demand that our program will warn us if an object of a different type is being added to a collection. We must demand that our programs catch errors early and help us, not hinder us!

Enter generics!

Think of generics as type annotations for your collections. With generics, you can tell your program, and other developers sharing the code, that a List of numbers is, well, actually a List of numbers.

Check it out:

main() {
  var digits = <num>[0,1,2,3];
  digits.add("four"); // BOOM, Failed type check. Program stops.
}

Thanks to the <num> annotation, the program can catch when an object with a mismatched type is added. Best of all, the error is caught early, and not when some other method tries to actually do something (incorrect) with the different object.

Dart's List interface is really a List<E> which you can read as "List of E, where E is some type." That E is a placeholder, and stand-in for a type that is declared external to the List interface definition.

These generics also help when extracting objects from a List, not just adding objects. For example, consider the following code:

add(num a, num b) => a + b;

main() {
  var names = <String>['alice', 'bob'];
  print( add(names[0], names[1]) ); // in Checked Mode, this BOOMs. Failed type check!
}

Wow, Dart is smart! It knows that add takes two numbers, and that names is a List of Strings. It also knows that objects inside of names are Strings, so when we reference names[0] (which the program knows is a String) and attempt to pass it to add(num, num), it errors with a Failed type check.

This is super duper cool! Adding a simple type annotation to our collection gives our program a tremendous amount of information, and the ability to fail quickly at the source of the bug (and not later in the program when it's too late.)  Simply being able to catch these sorts of bugs when they occur is enough to make you consider adding type annotations to your collections.

A List of Strings is a List, which sounds obvious when you say it out loud

Warning: the following section is for those who want to dive in deeper to generics, reification, and covariance. If you are new to these terms, the below might blow your mind. It's OK to stop reading here. The below isn't always the best way to write code.

One more cool part about Dart's collections and generics (aka type annotations.)  Dart will reify, or remember, the generic types for its collections. Unlike in Java, Dart's generic type annotation is not thrown away.

This means you can perform a check at runtime:

main() {
  var names = <String>['alice', 'bob'];
  print( names is List<String> ); // true!
}

OK, in retrospect, this is a totally obvious example. Trust me when I say, if you're a Java developer, your head is spinning right now.

Remember how we keep repeating that Dart is an optionally typed language? Someone (I don't know who) is going to write a function that does not specify any generic types, but the program should work anyway.

// we really recommend you don't omit the generic types
// for illustration only

awesome(List stuff) {
  // something awesome, if only I knew what was inside of stuff
  // but I was too lazy to add my generic type annotation
}

main() {
  var names = <String>['alice', 'bob'];
  awesome(names); // works! Dart lets a List<String> act as a List

  // which means
  print( new List<String>() is List );  // true!
}

When you say it out loud, it sounds obvious: a List of String is a List.

Here's the best part, and a true reflection of Dart's optional typing. If List<String> is a List, is the inverse true?  Can a List be passed to a function expecting a List<String> ?

// hurray! you added generic type annotations. your intention is clear.
awesome(List<String> stuff) {
  // something more awesome, because I know it's a List of Strings
}

main() {
  var names = ['alice', 'bob']; // I got lazy and didn't specify!
  awesome(names); // works!

  // which means
  print( new List() is List<String> );
}

Yes, the above works (though we don't recommend it!) because Dart's generics are covariant. This means List<String> is List, and List is List<String>.

But why oh why would Dart design a system that is so, well, unsound?!?!!

Remember that Dart has optional typing. This means a library author can write classes and functions without specifying any types at all. Heck, there's an army of JavaScript developers that have never seen a static type and many probably never want to see one. Hopefully the first part of this article convinced you that types are terse and useful, but Dart is built for a wide range of developers.

Now imagine that there is an untyped library that is AWESOME, and you want to use it. Your code, because you are a true Dart Viking, is fully typed.  Should you be able to mix your fully typed code with an arbitrary library that is untyped?  Yes!

// from the SuperLazyLib with zero types

awesome(stuff) {
  // you are brave
}

// from the Dart Viking, who writes clean and typed code

main() {
  var names = <String>['alice', 'bob'];
  awesome(names); // works!
}

Dart allows a mix of typed and untyped code, and as such the generics must be covariant.

If you are authoring a library, please add types. Your machines and humans will both thank you.

Summary

Demand more from your web programming language! Demand terse type annotations which help your program catch errors earlier, and your fellow developers more easily understand your intent.

Dart is a mostly familiar language by design, however it does have novel features like optional types.  Types should be thought of as annotations, or documentation, for both the machine and your fellow programmer.  Types are terse, often smaller than the comments you'd use to specify what a thing is or what can be passed to a method.

Once added, types can be interpreted by the program, allowing for bugs resulting from type mismatches to be detected early. Types also act to convey your intention and assertions to your fellow developer. This is A Good Thing (tm).

Types can be added to variables and function signatures. Types can also be given to collections such as Lists and Maps. These generics tell the program what objects are expected to be placed inside of, and retrieved from, a collection. Generics also help with tersely expressing programmer intent to both the machine and the fellow developer, and helps to catch bugs early.

Generics in Dart are covariant, which means a List of Strings is a List, and a List is a List of Strings. This is due to the fact that Dart's optional typing allows for a mix of types and untyped code to be used in the same program.

We recommend using types for method signatures, we don't recommend authoring libraries without types.

Next Steps

Learn more about Dart, play with Dart in your browser, browse the API docs, enter feature requests and bugs, and join the discussion. And remember, Dart is in Technology Preview right now, things are changing and we'd like to hear your feedback!

Also, check out Part 6, For loops in Dart, or, Fresh bindings for sane closures.

Popular posts from this blog

Lists and arrays in Dart

Converting Array to List in Scala

Null-aware operators in Dart