Types Design

Domains: Dart

Type annotations are what you normally think of when you think of “static types”. You can type annotate a variable, parameter, field, or return type. In the following example, bool and String are type annotations. They hang off the static declarative structure of the code and aren’t “executed” at runtime.

bool isEmpty(String parameter) {
  bool result = parameter.isEmpty;
  return result;
}

A generic invocation is a collection literal, a call to a generic class’s constructor, or an invocation of a generic method. In the next example, num and int are type arguments on generic invocations. Even though they are types, they are first-class entities that get reified and passed to the invocation at runtime.

var lists = <num>[1, 2];
lists.addAll(List<num>.filled(3, 4));
lists.cast<int>();

We stress the “generic invocation” part here, because type arguments can also appear in type annotations:

List<int> ints = [1, 2];

Here, int is a type argument, but it appears inside a type annotation, not a generic invocation. You usually don’t need to worry about this distinction, but in a couple of places, we have different guidance for when a type is used in a generic invocation as opposed to a type annotation.

In most places, Dart allows you to omit a type annotation and infers a type for you based on the nearby context, or defaults to the dynamic type. The fact that Dart has both type inference and a dynamic type leads to some confusion about what it means to say code is “untyped”. Does that mean the code is dynamically typed, or that you didn’t write the type? To avoid that confusion, we avoid saying “untyped” and instead use the following terminology:

  • If the code is type annotated, the type was explicitly written in the code.

  • If the code is inferred, no type annotation was written, and Dart successfully figured out the type on its own. Inference can fail, in which case the guidelines don’t consider that inferred. In some places, inference failure is a static error. In others, Dart uses dynamic as the fallback type.

  • If the code is dynamic, then its static type is the special dynamic type. Code can be explicitly annotated dynamic or it can be inferred.

In other words, whether some code is annotated or inferred is orthogonal to whether it is dynamic or some other type.

Inference is a powerful tool to spare you the effort of writing and reading types that are obvious or uninteresting. Omitting types in obvious cases also draws the reader’s attention to explicit types when those types are important, for things like casts.

Explicit types are also a key part of robust, maintainable code. They define the static shape of an API. They document and enforce what kinds of values are allowed to reach different parts of the program.

DO

DO annotate when Dart infers the wrong type.

Sometimes, Dart infers a type, but not the type you want. For example, you may want a variable’s type to be a supertype of the initializer’s type so that you can later assign some other sibling type to the variable:

num highScore(List<num> scores) {
  num highest = 0;
  for (var score in scores) {
    if (score > highest) highest = score;
  }
  return highest;
}
num highScore(List<num> scores) {
  var highest = 0;
  for (var score in scores) {
    if (score > highest) highest = score;
  }
  return highest;
}

Here, if scores contains doubles, like [1.2], then the assignment to highest will fail since its inferred type is int, not num. In these cases, explicit annotations make sense.

DO annotate with Object instead of dynamic to indicate any object is allowed.

Some operations work with any possible object. For example, a log() method could take any object and call toString() on it. Two types in Dart permit all values: Object and dynamic. However, they convey different things. If you simply want to state that you allow all objects, use Object, as you would in Java or C#.

Using dynamic sends a more complex signal. It may mean that Dart’s type system isn’t sophisticated enough to represent the set of types that are allowed, or that the values are coming from interop or otherwise outside of the purview of the static type system, or that you explicitly want runtime dynamism at that point in the program.

void log(Object object) {
  print(object.toString());
}

/// Returns a Boolean representation for [arg], which must
/// be a String or bool.
bool convertToBool(dynamic arg) {
  if (arg is bool) return arg;
  if (arg is String) return arg == 'true';
  throw ArgumentError('Cannot convert $arg to a bool.');
}

DO use Future<void> as the return type of asynchronous members that do not produce values.

When you have a synchronous function that doesn’t return a value, you use void as the return type. The asynchronous equivalent for a method that doesn’t produce a value, but that the caller might need to await, is Future<void>.

You may see code that uses Future or Future<Null> instead because older versions of Dart didn’t allow void as a type argument. Now that it does, you should use it. Doing so more directly matches how you’d type a similar synchronous function, and gives you better error-checking for callers and in the body of the function.

For asynchronous functions that do not return a useful value and where no callers need to await the asynchronous work or handle an asynchronous failure, use a return type of void.

PREFER

PREFER type annotating public fields and top-level variables if the type isn’t obvious.

Type annotations are important documentation for how a library should be used. They form boundaries between regions of a program to isolate the source of a type error. Consider:

install(id, destination) => ...

Here, it’s unclear what id is. A string? And what is destination? A string or a File object? Is this method synchronous or asynchronous? This is clearer:

Future<bool> install(PackageId id, String destination) => ...

In some cases, though, the type is so obvious that writing it is pointless:

const screenWidth = 640; // Inferred as int.

“Obvious” isn’t precisely defined, but these are all good candidates:

  • Literals.
  • Constructor invocations.
  • References to other constants that are explicitly typed.
  • Simple expressions on numbers and strings.
  • Factory methods like int.parse()Future.wait(), etc. that readers are expected to be familiar with.

When in doubt, add a type annotation. Even when a type is obvious, you may still wish to explicitly annotate. If the inferred type relies on values or declarations from other libraries, you may want to type annotate your declaration so that a change to that other library doesn’t silently change the type of your own API without you realizing.

PREFER signatures in function type annotations.

The identifier Function by itself without any return type or parameter signature refers to the special Function type. This type is only marginally more useful than using dynamic. If you’re going to annotate, prefer a full function type that includes the parameters and return type of the function.

bool isValid(String value, bool Function(String) test) => ...
bool isValid(String value, Function test) => ...

Exception: Sometimes, you want a type that represents the union of multiple different function types. For example, you may accept a function that takes one parameter or a function that takes two. Since we don’t have union types, there’s no way to precisely type that and you’d normally have to use dynamicFunction is at least a little more helpful than that:

void handleError(void Function() operation, Function errorHandler) {
  try {
    operation();
  } catch (err, stack) {
    if (errorHandler is Function(Object)) {
      errorHandler(err);
    } else if (errorHandler is Function(Object, StackTrace)) {
      errorHandler(err, stack);
    } else {
      throw ArgumentError("errorHandler has wrong signature.");
    }
  }
}

PREFER inline function types over typedefs.

In Dart 1, if you wanted to use a function type for a field, variable, or generic type argument, you had to first define a typedef for it. Dart 2 supports a function type syntax that can be used anywhere a type annotation is allowed:

class FilteredObservable {
  final bool Function(Event) _predicate;
  final List<void Function(Event)> _observers;

  FilteredObservable(this._predicate, this._observers);

  void Function(Event) notify(Event event) {
    if (!_predicate(event)) return null;

    void Function(Event) last;
    for (var observer in _observers) {
      observer(event);
      last = observer;
    }

    return last;
  }
}

It may still be worth defining a typedef if the function type is particularly long or frequently used. But in most cases, users want to see what the function type actually is right where it’s used, and the function type syntax gives them that clarity.

PREFER annotating with dynamic instead of letting inference fail.

Dart allows you to omit type annotations in many places and will try to infer a type for you. In some cases, if inference fails, it silently gives you dynamic. If dynamic is the type you want, this is technically the most terse way to get it.

However, it’s not the most clear way. A casual reader of your code who sees an annotation is missing has no way of knowing if you intended it to be dynamic, expected inference to fill in some other type, or simply forgot to write the annotation.

When dynamic is the type you want, writing it explicitly makes your intent clear.

dynamic mergeJson(dynamic original, dynamic changes) => ...
mergeJson(original, changes) => ...

CONSIDER

CONSIDER type annotating private fields and top-level variables if the type isn’t obvious.

Type annotations on your public declarations help users of your code. Types on private members help maintainers. The scope of a private declaration is smaller and those who need to know the type of that declaration are also more likely to be familiar with the surrounding code. That makes it reasonable to lean more heavily on inference and omit types for private declarations, which is why this guideline is softer than the previous one.

If you think the initializer expression — whatever it is — is sufficiently clear, then you may omit the annotation. But if you think annotating helps make the code clearer, then add one.

CONSIDER using function type syntax for parameters.

Dart has a special syntax when defining a parameter whose type is a function. Sort of like in C, you surround the parameter’s name with the function’s return type and parameter signature:

Iterable<T> where(bool predicate(T element)) => ...

Before Dart 2 added function type syntax, this was the only way to give a parameter a function type without defining a typedef. Now that Dart has a general notation for function types, you can use it for function-typed parameters as well:

Iterable<T> where(bool Function(T) predicate) => ...

The new syntax is a little more verbose, but is consistent with other locations where you must use the new syntax.

 

AVOID

AVOID type annotating initialized local variables.

Local variables, especially in modern code where functions tend to be small, have very little scope. Omitting the type focuses the reader’s attention on the more important name of the variable and its initialized value.

List<List<Ingredient>> possibleDesserts(Set<Ingredient> pantry) {
  var desserts = <List<Ingredient>>[];
  for (var recipe in cookbook) {
    if (pantry.containsAll(recipe)) {
      desserts.add(recipe);
    }
  }

  return desserts;
}
List<List<Ingredient>> possibleDesserts(Set<Ingredient> pantry) {
  List<List<Ingredient>> desserts = <List<Ingredient>>[];
  for (List<Ingredient> recipe in cookbook) {
    if (pantry.containsAll(recipe)) {
      desserts.add(recipe);
    }
  }

  return desserts;
}

