Dart declaration-site variance

Kallen Tu
Dart
Published in
5 min readDec 19, 2019
A code snippet showing the contravariant variance modifier (`in`) in use.

Declaration-site variance was my internship project on the Dart team, and I’ve documented my personal experience on the team in the article Life as a Dart intern. As the primary implementer of the declaration-site variance feature, I want to share the usages and benefits of sound variance.

We’ll discuss how to use variance, why we want to use the modifiers, how the feature can build on top of classes that don’t use the modifiers, and what benefits this feature provides us.

Note: The implementation of variance isn’t finished yet. Although you can play with it by enabling the experiment (instructions below), the feature might change before it’s finalized.

Before diving into Dart’s declaration-site variance feature, we’ll take a quick detour to discuss what variance means and how it’s used.

What is variance?

To briefly introduce variance, we can look at this example:

From this, an Iterable of integers can be substituted as an Iterable of Objects because an integer is an Object and can be used in an Iterable in every place an Object would be. The language allows this by saying that two instantiations of the same generic type (like Iterable<int> and Iterable<Object> here) are considered subtypes if their type arguments (int and Object) are. This subtyping relation is considered covariance.

This is convenient and logical. It makes sense, that is, until you take a look at the variance of the parameters of a method. Say you want to make a objectWriter in Dart:

Then you eagerly have your objectWriter write a String to discover that it produces a runtime error. The compiler allows this code, but when you run it, it throws an exception.

Why is this? With contravariance, the subtyping relation is reversed compared to covariance. We need to be able to write any Object to the objectWriter, but from earlier we know that the objectWriter is actually a disguised writer of integers.

The final variant type you will need to know about is invariance. Invariant subtyping relation means no subtype relation between two invariant types unless they are the exact same type.

What is the variance feature in Dart?

Since the Dart team proposed to add explicit variance modifiers to the language, we’ll preview some of the changes to expect.

Dart will have variance modifiers that can be applied to type parameters in classes and mixins. The syntax is similar to variance modifiers in C#.

You can use the keywords out, in, and inoutto declare a covariant, contravariant, and invariant type parameter respectively. This is used with generic types as such:

Why define explicit variance for generic types? Why do we want this feature?

Dart’s static type system currently treats all type parameters as covariant. That’s correct and convenient for generics where the type is used in a safely covariant place like a return type. But it’s wrong when the type argument should be contravariant or invariant:

When you use objectWriter, you expect to be able to write any Object. Unfortunately, the objectWriter only writes integers. The compiler doesn’t know any better and when you run the code, you receive the dreaded runtime error. To avoid unsoundness, Dart throws an error at runtime if you use a type argument in an unsafe way.

Fortunately, adding a variance modifier turns this incorrect use from a runtime error into a compile-time error.

This is much better. Long before you write objectWriter.write("I'm a string!"), the compiler will notify you that there’s something wrong.

Now, let’s take a look at what adding safely typed parameters using a variance modifier provides you.

Type parameters in members

If you mark a generic type parameter with out, the compiler emits a static error if you use that type in a method or field in a place that isn’t safely covariant like a return type. Likewise, a type parameter marked in can only be used in a place that is safely contravariant like a method parameter type. Type parameters marked inout can be used anywhere.

Here are some method variance position errors and correct usages that you may find helpful. The same error checking also occurs for mixins.

Errors can be emitted in fields as well.

Assignment and subtyping

The errors the compiler reports for misusing type parameters help the generic class author write correct code. The other half is the set of errors that help others use the class correctly. One of the changes that come with sound variance modifiers is the subtyping change that we can see through assignment.

If a generic type parameter is covariant, then you can assign it when its type argument is a subtype of the expected type’s type argument. For example, you can assign a Reader<int> to something that expects a Reader<Object>.

Likewise, if a generic type parameter is contravariant, assignment is allowed when its type argument is a supertype of the expected type’s type argument. You can assign a Writer<Object> to something that expects a Writer<int>, as such:

For invariant parameters, the type argument must be the same type.

Interface inheritance

So you might be asking, “Can we opt into stronger compile time checking with variance even when extending older classes?” The good news is that you can; however, there are a few restrictions.

out parameters can only extend parameter positions that are covariant or have default Dart type variance.

Keep in mind that any methods inherited from legacy classes could still be unsoundly variant, and hence may still cause runtime errors. Otherwise, all new methods in a subclass with type parameters that have variance modifiers will emit errors if the types are in unsound positions.

in parameters can only extend parameter positions that are contravariant.

inout parameters can extend all parameter positions. However, a parameter that is defined as inout can only be inherited by other invariant positions.

How can I give feedback on the variance feature?

We recommend trying the variance feature using the latest dev channel. Play around with this example to get a grasp on how variance works and what it can do for you.

Because the variance feature is still being implemented, you need to set an experimental flag to enable it:

dart --enable-experiment=variance variance_example.dart

We appreciate any and all feedback! You can let us know what you think in this GitHub issue.

Summary

Existing Dart generics are covariant by default, which makes it easy to start writing new classes and to get started. This means, however, that more errors appear at runtime rather than at compile time. The user is also paying for the cost of additional runtime checks. The main idea behind variance is to provide users with more informative error checking at compile time.

Variance is defined only for the parameters of generic classes and mixins. Users can use the variance feature by adding one of the in, out or inout keywords before a type parameter.

Additionally, new generic interfaces with these modifiers can extend legacy interfaces with no variance modifiers.

Declaration-site variance allows you to reap a bunch of new benefits, including:

  • Compile-time variant position checking within members of the interface
  • Removal of pesky runtime errors that occur with down and up casting
  • Additional subtyping changes based on the variance declared
  • More informative and accessible error checking

Now you won’t have to worry if that objectWriter is truly a writer of any object. You know it is.

--

--

Kallen Tu
Dart
Writer for

Software Engineer on the Dart team @ Google.