Skip to content

Instantly share code, notes, and snippets.

@lrhn

lrhn/FutureOr.md Secret

Last active January 26, 2017 10:54
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save lrhn/d6815f9556acb3dc8f91d2c680ea0e54 to your computer and use it in GitHub Desktop.
Save lrhn/d6815f9556acb3dc8f91d2c680ea0e54 to your computer and use it in GitHub Desktop.
Dart FutureOr Design and Discussion Document

FutureOr

Problem to solve

The Dart platform async libraries have multiple places where it expects either a value or a future.

The canonical example is the Future.then method which accepts a function that returns either a future or a value.

These are the examples from future.dart in dart:async:

Future<T>(dynamic computation())
Future<T>.microtask(dynamic computation())
Future<T>.sync(dynamic computation())
Future<T>.value(dynamic result)
Future<T>.delayed(Duration duration, [dynamic computation()])
Future Future.doWhile(dynamic f())
Future<S> Future<T>.then<S>(dynamic handler(T value), 
                            {dynamic onError(Object e, StackTrace s})
Future<T> Future<T>.catchError(dynamic onError(Object e, StackTrace s, …)
void Completer<T>.complete(dynamic result)
Future<T> Future<T>.onTimeout(Duration timeLimit, {dynamic onTimeout()})

All these functions use dynamic to represent the "value or future" result.

That is a problem for strong mode where, e.g., the Future.then method has a generic type with a type parameter S that depends on the argument's return type, but since S doesn't actually occur in the function signature, it cannot be inferred from arguments. There is currently special-case code in the analyzer to handle Future.then and some similar functions so users do get an inferred type parameter.

There are more examples in third party code, like the "test" package, so we can't just special-case the platform libraries.

FutureOr as a solution

To solve this problem we introduce the type constructor FutureOr so that FutureOr<int> is a type that is a supertype of both int and Future<int>.

Syntactically, FutureOr acts like a generic class with a single type parameter. Passing the wrong number of type arguments or using malformed or deferred type arguments causes the same errors or warnings as it would for a generic class. The only difference is that the type resulting from the type expression FutureOr<SomeType> is not a class type.

In "spec mode", the type expression FutureOr<SomeType> always represents the type dynamic, which is a sypertype of both SomeType and Future<SomeType>, and that is all there is to it.

The remainder of this document deals only with strong mode.

In strong mode, FutureOr<SomeType> represents a (possibly new) new type that is exactly the least supertype of SomeType and FutureOr<SomeType>. The type FutureOr<T> is effectively the union type of T and Future<T>.

We can then replace the dynamic cases above with a suitably parameterized FutureOr type.

Future<T>(FutureOr<T> computation())
Future<T>.microtask(FutureOr<T> computation())
Future<T>.sync(FutureOr<T> computation())
Future<T>.value(FutureOr<T> result)
Future<T>.delayed(Duration duration, [FutureOr<T> computation()])
Future Future.doWhile(FutureOr<bool> f())
Future<S> Future<T>.then<S>(FutureOr<S> handler(T value), 
                            {FutureOr<S> onError(Object e, StackTrace s})
Future<T> Future<T>.catchError(FutureOr<T> onError(Object e, StackTrace s, …)
void Completer<T>.complete(FutureOr<T> result)
Future<T> Future<T>.onTimeout(Duration timeLimit, {FutureOr<T> onTimeout()})

This will allow the analyzer/strong-mode compiler to infer types from parameters.

It also introduces a new union type to the type system (nullable types also introduces a union type).

The FutureOr type constructor should scope-wise be treated like a typedef declared in dart:async, even if we don't currently have syntax to make such a declaration.

The name FutureOr is suggestive of what it means, and we don't expect the name to be used without a type parameter often, if at all. There might be a few cases where a function parameter is allowed to be either synchronous or asynchronous, and you want to write FutureOr as return type instead of the longer FutureOr<Object> as return type, but they are expected to be rare.

The name is deceptively close to FutureOf, which is what you easily write when not being very careful. That might just be a matter of habit.

Type semantics

We define FutureOr<T> to be the type union T | Future<T>.

Union types in general

It's not absolutely clear what that means. We don't have general union types in the language, and there is more than one way to define something that can be considered the union of two types.

Whatever we do for FutureOr, it should be consistent with what we do for nullable types, where T? will be the union T | Null.

A type union of two types A and B, written A | B, denotes a type which should satisfy the following property:

  1. A and B are both subtypes of A | B.

  2. Any proper subtype of A | B is a subtype of either A or B.

    These two requirements mean that a value of type A or type B can be used where the type A | B is expected, and nothing else can. It is basically the minimal requirement for calling something a union type.

    This doesn't say anything about the A | B type itself. For example. it doesn't even say whether it's a subtype of Object. To be able to position the type A | B somewhere in the type hierarchy, we might also want the following property.

  3. If both A and B are subtypes of a type C, then A | B is a subtype of C.

    This ensures that A | B is a subtype of Object and of any type that both A and B implements. For example, an int | double is assignable to num. If there is a minimal class, C, that A and B are subtypes of (say a common abstract superclass) then this rule makes A | B a subclass of C, and not equivalent to it.

Rule 3 is not uncontroversial, and we might have to compromise on it. Another option is to say that Object is the only supertype of union types. However, we probably want FutureOr<num> to be a supertype of FutureOr<int>, so the alternative becomes "only Object and other FutureOr types", which will probably still get more complicated with nullable types.

Which supertype A | B has is important for two things: Assignability and member access. We can try to distinguish the two, but since you can always do (valueAOrB as C).memberOfC if A | B is assignable to C, we might as well allow calling members of C directly on valueAOrB.

These three rules by themselves still leave a some room for wiggling.

Derived properties of union types.

If A is a subtype of B then A | B is a subtype of B (3) and B is a subtype of A | B (1). We haven't specified whether that means that A | B is B, or whether they are just equivalent in the type hierarchy (two distinct types can be said to be equivalent if they are subtypes of each other). It might be convenient to say that the | type operator always introduce a new type, but it also might be convenient that it doesn't when one type already subsumes the other.

You can't really distinguish equivalent types at the type level - they satisfy the same subclass and instance checks - but it might be visible if it is reflected in the operator== of Type objects. The equality operator on Type objects can be changed to reflect equivalence instead of equality if we want to.

The rules also imply a relation between A | B and B | A. Since A and B are subtypes of both (1) then the two are subtypes of each other (3), so they are at least equivalent. We likely want them to be equal. Likewise A | (B | C) and (A | B) | C are equivalent, and we might want them to be equal.

That is, the type union operation is commutative and associative. The types that are considered equivalent by this rule have the same super and sub types and implement the same interfaces, so they really are indistinguishable in practice. (This rule isn't actually important to FutureOr because users can't change the ordering, it's just included for completeness).

Modulo equivalence, type union satisfies:

  • A | A is equivalent to A.
  • It's idempotent (A | B | B is equivalent to A | B).
  • If we have a bottom type, bottom | A is equivalent to A.

A union type is not a "class type". You can't extend, implement, or mix in a union type, you can't do new UnionType() or access static members of it (no UnionType.foo()). It's a structural type, not a nominal type, so like function types, an A | B written in two different places denotes the same type.

FutureOr as a union type

So, for FutureOr<T> we use the union of T and Future<T>.

If T isn't a super- or sub-type of Future, then the two parts are clearly separate.

There are a few cases where this is not the case:

  • FutureOr<Object> is equivalent to Object (similarly for dynamic and possibly void).
  • FutureOr<Future<Object>> is equivalent to Future<Object>.
  • FutureOr<Future<Future<Object>>> is equivalent to Future<Future<Object>> (etc.).
  • FutureOr<bottom> (if we have a bottom type) is equivalent to Future<bottom>.

Otherwise it's generally the case that FutureOr<T> is neither equivalent to T nor to Future<T>. (If we get nullable types, there might be more types that are super- or sub-types of Future).

Since dynamic is equivalent to Object as a type, only with different member lookup behavior, FutureOr<dynamic> is equivalent to Object and dynamic. We should probably not actually reduce it to dynamic since that would allow you to call members of the subtypes without casting which removes some of the type protection that this construct gives over plain dynamic.

We could disallow using Object or Future<something> as type argument to FutureOr, but since types can go through type parameters, that would need to be a dynamic check in some cases, and then it's probably not worth it.

Also, if A is a subtype of B then FutureOr<A> is a subtype of FutureOr<B>. This follows directly from the properties 1. and 3. of union types and that Future<A> is a subtype of Future<B>.

Consequences

Adds infinitely many supertypes for most types.

Because FutureOr can be used to create a supertype for any type, including a union type, there is an infinite chain of supertypes of, e.g., int: FutureOr<int>, FutureOr<FutureOr<int>>, etc.

That's not new, we already have an infinite supertype chain from, e.g., Comparable: num, Comparable<num>, Comparable<Comparable<num>>, etc.

The "nullable" union type does not have this property because it doesn't increase the type complexity of its argument - it's idempotent, so int?? is equivalent to int?.

There are also infinitely many supertypes of Object, but they are all equivalent to Object, so Object can still be used as a top element of the type hierarchy (modulo equivalence).

Places where FutureOr can be used

Also, adding a union type, even as restricted as this, means that the type system must be able to handle a union type everywhere a type can occur.

Places where types can occur:

  • Type annotations on variables
  • Parameter types
  • Return typesConsequences
  • Function type parameter type
  • Function type return type
  • Type literal expression
  • Type instantiation expression
  • Type arguments
  • Type parameter bounds
  • Type checks (is)
  • Type casts (as)
  • Try-on-clauses
  • In a LUB computation
  • In member checks (is x.foo() a warning?)
  • Assignability
  • Nullable (FutureOr<int>?)
  • Nested (FutureOr<FutureOr<int>>)

The two bolded entries are the ones that actually occur in the current library code. User code may have other uses as well.

Typed variables are likely needed internally in the implementation of those functions.

Variable types

FutureOr<int> x;

This declares a variable with a union type (doubly so because it's currently nullable).

Only methods on common supertypes are directly usable on the variable, and that (usually) means only the methods on Object.

So, to use the variable, you need to either cast or promote the type to a subtype.

An as cast adds a dynamic check, as does an implicit downcast to a subtype (if we allow those).

An is check can cause type promotion to either int or Future<int>.

If you can't use type promotion (e.g., the variable isn't a local variable) then you need to cast or assign to a differently typed variable before using the value.

Parameter types

Future.value(FutureOr<T> value);

Used in the existing functions and constructors (like here). The type of the variable will be FutureOr<T> internally in the function.

The function can be called with either a either a value or a future, or with the value of a variable typed with a FutureOr<T>.

The function type (FutureOr<T>)->R is a subtype of both (T)->R and (Future<T>)->R and can be used where either of those are expected.

Return types

FutureOr<T> foo() … 

This should hopefully happen rarely - most functions are either asynchronous or not. That makes the function much easier to use for the caller.

Still, not all functions are intended for general use, and the syntax is valid and meaningful. If we choose to infer FutureOr<T> as a type, we can type a function expression like:

future.then((bool x) => x ? syncFunction() : asyncFunction())

We kind-of already have the option of being sync/async because Future<Null> is nullable, so there are methods that may return either null or a Future. The StreamController.close method is allowed to return null (but discouraged, going on deprecated - in Dart 2.0 it might become non-nullable).

Function type argument type

Future<T> handleSomething<T>(Future<T> action(FutureOr<T> value)) ...

It's likely to be rare to require a function with an argument type of FutureOr<T>. It might make sense for some kinds of callbacks or helper function abstraction that have to handle a FutureOr<T> value. The actual argument function really has to take FutureOr<T> or Object as argument, it can't take just a value or just a function (like for return types), which reduces the convenience of using the union. If we allow covariant callbacks, it could be used to call with a function that only actually supports either a future or a value, and throws if it gets the other.

Function type return type

Future<S> then<S>(FutureOr<S> handler(T value), …)

This pattern is used for existing functions like Future.then.

Since FutureOr<S> here occurs covariantly in the parameter type, it's possible to pass either a function returning S or one returning Future<S> - the way function subtyping works, (()->T | ()->Future<T>) is a subtype of ()->(T|Future<T>).

Again, it's unlikely that there are many actual functions returning a FutureOr result, but this allows functions returning either values or futures to be passed to the same function.

Type Literal expression

The expression FutureOr by itself represents a type, FutureOr<dynamic>. Probably not useful.

This is similar to how typedef names evaluate to a Type object for a function type.

Type instantiation

The FutureOr type constructor acts like a generic type class, so it can be instantiated with a type argument FutureOr<int>.

Type arguments can be inferred, so if you have a function:

Future<T> foo<T>(FutureOr<T> value) { … }

called as

Future<int> x = ...;
foo(x);

then the type argument to foo is inferred from the type Future<int>.

There are two ways to infer T - as int or as Future<int>. We should infer int. That is probably doable since it's a common problem for union types and picking the most specific solution is usually possible.

Instantiation with Object and Future<Object> both lead to degenerate unions, as discussed above. Instantiating with bottom gives just Future<bottom> - a future that never completes with a value.

Type arguments

new List<FutureOr<int>>()..add(42)..add(new Future<int>.value(42));

Currently not used, and is the reason that we have an infinite chain of super types of all types.

Shouldn't be a problem in practice.

Since type arguments are available as type variables, it means that an async type can flow into any position where a type parameter can occur.

Structural types everywhere isn't new - function types are also structural and can flow anywhere as a type parameter.

If we wanted to restrict where FutureOr<T> can occur (which we probably don't), we would need to disallow its use as a type argument since type variables can occur everywhere. We do have to accept nullable types, so we can't blanket-reject union types, so FutureOr probably doesn't warrant special treatment.

Can FutureOr<T> be inferred as a type argument if it doesn't appear in the original expression?

var x = [42, new Future<int>(42)];  // -> List<FutureOr<int>> ?

Probably not worth it, but if we get general union types, it's possible that we may want it then.

Type parameter bounds

class C<T extends FutureOr<int>> {
  T get something {
    int v = hubba();
    if (v is T) return v;
    return new Future<int>(v) as T;
  }
}

This is highly suspicious coding, but technically correct.

There is no real problem with it, even if we decide to disallow FutureOr<T> as a type argument.

I fear that someone might want to write a function that works as both synchronous and asynchronous, and that this can be used to do it, which we should discourage. It's too close to template metaprogramming.

Type check

x is FutureOr<T>

This is technically easy to describe, but pretty useless.

It can make sense in a situation where you are filtering out objects that can be used in a context where either a value or a future of a specific type is expected, like an await expression or a return statement in an async function, but that seems incredibly rare.

An is check can also cause type promotion. Type promotion happens when the checked type is a subtype of the currently known static type of the variable.

Even if you do get type promotion, which would probably mean that x has type Object, you still don't get a useful type. Just check against T or Future<T> directly.

Doing FutureOr<int> x = foo; … if (x is int) ... is useful and should work with type promotion.

Doing FutureOr<int> x = foo; … if (x is Future<int>) ... is equally useful.

Doing FutureOr<int> x = foo; … if (x is Future) … is not as useful. Future is not a subtype of int and it's not a subtype of Future<int> so there is no type promotion, unless we infer the <int> (which strong mode might actually do).

On the other hand, for general union types, we might want to have type promotion for negative tests that are known to reject some parts of a union. For example:

int|double|String x = …; 
if (x is !String) … /* x is int|double */

That kind of "promotion by elimination" is a new kind of promotion that isn't in the current specification and that only applies to union types. We probably want something like that for nullable types with Foo? x = …; if (x != null) … /* x is Foo */.

Promotion in the presence of type parameters can be less than intuitive due to FutureOr<Future<Object>> being equivalent to Future<Object>:

void foo<T>(FutureOr<T> x) {
  if (x is Future) {
    // When T is Future<int> we can only conclude that x is either 
    // Future<int> or Future<Future<int>>, so we don't get useful type promotion anyway.
    // Since this is generic code, and type promotion doesn't depend on the value of `T`, 
    // we never get type promotion here.
  } else {
    // x is definitely T.
  }
}

Type cast

x as FutureOr<T>

Pretty unusable, doubt we will see it, but it's easily expressible in terms of an is check, so shouldn't be a problem to implement.

Try/On clause

Effectively the same as an is check. Should not happen in practice. Please don't throw futures!

LUB computation

What is the LUB of FutureOr<T> and Future<T>? Is it FutureOr<T>?

General union type semantics suggests it is at least equivalent to FutureOr<T>.

If we have unification of type parameters as part of LUB computation, we should be prepared for the LUB of FutureOr<int> and Future<double> which should be … FutureOr<num>?

Member checks/lookup

Is the following allowed or not?

FutureOr<int> x = …;
x.foo();

General union type behavior would say no. You can only call a method if it's declared by a common supertype of the types of the union. Since Future extends Object directly, the only members shared between T and Future<T> are the ones from Object … unless T extends Future.

If you have an FutureOr<Future<int>> (aka Future<int> | Future<Future<int>>) then maybe you can call methods of Future, but only if the arguments are valid for both types of futures. Example:

FutureOr<Future<int>> x = …;
Future<int> handle(Object x) => new Future<int>.value(x);
x.then(handle);  // Could be valid, but please don't code like this!

This may or may not be valid. The actual type of x is either Future<int> or Future<Future<int>> which have different types for the then method. However, we can see that the call to then is valid for both types, or we can see that it's valid for the common supertype Future<Object>, and conclude that the call is valid.

Checking that a call is valid for each type of the union is a structural property. We would like to avoid that, especially since strong mode implementations use static types to know how to call a method. We would like to only call the method if it's actually the *same *method implemented by both types.

We usually don't check a supertype for whether a method call is valid when we know a subtype - the subtype must correctly implement the super type interface so it isn't necessary - but for union types we are in a position where we can know a common supertype (or infinitely many of them, for that matter) without knowing the actual subtype. Even if Future<Object> is a super-type it isn't a super*-class* of either type. That might be what should make the difference.

This is actually one reason for not having property (3) of union types - it requires us to either find a least upper bound of int and Future<int> to see which members are available. An alternative is to check whether a member access is valid not just by looking at the type itself, but look at all supertypes that implement the function. That's probably not tractable since we have infinitely many supertypes for some (or all) types, so we need a way to reduce the problem to something finite.

Assignability

Which values are assignable to FutureOr<num>, and which types is something with static type FutureOr<num> assignable to?

The first question is easy, any instance of a subclass is assignable, which means all instances of num and Future<num> and also anything actually statically typed as FutureOr<num>, and by subtyping, anything typed as FutureOr<int> or FutureOr<double>. Those are safe assignments, any instance of those types are either a num or a Future<num>. This comes out of the rules for subtyping of unions since you can assign something to any supertype of its static type.

For the other direction, a value of a union type is only assignable to a type that is a supertype of all the elements of the union. So, for FutureOr<int>, the primary supertypes are FutureOr<num>, FutureOr<Object> and Object, and then there is are infinite chains of FutureOr<FutureOr<num>, FutureOr<FutureOr<Object>>, Async<Comparable<num>>, etc. above.

So, to check whether a type is assignable to a union type, check if it's assignable to at least one of the parts. To check whether a union type is assignable to another type, check if all parts of it are individually assignable to that type.

Nullability

There should be no problem interacting with nullable types, FutureOr<int?>? is still reasonable, it's the union int | Null | Future<int?> and everything said above about what to do with unions in general still applies.

Nesting

You can write FutureOr<FutureOr<int>> and it means int | Future<int> | Future<FutureOr<int>>.

That's probably not a problem, nor is it likely to ever be useful.

It does mean that we can have arbitrarily large union types (as opposed to nullability which only allows one level by itself because it's idempotent).

Interaction with flattening

Our futures do flattening, which is the reason you never see a Future<Future<int>> in practice. You can write the type, but you usually can't actually create a value of that type without running into problems when you try to complete it (with some exceptions, e.g., null).

That means that a construct like FutureOr<Future<T>> is unlikely to be practical because it contains a nested future type.

Can we write something that is correctly typed using FutureOr but which fails to produce the correct type due to future flattening? Probably not. Since an FutureOr<T> isn't useful by itself, the code will always eventually try to cast to either T or Future<T>, and that cast will be checked at runtime because it's a down-cast. Any problem should be caught at that point.

We will likely have to go through the code and ensure that we handle FutureOr<Future<int>> correctly. A test of if (x is Future) isn't sufficient to conclude that it's a Future<Future<int>, it could also just be a Future<int>, so we need to do is Future<int> to be sure to know which part of the union we are in.

That is, code like the following is bad:

/// Check if x is value or x is a future that completes with value.
Future<bool> check<T>(FutureOr<T> x, T value) async {
  if (x is Future) {
    // Doesn't cause type promotion to Future<T>.
    return await x == value;
  } else {
    return x == value;
  }
}

because it will fail to do the correct thing for:

Future<int> f = new Future<int>.value(42);
check<Future<int>>(f, f).then(print);  // Should print true, prints false - 42 != f.

In other cases, sending all futures to the async path is correct, and we should just be sure not to assume anything about the future's value's type (and is Future doesn't do that).

Question: Could we remove flattening. It worked fine in an untyped setting, but in a typed setting, maybe we could just ignore it. It would mean that a Future<Future<int>> is possible to create, but we could make it a lint if you actually write it.

Alternatives

The goal is to give type information to the analyzer and strong-mode compiler. Explicitly writing FutureOr<T> gives that information but also introduces a complicating factor that can occur everywhere else.

Other possible solutions to the same problem includes:

Method overloading

class Future<T> { 
  … 
  Future<S> then<S>(S handler(T value), … ) { … }
  Future<S> then<S>(Future<S> handler(T value), …) { … }
  …
}

Declare multiple methods, use the argument type to pick the correct one.

This is what other languages with method overloading do, but it fits badly into Dart.

Dart allows tearing off a method just by writing o.methodName. This would not distinguish between different methods with the same name, and we might actually want to tear off all the methods with that name as one overloaded method value.

Also, Dart allows you to call methods dynamically, without static type information, so the runtime system also needs to be able to dispatch.

It's a big hammer to solve this problem.

Only works for function parameters, but that handles all the current uses.

Method parameter pattern matching

class Future<T> { 
  … 
  Future<S> then<S>(S handler(T value), … ) { … }
                  |(Future<S> handler(T value), … ) { … }
  …
}

Similar to overloading, but more explicitly treats it as one function that does pattern matching on the parameters. Desugars to dispatch code in the function body, but has a structure that the analyzer can use to detect types.

Only works for function parameters, but that handles all the current uses.

Add more methods

Do manual overloading by changing the name of one of the function variants.

class Future<T> { 
  … 
  Future<S> then<S>(S handler(T value), … ) { … }
  Future<S> chain<S>(Future<S> handler(T value), … ) { … }
  …
}

This is what we originally had, and it was a great usability improvement to combine the two functions into one, so going back will be a regression.

Picard for FutureOr

Only accept a Future, then rely on Picard to auto-coerce a synchronous function into an async one, or a synchronous value into an asynchronous future.

class Future<T> { 
  … 
  Future<S> then<S>(Future<S> handler(T value), … ) { … }
  …
}
… 
int foo(int x) => … ;
intFuture.then(foo!);
Future<int> value = 4!;  // No need for Future.value any more.

Using Picard to convert from T to Future<T> might be overstepping the limits on how obvious the conversion implied by Picard should be. The resulting code should still be readable if you ignore the ! conversions, they are just doing "intuitive" things. Whether coercing a synchronous function into an asynchronous one is obvious isn't clear.

Do nothing

Just use dynamic as the type. It prevents some very useful type inference of very often used methods, so it's a usability regression from even the ad-hoc special casing we have now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment