language
language copied to clipboard
Type promotion for variables typed as a sealed class based on control flow
With null checks, we do type promotion:
void example() {
final i = Random().nextBool() ? null : 1;
if (i == null) {
return;
}
print(i + 3); // We don't need a `!` on `i`.
}
It would be nice if we could do the same with sealed classes
void example() {
final result = Random().nextBool() ? const Success(1) : const Failure(123.0);
if (result is Failure) {
return;
}
print((result as Success).value + 3); // This `as Success` is redundant and could be inferred by the compiler.
}
sealed class Result {
const Result._();
}
class Success extends Result {
final int value;
const Success(this.value) : super._();
}
class Failure extends Result {
final double value;
const Failure(this.value) : super._();
}
The workaround is using exhaustive switches.
void example() {
final result = Random().nextBool() ? const Success(1) : const Failure(123.0);
switch (result) {
case Failure():
return;
case Success(:final value):
print(value + 3);
break;
}
}
However, using switches leads to deeply nested code if there are all kinds of failures. It would be much nicer to have early returns and the type to be promoted to the success type.
I'm not entirely sure if this would be feasible, it would kind of work by terms of elimination of cases when there are multiple subtypes involved, but this would then interact with flow analysis. (The exhaustiveness in switches doesn't have to deal with flow analysis, and the type promotion from nullable to non-nullable types only deals with two subtypes of T?: T and Null.)
Yeah, the only thing you could do is check if it's NOT Success and return early, then obviously it would be promoted to Success. Though, you wouldn't have any protection if you were to add a new case and wanted to handle that separately.
void example() {
final result = Random().nextBool() ? const Success(1) : const Failure(123.0);
if (result is! Success) {
return;
}
print(result.value + 3); // result is now Success
}
Adding a new subtype to a sealed type is already a breaking change.
However, this example looks easy because there are only two subtypes, so let's assume a sealed type with three subtypes (Choice with subtypes Yes, No and Maybe).
If you do
void foo(Choice c) {
if (v is Maybe) {
...
} else {
// (1): What is type of `c` here?
bar();
if (c is Yes) {
...
} else {
// (2): What is type of `c` here?
}
}
}
We cannot promote to Yes|No, that's not a type.
If that is not the type of c at (1), what will the type be at (2)?
Preferably it should work like this works in switches, but they are special for a number of reasons:
- they prevent changing the value between tests (the matched value is captured at the start, it doesn't read a variable more than once, and properties are cached when read the first time).
- they use a special "space-exhaustion" algorithm to check that matching is exhaustive, and that the last subtype of sealed type is actually the last option left.
Then again, the exhaustiveness algorithm is used to check exhaustiveness, but promotion is done by type inference before that, so the ability to recognize that No is the only thing left, should already exist.
It's then a question of how far-reaching this ability should be. Does it work on (Choice, bool, Result)? On case matches in general?
It would be nice if
if (c is Maybe) {
...
} else if (c is Yes) {
...
} else {
... // c is No
}
Or
if (c case Maybe m) {
...
} else if (c case Yes y) {
...
} else {
... // c is No
}
worked the same as a similar switch, but the moment it starts reading properties of anything except a record, it cannot assume that it's the same value between tests, like the switch can.
Could still potentially work for direct type checks of values and records only.
The workaround is using exhaustive switches.
void example() { final result = Random().nextBool() ? const Success(1) : const Failure(123.0); switch (result) { case Failure(): return; case Success(:final value): print(value + 3); break; } }However, using switches leads to deeply nested code if there are all kinds of failures. It would be much nicer to have early returns and the type to be promoted to the success type.
Personally, I wouldn't describe that as a workaround. Switch statements and expressions are (or at least should be) the normal way to work with sealed types.
I'm not entirely sure if this would be feasible
This means the language would have to reason about "negative types" or the set of types left after you subtract one or more subtypes from a sealed type. If there is only one type left, we have a type we can use to represent that: the remaining one. But if there are multiple left, as @lrhn notes, we don't have a way to model that in the type system.
If we had union types, this is maybe a thing we could do. But personally, I would consider using a switch here instead. Dart is in a really unusual position in that it has both pattern matching with exhaustiveness and type promotion/flow typing/smart casts. That means you often end up having two ways get from a supertype to a subtype and it's often not clear which one is the best. Most languages just pick one or the other (pattern matching for Rust and Swift, flow typing for Kotlin and TypeScript).
Often, it's handy to support both, but sometimes users end up in the uncanny valley where it's not clear which one makes the most sense to use or one isn't quite as full-featured as the other. Also, the interaction between flow analysis and exhaustiveness checking isn't as seamlessly integrated as it could be.
Personally, I wouldn't describe that as a workaround. Switch statements and expressions are (or at least should be) the normal way to work with sealed types.
That doesn't really work. We'd end up with 5 switches deep if you have 5 things that can fail.
An alternative is monadic style with a .then(...). But Dart is much less nice doing monadic style code. It's horrible to debug, one can't just step through it. (Which is why we always write awaits instead of doing Future.then.)
If we had union types, this is maybe a thing we could do.
In that case I would just use a union instead of an Either or Result with two type arguments describing the union. 😄 I guess this is my poor-mans-union-type. 😄
I ended up not using the type system for my use case:
final fooResult = fooMethod(..);
if (fooResult.isFailure) {
return fooResult.asFailure;
}
final foo = fooResult.success;
This gives un-indented, un-closured straight line code similar to awaits. The downside is that it requires users to know about such pattern.
https://github.com/dart-lang/native/blob/79df0dcfb27265f669229530f9d2fbaa712fd735/pkgs/hooks_runner/lib/src/build_runner/build_runner.dart#L123-L127
That doesn't really work. We'd end up with 5 switches deep if you have 5 things that can fail.
You can do a parallel switch:
switch ((result1, result2, ... , result5)) {
case (Failure f, _, _, _, _):
case (_, Failure f, _, _, _):
case (_, _, Failure f, _, _):
case (_, _, _, Failure f, _):
case (_, _, _, _, Failure f):
...
}
(Handle individually or all the same.)
That only works if you have parallel computations. If you want to continue computing on success, then:
final Foo foo;
switch (computeFoo(...)) {
case Failure f: return f;
case var s: foo = s.value;
}
or
final Result<Foo> fooResult = computeFoo(...);
switch (fooResult) {
case Failure f: return f;
}
final foo = fooResult.value;
should work.
(If we made return an expression, maybe even
final foo = switch (computeFoo(...)) {
Failure f => return f,
var s => s.value,
};
)
Also:
if (fooResult.isFailure) {
return fooResult.asFailure;
}
can be
if (fooResult.asFailure() case var f?) {
return f;
}
A good API shouldn't need to do test+cast, the test should also do the cast. Either as a pattern with binding, or by returning a cast value or null.
With how we are currently (no union types) I think Dart would really benefit from having a return expression precisely for this use case as exemplified by Irhn:
final foo = switch (computeFoo(...)) {
Failure f => return f,
var s => s.value,
};
In fact, we can actually use this pattern right now by instead using the throw expression:
sealed class Union3<A, B, C> {
Union3<AP, BP, CP> cast<AP, BP, CP>() => this as Union3<AP, BP, CP>;
}
class FirstU3<A, B, C> extends Union3<A, B, C> {
final A value;
FirstU3(this.value);
}
class SecondU3<A, B, C> extends Union3<A, B, C> {
final B value;
SecondU3(this.value);
}
class ThirdU3<A, B, C> extends Union3<A, B, C> {
final C value;
ThirdU3(this.value);
}
final class Unit {
const Unit();
}
const unit = Unit();
Union3<A, B, Unit> example<A, B>(Union3<A, B, void Function()> union) {
try {
final third = switch (union) {
final FirstU3 _ => throw union,
final SecondU3 _ => throw union,
final ThirdU3 third=> third,
};
// this is now "promoted" to ThirdU3
third.value();
return ThirdU3(unit);
} on Union3<A, B, void Function()> catch (union) {
return union.cast();
}
}
If we had return expressions:
Union3<A, B, Unit> exampleWithReturn<A, B>(Union3<A, B, void Function()> union) {
final third = switch (union) {
final FirstU3 _ => return union.cast(),
final SecondU3 _ => return union.cast(),
final ThirdU3 third=> third,
};
// this is now "promoted" toThirdU3
third.value();
return ThirdU3(unit);
}
This in turn would enable the language team to add a synctic sugar to the language, the very convenient try (Zig) or ? try operator suffix (Rust):
https://ziglang.org/documentation/0.14.1/#try https://doc.rust-lang.org/std/ops/trait.Try.html and its new proposal https://github.com/rust-lang/rust/issues/84277
Union3<A, B, Unit> exampleWithTry<A, B>(Union3<A, B, void Function()> union) {
final third = try union;
// this is now "promoted" to ThirdU3
third();
return ThirdU3(unit);
}
Now, you might ask, but how does the language decide which of the possible subtypes of the sealed class third should be?
We could do a couple of things, but the most obvious, and, in my opinion, the better solution would be to force the variable to be explicitly typed:
Union3<A, B, Unit> exampleWithTry<A, B>(Union3<A, B, void Function()> union) {
final ThirdU3 third = try union;
// this is now "promoted" to void ThirdU3
third();
return ThirdU3(unit);
}
This would allow the user to choose which of the possible values they want:
Union3<A, int, C> exampleWithTry<A, C>(Union3<A, String, C> union) {
final B b = try union;
// this is now "promoted" to String
return SecondU3(b.str.length);
}
With that we would improve the usage of sealed classes, especially for the usage of Errors and Unions ( via sealed classes ).
You might have realised, though, that using by using sealed classes to emulate unions, we can only get ordered unions. Indeed, if we try to change the order:
Union3<C, int, A> exampleWithTry<A, C>(Union3<A, String, C> union) {
final B b = try union;
// this is now "promoted" to B
return SecondU3(b.str.length);
}
For the cases where union is FirstU3 and ThirdU3 we will get a cast error, because our cast function is very naive. I have not found any way to solve this problem using casts (a.k.a without incurring "costly" operations like mapping).
Now, I presented the use case of sealed class Union types because I personally use them and would like to see their usage simplified, but going back to the original post's example we could have:
sealed class Result<T> {
const Result();
}
class Success<T> implements Result<T> {
const Success(this.value);
final T value;
}
class Failure implements Result<Never> {
final Object? error;
Failure(this.error);
}
Result<bool> example(Result<int> result) {
try {
final success = switch (result) {
final Failure _ => throw result,
final Success success => success,
};
return Success(success.value == 1);
} on Failure catch (failure) {
return failure;
}
}
// Prints:
// true
// false
// Exception: :(
void main() {
final Result<int> successTrue = Success(1);
final Result<int> successFalse = Success(2);
final Result<int> failure = Failure(Exception(':('));
final result_1 = example(successTrue);
final result_2 = example(successFalse);
final result_3 = example(failure);
if (result_1 case Success<bool> f) {
print(f.value);
}
if (result_2 case Success<bool> f) {
print(f.value);
}
if (result_3 case Failure f) {
print(f.error);
}
}
// support type
final class Unit {
const Unit();
}
const unit = Unit();
I believe this desugaring could be implemented with some form of metaprogramming, whenever that lands.