Of course, there is probably no reason not to use File.read! if we’re expecting it to succeed. I still think that single line matches to assert success are pretty common, though. Maybe that wouldn’t be the case if there was always a bang version of every function than can fail. But even that would only solve :ok | :error situations and nothing else.
This reminds me of a thought I had some time ago. Since bang functions are pretty much always implemented the same way, how awesome would it be if ! was a macro, so that you could use it with any function and nobody would ever need to explicitly implement a bang version of a function again? It would basically be like an option unwrap function, but in one character. But that’s definitely a topic for another discussion.
José, do you know whether the initial development work for this will be “in the open,” or do y’all plan to keep development private until some internal milestone is reached?
Selfishly, I hope that work is just done in a branch of elixir-lang/elixir, but I can understand how that might lead to a “too many cooks in the kitchen” scenario, or people trying/expecting/assuming things of the implementation while it’s still in a very much underdeveloped state.
Off topic, yes, but that would be very nice. I’ve been in the weeds of Image and, to a lesser extent, Vix lately where ~99% of all public functions have a bang version.
And yes, fair enough, I wasn’t thinking about other cases of matching when a bang version doesn’t exist—I have indeed seen a lot of :ok = thing().
On the topic of typed languages, it seems that third party cookies are doing their wonders, as I stumbled upon this talk today:
If you have time to watch it I recommend it, as it is a very well structured and interesting talk. If not, this guy is the creator of https://www.roc-lang.org/, quite a interesting language that employs a strategy close to dialyzer, but this is done by the compiler.
Our goal is to open it as soon as possible. I think the initial Elixir implementation will be closed, so we iron out the basic foundation, and then move it to Elixir and keep it open.
This is one of the core tensions in the value proposition of an exhaustive type system for Elixir, TBH. Best-in-class pattern matching + best-in-class resiliency lets me choose my own “error kernel”—boundaries to contain cascading errors—and non-exhaustively pattern match within it with concise confidence.
I would love something like case! when I want that exhaustion, and it’d pair well with success-typing dialyzer-style stuff to make sure that all my explicit calls to functions and exhaustive branching alike can pattern match. I do feel like people who want exhaustiveness all the time, everywhere like TS/Elm are programming around some of the strengths of the language, though.
Well, I still think case! can be replaced with a compiler switch or mix config option. Though that means that gradual upgrading of code will be not possible. It has to be an all or nothing. I can see the conundrum, yep, but still can’t see it as bad; as me and others have pointed out, having stronger guarantees in your code is a feature desired by many.
I wouldn’t want a splintered community and I am fearful that either option has the potential to lead to that outcome.
I suppose the highest priority should be “can we guarantee a smooth transition?”.
I certainly agree that this should be avoided. I love how concise the core language is and I really appreciate the core team standing their ground against high pressure situations to add new features and syntax.
I really like like how you put that as it’s how I was feeling the other day when I came across it. It’s a very-low stakes scenario but case! would let me say, “Hey, I mean to do this!” Of course, I could also have a catch-all that raises which would be fine if not a little inelegant. Furthermore, case! could lead to confusion and, as @dimitarvp mentioned, having teams that ban its use since no one can agree if it was being used it on purpose or not.
Whether I’d want exhaustive pattern matching or not depends on the context within the application.
For example, if I have a certain struct/schema that has a type field, and somewhere I want to do something based on the type of an instance, I would very much like to get a warning if I didn’t consider a newly added type.
However, there are situations where certain values should not occur in the current context. If I get them anyway, that would be unexpected and indicate a flaw in my application logic somewhere else. Consider a UI element that should only be rendered if the current user is authorized to use it. It should be impossible for the events triggered by this element to occur if the user is unauthorized. The context functions called by the event handler would still check whether the current user is authorized (and return something like {:error, :unauthorized}, but the event handler function itself could assume that the current user is authorized and wouldn’t need to match on that particular error. I would like to get a runtime pattern match error in this case, because it would indicate that I either forgot to add the authorization check in the template, or I used the wrong policy, etc.
I don’t know whether these are good examples, but if I look through the applications I’m working on, I’m sure I will find plenty places where I would prefer exhaustive pattern matching and other places where I would prefer non-exhaustive pattern matching. I don’t think a compiler switch would be sufficient then.
As other mentioned above, I also consider non-exhaustive pattern-matching a feature rather than a bug, since it behaves just like guards and help write concise assertive code in some cases, just like =. For example: it’s either a map with a name string key, or nil, everything else is unexpected and we could just LetItCrash™. Else, you would need to add a pokemon clause (catch them all) and manually throw something equivalent to a MatchError, which is done out of the box.
Exhaustive pattern-matching would also be a very useful feature in other cases when we don’t want this, so I can see the case (no pun intended) for having both case! and case. The issue I’m seeing is that case would be the assertive version than raises on runtime, and case! would handle all cases and not raise (except at compile time).
Also, we will have the same issue for def ( def!?), since exhaustive pattern-matching would probably also be expected here.
I guess the general description of the second example is that sometimes you can rule out certain return values of a function because they only occur under conditions that you already know will not be met in the given context.
My scenario was an incredibly simple one: I have a whole mess of checkboxes with phx-clicks on them. They post with a value of "true" or "false". At first I was going to cast it to a boolean and use an if/else but then I thought about how the only way it could ever not one of those two strings is if someone is messing with my app. If I cast I would probably be non-the-wiser and, even if it’s not a big deal, it felt right to case on those two values alone so it otherwise crashes. I thought of adding a catch-all that raises but @sabiwara put into better words than I could about my reservations for doing so.
Anyway, not to keep beating this case! horse but I’d never thought too much about this before and enjoying the insights.
My interpretation was that case! would the new behaviour, to be backwards-compatible. I definitely would want a new keyword for the new type-exhaustive pattern match.
I do see how ! connotations with “may fail” would make an argument for the non-exhaustive version. In this situation I’d rather a keyword like handle, with language that asserts that it can handle all cases, in addition to case, which simply describes all the cases you wish to handle.
So something like:
handle File.read("foo") do
{:ok, contents} -> contents
{:error, :enoent}} -> nil
end
Would raise a
CompileErrror: did not handle all cases, missing:
{:error, :eacces}
etc
Whereas
handle File.read("foo") do
{:ok, contents} -> contents
{:error, _}} -> nil
end
One solution to the discussion I’ve not seen yet is making errors/exceptions explicit in type expressions.
If you could say that a functions return value is :ok | Exception then you don’t need your exhaustiveness check on case and doing :ok = Foo.maybe_fail!() would all be ok.
On the other hand if you didn’t annotate your function with a possible exception the compiler would complain
This probably opens an other can of worms but it still might be interesting to explore.
(And it’s probably something the people working on this have already thought/discussed about. It’s a bit funny there’s an announcement about first step and we go into a full what if discussion )
This is possible in typespecs today, with the no_return() type.
Libraries and applications used to use exactly something like :ok | no_return() for functions they intended to indicate would explicitly raise an error in certain circumstances, to hint to the caller that they may need to handle something.
Sadly, since Elixir 1.15 the standard library docs recommend against this. This is very confusing to me, especially as Elixir explores an exhaustive type system where there would be a meaningful difference in code that, like any function, could always potentially raise an exception; and functions specified as potentially raising an exception you might need to exhaustively handle to ensure correctness.
I’d much rather see no_return() come back, with support for annotating a function with something like no_return(File.Error), and have an exhaustive type checking system that ensures that callers to functions annotated with no_return() are inside a corresponding try; rescue e -> handler, and functions annotated with no_return(struct) are inside a corresponding try; rescue struct -> handler.
In my opinion no_return as a successful call, for side effects, similar to void or unit in other languages is something entirely different than this function could throw an exception. Exception means something didn’t go correctly.
So the type annotation could be: no_return | Exception for example in the case of File.write!
We recommending against no_return() is specific to Dialyzer, as it makes it less useful as it will avoid warning when it finds some error cases. It has no relation to how exceptions will be handled in the type system.
That makes sense to me. I suppose it was frustrating to read as a library author who wants to indicate when I want a caller to know I may intend to raise based on their input. But of course, with a supportive type system, I would have a mechanism to do so.