-
Notifications
You must be signed in to change notification settings - Fork 220
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
void writeLogs(Object stringOrListOfString) {
if (stringOrListOfString is String) {
_writeLog(stringOrListOfString);
} else if (stringOrListOfString is List<String>) {
stringOrListOfString.forEach(_writeLog);
} else {
throw ArgumentError.value(stringOrListOfString, 'Not a String or List<String>');
}
}
Problems:
- No static type safety. The user can pass an
Octopus
, and only receive an error at runtime:
void main() {
// No static error.
// Runtime error: "Instance of 'Octopus': Not a String or List<String>".
writeLogs(Octopus());
}
- Relies on complex TFA for optimizations, which fall apart with dynamic access:
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:
void writeLog(String log) {
_writeLog(log);
}
void writeLogList(List<String> 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:
void writeLog(String | List<String> logOrListOfLogs) {
if (stringOrListOfString is String) {
_writeLog(stringOrListOfString);
} else if (stringOrListOfString is List<String>) {
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>(T | List<T> | Map<T, List<T> | ....)
, 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)
:
void writeLog(String | List<String> logOrListOfLogs) {
when (logOrListOfLogs) {
String: {
_writeLog(logOrListOfLogs);
}
List<String>: {
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):
void writeLog(String log) {
_writeLog(log);
}
void writeLog(List<String> logs) {
logs.forEach(_writeLog);
}
... this solves all of the above concerns. It does not allow dynamic calls, but neither will static extension methods 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:
// success.json
{
"status": "SUCCESS"
}
// failure.json
{
"status": "ERROR",
"reason": "AUTHENTICATION_REQUIRED"
}
Modeling this in Dart is especially difficult:
void main() async {
Map<String, Object> 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 or data classes of some form, and let's even assume NNBD to boot.:
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):
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.
TypeScript would model this as:
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);
}