If the local variable doesn’t have an initializer, then its type can’t be inferred. In that case, it is a good idea to annotate. Otherwise, you get dynamic and lose the benefits of static type checking.

List<AstNode> parameters;
if (node is Constructor) {
  parameters = node.signature;
} else if (node is Method) {
  parameters = node.parameters;
}

AVOID annotating inferred parameter types on function expressions.

Anonymous functions are almost always immediately passed to a method taking a callback of some type. (If the function isn’t used immediately, it’s usually worth making it a named declaration.) When a function expression is created in a typed context, Dart tries to infer the function’s parameter types based on the expected type.

For example, when you pass a function expression to Iterable.map(), your function’s parameter type is inferred based on the type of callback that map() expects:

var names = people.map((person) => person.name);
var names = people.map((Person person) => person.name);

In rare cases, the surrounding context is not precise enough to provide a type for one or more of the function’s parameters. In those cases, you may need to annotate.

AVOID redundant type arguments on generic invocations.

A type argument is redundant if inference would fill in the same type. If the invocation is the initializer for a type-annotated variable, or is an argument to a function, then inference usually fills in the type for you:

Set<String> things = Set();
Set<String> things = Set<String>();

Here, the type annotation on the variable is used to infer the type argument of constructor call in the initializer.

In other contexts, there isn’t enough information to infer the type and then you should write the type argument:

var things = Set<String>();
var things = Set();

Here, since the variable has no type annotation, there isn’t enough context to determine what kind of Set to create, so the type argument should be provided explicitly.

AVOID using FutureOr<T> as a return type.

If a method accepts a FutureOr<int>, it is generous in what it accepts. Users can call the method with either an int or a Future<int>, so they don’t need to wrap an int in Future that you are going to unwrap anyway.

If you return a FutureOr<int>, users need to check whether get back an int or a Future<int> before they can do anything useful. (Or they’ll just await the value, effectively always treating it as a Future.) Just return a Future<int>, it’s cleaner. It’s easier for users to understand that a function is either always asynchronous or always synchronous, but a function that can be either is hard to use correctly.

Future<int> triple(FutureOr<int> value) async => (await value) * 3;
FutureOr<int> triple(FutureOr<int> value) {
  if (value is int) return value * 3;
  return (value as Future<int>).then((v) => v * 3);
}

The more precise formulation of this guideline is to only use FutureOr<T> in contravariant positions. Parameters are contravariant and return types are covariant. In nested function types, this gets flipped—if you have a parameter whose type is itself a function, then the callback’s return type is now in contravariant position and the callback’s parameters are covariant. This means it’s OK for a callback’s type to return FutureOr<T>:

Stream<S> asyncMap<T, S>(
    Iterable<T> iterable, FutureOr<S> Function(T) callback) async* {
  for (var element in iterable) {
    yield await callback(element);
  }
}

DON'T

DON’T specify a return type for a setter

Setters always return void in Dart. Writing the word is pointless.

void set foo(Foo value) { ... }
set foo(Foo value) { ... }

DON’T use the legacy typedef syntax

Dart has two notations for defining a named typedef for a function type. The original syntax looks like:

typedef int Comparison<T>(T a, T b);

That syntax has a couple of problems:

  • There is no way to assign a name to a generic function type. In the above example, the typedef itself is generic. If you reference Comparison in your code, without a type argument, you implicitly get the function type int Function(dynamic, dynamic)not int Function<T>(T, T). This doesn’t come up in practice often, but it matters in certain corner cases.

A single identifier in a parameter is interpreted as the parameter’s name, not its type. Given:

typedef bool TestNumber(num);
  • Most users expect this to be a function type that takes a num and returns bool. It is actually a function type that takes any object (dynamic) and returns bool. The parameter’s name (which isn’t used for anything except documentation in the typedef) is “num”. This has been a long-standing source of errors in Dart.

The new syntax looks like this:

typedef Comparison<T> = int Function(T, T);

If you want to include a parameter’s name, you can do that too:

typedef Comparison<T> = int Function(T a, T b);

The new syntax can express anything the old syntax could express and more, and lacks the error-prone misfeature where a single identifier is treated as the parameter’s name instead of its type. The same function type syntax after the = in the typedef is also allowed anywhere a type annotation may appear, giving us a single consistent way to write function types anywhere in a program.

The old typedef syntax is still supported to avoid breaking existing code, but it’s deprecated.

Similar pages

Page structure
Terms

Functions

int

Dart

Types

String

bool

Methods

Set

List

Parameters

Variables

Map

Constructor

Numbers

Collections

Anonymous functions

Return values

Getters and setters