4 ms·
I really wish Rust had proper union types. So much ceremony over something that could be Foo | Bar | Error
by _benton 1y ago
I really wish Rust had proper union types. So much ceremony over something that could be Foo | Bar | Error
- rtpg 1y agoI've thought about this a good amount too because Typescript gets so much out of untagged unions but I think that with a language like Rust you get into a huge mess due to it messing up inference and also Rust _really_ wanting to know the size of a type most of the time. let mut x = 1 x = false is x a usize? a bool? a usize | bool? let mut x = if some_condition { 1 } else { false } is x x a usize? a bool? a usize | bool? One could make inference rules about never inserting unions without explicit intervention of user types. But then you get into (IMO) some messy things downstream of Rust's pervasive "everything is an expression" philosophy. This is less of a problem in Typescript because you are, generally, much less likely to have conditionally typed expressions. There's a ternary operator, but things like `switch` are a statement. So in production code Typescript, when presented with branching, will have a downstream explicit type to unify on. Not so much in Rust's extensive type chaining IMO. And then we start talking about Into/From and friends.... I don't really think that you want a rule like `(if cond { x: T } else { y: U}) : T | U` in general. You'll end up with _so many_ false negatives and type errors at a distance. But if you _don't_ have that rule, then I don't know how easily your error type unification would work.
- _benton 1y agoWell, let x = 1; x = false; is a type error in TS anyways. let x: Number | Boolean = 1 is fine but also clear about its allowed types. Cant you do something like let mut x: Result<Either<Foo, Bar>, Error> in Rust? Same thing, just more ceremony?
- rtpg 1y agoYeah my point is more about in type inference. If you explicitly annotate the expression's type then I'm not worried. Just like.... if you infer the union then all your type errors are going to shift around and you'll have to do more hunting to figure out where your stuff is. And my impression is that Rust has a lot more expression inference going on in practice than TS. But just an impression.
- madeofpalk 1y agoI don't understand the ambiguity. let mut x = 1 x = false In TS, x is inferred as usize, second line is an error. let mut x = if some_condition { 1 } else { false } In TS, x is inferred as usize | bool. Is there something specific to rust that makes this less clear that I'm missing?
- rtpg 1y agoIn TS you get literals, which is its own dimension of valuable tooling of course. So inferring as an untagged union is not wrong of course! It's just that if you are always inferring the type of an if expression to A | B, then this will also happen unintentionally a lot. And so at the end of some code, when you actually use x, then you'll see an error like "expected usize, got usize | bool". In the case that this was a mistake, you're now looking at having to manually figure out why x was inferred this way. In typescript your "if expression" is a ternary expression. Those are quite rare. In rust they're all over the place. Match statements are the same thing. Imagine having a 10 clause match statement and one of them unintentionally gives a different type. There's even just the classic "semicolon makes the branch into a ()"! So always inferring a union across branches of an if expression or a match means that your type errors on genuine mistakes are almost never in the right spot. Of course we can annotate intermediate values to find our way back. Annotating intermediate values in a chained expression is a bit miserable, but it is what it is. Decent typescript tends to not have this problem because there are few syntactic structures where you need to evaluate multiple branches to figure out the type of an expression. And the one big example (return values)... well you want to be annotating the return value of your functions in general. Rust is in a similar space for Result types, at least. But I don't think it generalizes at all. If you start inferring union types, and combine that with trait resolution, I _think_ that we'd end up with much less helpful error messages in the case of actual mistakes, because the actual location of the error will be harder to find. let mut x = if some_condition { 1 } else { false } // bunch of code return f(x) // expected usize, got usize | bool TS gets away with this stuff because JS's object model is simple (TS doesn't need to do any form of trait resolution!) and the opportunities to introduce unions implicitly are relatively few in TS code in general. And this isn't even really getting into Rust needing to actually implement untagged unions if they had them! Implicit tagging feels off in a language very serious about not having expensive hidden abstractions. But how are you going to guarantee bit layout to allow for the differentiation here? I'm saying all of this but I'd love it if someone showed up with a good untagged union proposal to Rust, because I _like_ the concept. Just feels intractable
- metaltyphoon 1y agoLook at Zig then, as it does exactly this. However you can’t carry any context and it’s also a problem.
- _benton 1y agoZig has far too many other issues tho (lack of interfaces??) for me to seriously consider it a competitor to TS's type system.
- metaltyphoon 1y agoI 100% agree with you. I wanted OP to see: > So much ceremony over something that could be Foo | Bar | Error Is not really a good idea and he can see this done in Zig.
- kelnos 1y agoYeah, it's frustrating that there's no syntax for this. It could even be syntactic sugar; in this case if you had: type FooOrBarOrError = Foo | Bar | Error; Then that could desugar to: enum FooOrBarOrError { Foo(Foo), Bar(Bar), Error(Error), } And it could also implement From for you, so you can easily get a FooOrBarOrError from a Foo, Bar, or Error; as well as implementing Display, StdError, etc. if the components already implement them. I actually wonder if you could implement this as a proc macro...
- amluto 1y agoI don't, and I say this as a long-time user of C++'s std::variant and boost::variant, which are effectively union types. Foo | Bar makes sense when Foo and Bar are logically similar and their primary difference is the difference in type. This is actually rather rare. One example would be a term in your favorite configuration markup language along the lines of JSON or YAML: type Term = String | List<String>; or perhaps a fancier recursive one: type Term = String | Box<List<Term>>; or however you want to spell it. Here a Term is something in the language, and there are two kinds of terms: string or lists. But most of the time that I've wanted a sum type, I have a set of logical things that my type can represent, and each of those things has an associated type of the data they carry. Result types (success or error) are absolutely in that category. And doing this wrong can result in a mess. For example, if instead of Result, you have SuccessVal | Error, then the only way to distinguish success from error is to literally, or parametrically in a generic, spell out SuccessVal or Error. And there are nasty pathological cases, for example, what if you want a function that parses a string into an Error? You would want to write: fn parse_error(input_string: &str) -> Error | Error Whoops!
- jffaufwwasd 1y agoThis is where you should be combining them. Ok' and Err' as nominal type constructors which are unioned: struct Ok<T>(T); struct Err<E>(E); fn parse_error(input_string: &str) -> Ok Error | Err (ErrorA | ErrorB | ErrorC...) Or make a sum type enum Result<T, E> { Ok(T), Err(E), } fn parse_error(input_string: &str) -> Result<Error, (ErrorA | ErrorB | ErrorC...)> The error types are unioned for easy composition but you have a top level sum type to differentiate between success and failure.
- anon-3988 1y agoThis is brilliant, I feel like Rust should ultimately arrive at this but imagine this is very hard to implement from the tooling perspective.
- amluto 1y agoAh, so you want both sum and union types, and you probably want those unions to be genuinely unordered, so that A | B is the same as B | A. And maybe even with inferred subtype relationships or automatic conversions so that A | B can be used where A | B | C is expected. This could be useful. I can also imagine it resulting in horrible compilation times and/or generated code bloat in a language+toolchain like Rust that insists on monomorphizing everything.