language icon indicating copy to clipboard operation
language copied to clipboard

Provide a way to "unzip" nullability by destructring a nullable record of values into a non-nullable record of nullable values

Open mateusfccp opened this issue 2 years ago • 5 comments

Consider the following code:

void main() {
  final (a, b) = getIntegers();
}

(int, int)? getIntegers() => (0, 0);

The code generates the following error:

error: The matched value of type '(int, int)?' isn't assignable to the required type '(Object?, Object?)'. (pattern_type_mismatch_in_irrefutable_context)

This can be solved by many ways, like providing a default value, using bang or not using destructure:

void main() {
  // Option 1
  final (a, b) = getIntegers() ?? (0, 0);

  // Option 2
  final (a, b) = getIntegers()!;
  
  // Option 3
  final integers = getIntegers();
  final a = integers.$1 ?? 0;
  final b = integers.$2 ?? 0;
}

(int, int)? getIntegers() => (0, 0);

However, sometimes we want to have the destructured values to be nullable when the record value is nullable. It would be useful to have a way to do this while destructuring. It would work like a method unzip on a functor F (a b) => (F a, F b)).

Example:

void main() {
  final (a?, b?) = getIntegers(); // a and b are int? instead of int, no errors
}

(int, int)? getIntegers() => (0, 0);

mateusfccp avatar Dec 29 '23 01:12 mateusfccp

Basically, asking to let (int, int)? destructure into (int?, int?)? I think you're losing some clarity if you mix "the elements in a record are nullable" with "the record itself is nullable". Wouldn't it be clearer to say getIntegers() ?? (null, null)?

Levi-Lesches avatar Dec 29 '23 04:12 Levi-Lesches

This may be worthy of a more general feature.

Consider an "optional pattern", where p?? matches the matched value against the pattern p of the value is non-null, but accepts if the value is null, and binds all variables declared in p to null, effectively changing their type to be nullable if necessary.

If (person case Person(name: (var first, var last)??)) ...

If the person has no name, the record is still accepted, but first and last are null, and they are always nullable.

Could also be used for map entries:

... case {"key"??: p}

This would match even if there was no "key" entry, but the variables of p becomes nullable, and null of there is no key.

Or maybe not make it null-specific, just allow any kind of sub-pattern to fail, but if it does, all its bound variables become null instead of rejecting.

case (String(isEmpty: false) && var s)??

This would match any non-empty string, and s to null on anything which is not a non-empty string.

lrhn avatar Dec 29 '23 09:12 lrhn

Wouldn't it be clearer to say getIntegers() ?? (null, null)?

It's only practical when you want to default to null. It gets cumbersome when you are dealing with more complex objects and only care for one or few fields.

For instance, using ?? I would have to do:

final defaultObject = ComplexObject(
  fieldA: 0,
  fieldB: 10, // I only care for fieldB of the incoming object, but I have to create an entire empty object to use as a fallback to the null value
  fieldC: 0,
  fieldD: 0,
  fieldE: 0,
);

final (complexObject, myInteger) = getObjects() ?? (defaultObject, 0);
print(complexObject.fieldB);

With the proposed approach I would do instead:

final (complexObject?, myInteger) = getObjects();
print(complexObject.fieldB ?? 10);

It's even worse if the incoming object is not data-value-like, as in this case the internal state of the incoming object could be different from the newly created fallback object.

mateusfccp avatar Dec 29 '23 10:12 mateusfccp

Could also be used for map entries:

... case {"key"??: p}

This would match even if there was no "key" entry, but the variables of p becomes nullable, and null of there is no key.

It seems to be an idea similar to what is proposed in #2496.

mateusfccp avatar Dec 29 '23 10:12 mateusfccp

I agree with @Levi-Lesches that I don't think pattern matching should do this lifting implicitly. But I do think there's merit to, essentially, a "null-aware" pattern match operator of some kind. Note that for the specific case of records with positional fields, you could write some extensions to alleviate this:

extension NullableRecord2<T1, T2> on (T1, T2)? {
  (T1?, T2?) get ifNotNull => switch (this) {
    (var f1, var f2) => (f1, f2),
    _ => (null, null),
  };
}

// Likewise for other arities...

void main() {
  // Option 4
  final (a, b) = getIntegers(true).ifNotNull;
  print('$a $b');

  final (c, d) = getIntegers(false).ifNotNull;
  print('$c $d');
}

(int, int)? getIntegers(bool b) => b ? (0, 0) : null;

munificent avatar Jan 02 '24 19:01 munificent