dart-lang / dart-lang/language

Users want to define union or union-like APIs

Open
#145 27 comments 133 reactions 0 assignees View on GitHub
request union-types
Dominant language
TeX
Stars
2.9k
Forks
239
Avg merge
2d 18h
Merged PRs (30d)
14

Description

_Based on conversations with @yjbanov, @leonsenft, @mdebbar._

Currently, the Dart language lacks a way to provide static union or union-like semantics or APIs. Multiple other platforms take different approaches - anything from user-definable union types, algebraic/tagged unions, method overloading, and I'm sure other approaches we missed.

Let's look at two examples:

## APIs that take nominal types A or B

```dart
void writeLogs(Object stringOrListOfString) {
if (stringOrListOfString is String) {
_writeLog(stringOrListOfString);
} else if (stringOrListOfString is List) {
stringOrListOfString.forEach(_writeLog);
} else {
throw ArgumentError.value(stringOrListOfString, 'Not a String or List');
}
}
```

### Problems:

* No static type safety. The user can pass an `Octopus`, and only receive an error at runtime:

```dart
void main() {
// No static error.
// Runtime error: "Instance of 'Octopus': Not a String or List".
writeLogs(Octopus());
}
```

* Relies on complex TFA for optimizations, which fall apart with dynamic access:

```dart
void main() async {
// Inferred as "dynamic" for one reason or another.
var x = something.foo().bar();

// No static error. Even if it succeeds, all code paths are now retained (disables tree-shaking).
writeLogs(x);
}
```

### Solutions

* A clever use can simply just write two functions:

```dart
void writeLog(String log) {
_writeLog(log);
}

void writeLogList(List logs) {
logs.forEach(_writeLog);
}
```

... unfortunately, this now means you often need to think of convoluted API names like `writeLogList`.

* Something like user-definable union types:

```dart
void writeLog(String | List logOrListOfLogs) {
if (stringOrListOfString is String) {
_writeLog(stringOrListOfString);
} else if (stringOrListOfString is List) {
stringOrListOfString.forEach(_writeLog);
} else {
// Bonus: Can remove this once we have non-nullable types.
throw ArgumentError.null(logOrListOfLogs);
}
}
```

... unfortunately this **(a)** Can't have different return types, and **(b)** might have complex side-effects with reified types (i.e. expensive performance reifying and storing `writeLog(T | List | Map | ....)`, and **(c)** just looks ugly compared to the rest of the language.

@yjbanov did mention a first-class `match` or `when` could help with `(c)`, but not `(a)` or `(b)`:

```dart
void writeLog(String | List logOrListOfLogs) {
when (logOrListOfLogs) {
String: {
_writeLog(logOrListOfLogs);
}
List: {
logOrListOfLogs.forEach(_writeLog);
}
Null: {
// Bonus: Can remove this once we have non-nullable types.
throw ArgumentError.null(logOrListOfLogs);
}
}
}
```

* Something like user-definable method overloads (my preference in this scenario):

```dart
void writeLog(String log) {
_writeLog(log);
}

void writeLog(List logs) {
logs.forEach(_writeLog);
}
```

... this solves all of the above concerns. It does not allow dynamic calls, but neither will [static extension methods](https://github.com/dart-lang/language/issues/41) and neither do, say, named constructors or separate methods (used today), so I don't see this as a net negative.

## APIs that structural types A or B

@dantup ran into this while defining Microsoft Language Service protocols. Imagine the following JSON:

```js
// success.json
{
"status": "SUCCESS"
}
```

```js
// failure.json
{
"status": "ERROR",
"reason": "AUTHENTICATION_REQUIRED"
}
```

Modeling this in Dart is especially difficult:

```dart
void main() async {
Map response = await doThing();
final status = response['status'] as String;
if (status == 'SUCCESS') {
print('Success!');
} else if (status == 'ERROR') {
print('Failed: ${response['reason']}');
}
}
```

You can write this by hand, of course, but imagine large auto-generated APIs for popular services. At some point you'll drop down to using code generation, and it's difficult to generate a good, static, model for this.

## Problems

Let's imagine we get [value types](https://github.com/dart-lang/language/issues/125) or data classes of some form, and let's even assume [NNBD](https://github.com/dart-lang/language/issues/110) to boot.:

```dart
data class Response {
String status;
String? reason;
}
```

This _works_, but like the problems in the nominal types above, you need runtime checks to use the API correctly. This can get very very nasty on giant, popular APIs (like Microsoft's Language Service, but many many others including Google's own):

```dart
void main() async {
var response = await getResponse();
// Oops; this will never trigger, because we did not capitalize 'ERROR'.
if (response.status == 'error') {
print('ERROR!');
return;
}
// Oops; this will print 'Yay: null' because success messages do not have a reason field.
if (response.status == 'SUCCESS') {
print('Yay: ${response.reason}');
return;
}
}
```

## Solutions

One way this could be solved is having user-definable [tagged unions](https://blog.mariusschulz.com/2016/11/03/typescript-2-0-tagged-union-types).

TypeScript would model this as:

```ts
type Response = IResponseSuccess | IResponseFailure;

interface IResponseSuccess {
status: "SUCCESS";
}

interface IResponseFailure {
status: "ERROR";
reason: string;
}

async function example_1() {
const response = await getResponse();
// Static error: "status" must be "SUCCESS" or "ERROR", got "error".
if (response.status == 'error') {
console.log('ERROR!');
return;
}
}

async function example_2() {
const response = await getResponse();
if (response.status == 'ERROR') {
console.log('ERROR!');
return;
}
// Automatically promotes "response" to "IResponseSuccess"!
// Static error: "reason" does not exist on "IResponseSuccess".
console.log('Yay: ', response.reason);
}
```

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.