The Dart language has great support for functional programming, but it's also a pure object-oriented (OO) language with single inheritance and mixin support. Even literals are objects, allowing you to write code like 5.isOdd, which will resolve to true. It's not a dogmatic OO language, like Java, which requires you to define everything within classes, so it's important to know when to keep code outside of classes as well.

Dart has some conventions and special syntax to be aware of when designing classes and instantiating objects of those classes. There is more than one way to do almost anything, but what are the best practices in Dart? Here, we'll explore a few for class design and object instantiation.

The code for this article was tested with Dart 2.8.4 and Flutter 1.17.5.

Creating objects succinctly

Like most OOP languages, Dart supports the keyword new for creating instances of classes. Here is an example of a traditional object instantiation, using the new keyword:

final buffer = new StringBuffer();

In the interest of brevity, it's best to omit the keyword. Here's the same object instantiation without using new:

final buffer = StringBuffer();

The advantage of this less verbose habit is emphasized when we look at typical Flutter code, where you commonly need to instantiate whole trees of objects. Here is a short example of a Flutter widget  build() method using new:

@override
Widget build(BuildContext context) {
  final buttonText = "Flat Button";

  return new Column(
    children: <Widget>[
      new Row(
        children: <Widget>[
          new Icon(Icons.message),
          new SizedBox(width: 5),
          new TextWithOverflow(
            text: "My Name",
            style: Theme.of(context).textTheme.subtitle,
          ),
        ],
      ),
      new FlatButton(
        onPressed: null,
        child: new Text(buttonText),
    ],
  );
}

Without new, the code is considerably easier to read, and the effect is even more pronounced in longer code blocks:

@override
Widget build(BuildContext context) {
  final buttonText = "Flat Button";

  return Column(
    children: <Widget>[
      Row(
        children: <Widget>[
          Icon(Icons.message),
          SizedBox(width: 5),
          TextWithOverflow(
            text: "My Name",
            style: Theme.of(context).textTheme.subtitle,
          ),
        ],
      ),
      FlatButton(
        onPressed: null,
        child: Text(buttonText),
    ],
  );
}

Fewer language keywords means less clutter, and in this case, readability is not sacrificed.

Automatic initializers in constructors

You create a Dart class constructor by adding a class method with the same name as the class itself. Often, constructors take parameters to initialize member variables:

class Point {
  int x;
  int y;
  
  Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

The this keyword is necessary to disambiguate the parameters from the member variables. An ill-advised alternative would be to change the names of either the parameters or the members, but this situation is so common, Dart has special syntax to make it easier:

class Point {
  int x;
  int y;

  Point(this.x, this.y);
}

In this version, Dart handles the value assignments for you automatically. Additionally, it's no longer necessary to include data types in the parameter list, because Dart can infer the types from the types of the matching member variables. Note that Dart doesn't require constructors to have explicit body code, so now we can omit the constructor's curly braces too.

Next, we'll take a look at ways to add clarity to function parameters.

Named parameters

Since most Flutter user interface (UI) code is produced directly in Dart, a best practice has developed to use Dart's named parameter feature often. Unlike the more traditional positional parameters, named function parameters must be prefixed by their names in a function call and are optional by default.

Flutter UI code is made much clearer through the use of named parameters, and it has become customary to prefer them in a Flutter code base:

class Message {
  String id;
  String content;
  
  Message({this.id, this.content});
}

We indicate these parameters should be optional and named using the surrounding curly braces in the constructor's parameter list.

A new Message instance must now be created with explicit parameter names, shown next formatted in the typical Flutter style using lots of newlines and a trailing comma:

final msg = Message(
  id: "1",
  content: "I love Flutter.",
);

It takes up a few extra lines, but it's obvious at a glance that you're creating a Message with an ID and a bit of content, and it's easy to edit the parameter values quickly.

Trailing commas: Some can't stand them, but adding a trailing comma after the last element in a list comes with a few advantages for the Dart and Flutter developer. IDEs are better able to automatically format code with trailing commas, and it's much easier to reorder, add, or comment out elements when you include them.

The named parameters allow for more readable code than the traditional approach using positional parameters. Without named parameters, creating a Message looks like this:

final msg = Message("1", "I love Flutter.");

In this code, it's not immediately obvious what the first argument is for, nor is it particularly easy to make alterations to the argument list.

final vs. var: When declaring variables, use final instead of var everywhere you can. This tells Dart that the variable should not be reassigned after initialization. The code analyzer will alert you if any code attempts to set the value again. If a variable's value should not change after it's initialized, Dart's final keyword can help you avoid bugs related to unexpected mutation.

One problem you may have noticed with using named parameters is that they're optional, and sometimes that is undesirable.

Making named parameters required

A Message object lacking either an ID or content makes no sense. Since named parameters are optional, this poses a problem. It's possible to create a bad message this way:

final msg = Message();

If you don't provide values for the optional parameters, they default to null. For Message, it's best to make use of the @required metatag, which is available in package:flutter/foundation.dart or in the meta package on Pub:

import 'package:meta/meta.dart';

class Message {
  String id;
  String content;

  Message({@required this.id, @required this.content});
}

Now both parameters are required instead of optional. Failing to include either of them when instantiating a new Message will result in complaints from the Dart analyzer, allowing you to catch this sort of error at development time.

Unfortunately, it is still possible to explicitly pass null values to a required parameter, but there are measures we can take.

Using assertions to check parameter values

A note about upcoming changes to Dart regarding null: The need for the following technique will soon be considerably lessened, as Dart's development team is adding non-nullable variables to the language, but until then, we need to deal with null values regularly.

Even though you've made all of the Message class's constructor parameters required, it's still possible to explicitly pass null arguments to thwart this effort:

Message(id: null, content: null);

Use assert() to prevent invalid arguments at runtime:

class Message {
  String id;
  String content;

  Message({@required this.id, @required this.content}) {
    assert(id != null);
    assert(content != null);
  }
}

This version of the class will throw an exception of type AssertionError if either property of an instance is initialized to null at runtime. An assert statement takes a boolean expression as its first parameter, and if that resolves to false, the exception occurs. You can pass an optional second parameter if you want to customize the exception message:

assert(id != null, "A message ID is required.");
assert(content != null, "Message content is required.");

For Dart and Flutter projects, assertions are not evaluated in production code. They will only execute in debug mode. Astute readers will have realized assertions could be used along with optional parameters to simulate required arguments, but assertions occur at runtime, whereas parameters marked with @required work with Dart's code analyzer to catch problems during development.

To help users of your constructors and methods write less code and produce fewer errors, you can provide default values for parameters.

Providing default values for parameters

Whenever possible, you should define sensible default values for your named parameters. Take a look at this sample Task class from a hypothetical task-tracking app:

class Task {
  String text;
  bool isComplete;

  Task({@required this.text, this.isComplete = false});
}

Since most Task objects will start out incomplete, it makes sense to set them that way by default. If the second parameter is omitted, isComplete will automatically initialize to false:

final task = Task(text: "Do something.");  // incomplete task

Using named parameters for boolean values is always a good habit, even when there are positional parameters, since it's difficult to intuit the effect of a boolean parameter from its passed value.

This next version of Task uses positional parameters, with the boolean declared as positional and optional (denoted by square brackets). Once again, a default value of false is specified, because without it, the default will be null, as with all Dart variables:

class Task {
  String text;
  bool isComplete;

  Task(this.text, [this.isComplete = false]);
}

final task = Task("Do something.", true);  // ambiguous boolean

When creating a new Task object, it's not obvious what true means here, as it has no label.

If we substitute the square brackets for curly braces in the class constructor, the boolean parameter becomes named, and the ambiguity in an instantiation expression is eliminated:

class Task {
  String text;
  bool isComplete;

  Task(this.text, {this.isComplete = false});
}

final task = Task("Do something.", isComplete: true);

The parameter name in the calling code makes it clear what the boolean value is for.

Conclusion

Now, we've seen how you can use special features of Dart like automatic initializers and named parameters to make your code less verbose, more readable, and less prone to errors. Until next time, keep Darting!