Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Composing errors is difficult #362

Closed
yorickpeterse opened this issue Jan 27, 2023 · 7 comments · Fixed by #508
Closed

Composing errors is difficult #362

yorickpeterse opened this issue Jan 27, 2023 · 7 comments · Fixed by #508
Labels
compiler Changes related to the compiler std Changes related to the standard library

Comments

@yorickpeterse
Copy link
Collaborator

Consider a method like this:

fn foo !! SomeError -> SomeValue { ... }

In some cases we may want to map the error into say an Option, like so:

let x = try foo else Option.None

This doesn't work because the return type of foo is SomeValue, but the return type of the else is Option. Something like this doesn't work either, because try only applies to/works for method calls:

let x = try Option.Some(foo) else Option.None

This won't work either:

let x = Option.Some(try foo else Option.None)

To work around this you have to introduce an auxiliary method like so:

fn wrapper -> Option[SomeValue] {
  Option.Some(try foo else return Option.None)
}

A generic version would be this:

fn wrap[T, E](block: fn !! E -> T) -> Option[T] {
  Option.Some(try block else return Option.None)
}

wrap fn { try foo }

This could be turned into something like this:

class pub Option[T] {
  fn pub static from_error[E](block: fn !! E -> T) -> Option[T] {
    Option.Some(try block.call else return Option.None)
  }
}

Option.from_error fn { try foo }

While this works, I'm not sure I'm a fan of this approach.

An area where this doesn't report is when dealing with iterators:

If you have an iterator that doesn't throw, using a method that throws in e.g. map isn't possible:

[10, 20].iter.map fn (v) { try foo }.to_array

This won't work because the iterator is typed as Iter[Int, Never], meaning map can't throw. This in turn means it's practically impossible to use methods that throw inside iterators, and you just want to re-throw the error.

This is also true for Rust: if you use e.g. for_each you can't bubble up a Result, instead you have to use for. Inko used to have for but I removed it as the implementation was a bit messy. While it's one way to handle things, I'm not sure I'm a fan of relying on dedicated expressions just to make it easier to bubble up errors.

Inko needs to provide a solid answer to this.

@yorickpeterse
Copy link
Collaborator Author

Here's a real-world example:

I'm writing some code to control my ventilation system. This involves parsing some JSON configuration and turning that into Inko objects. A snippet of this code is as follows:

let rooms = try { object(root, 'rooms') }.into_iter.select_map fn (obj) {
  let setting = try int(obj, 'setting') else return Option.None
  let motion = try int(obj, 'motion') else return Option.None
  let humidity = ...
  let name = try string(obj, 'name') else return Option.None
}

Here humidity should be an optional value, set to Some("...") if the humidity key is present in the JSON object, or None if it isn't. Because int() throws if the key is missing, this isn't easy to do.

Of course in this case I can change int() to return an Option instead of throwing, but the point is that with e.g. a Result type this would've been trivial:

let humidity = int(obj, 'humidity').some # turns Result[A,B] into Option[A]

@yorickpeterse
Copy link
Collaborator Author

Actually with Option types inside the loop things get more annoying, as we have to resort to something like this:

let rooms = object(root, 'rooms').into_iter.select_map fn (obj) {
  let setting = match int(obj, 'setting') {
    case Some(v) -> v
    case _ -> return Option.None
  }
                                                                  
  ...
}

I considered it in the past but wasn't sure about this, but perhaps we should ditch error types and just make try ... else ... a shorthand for unwrapping a Result/Option. This means that this:

try foo else bar 

Turns into something like this:

match foo {
  case Ok(val)  -> val
  case Error(_) -> bar
}

This would then let us write this:

let rooms = object(root, 'rooms').into_iter.select_map fn (obj) {
  let setting = try int(obj, 'setting')
  let motion = try int(obj, 'motion')
  let humidity = string(obj, 'humidity')
  let name = try string(obj, 'name')
}

@yorickpeterse
Copy link
Collaborator Author

@yorickpeterse
Copy link
Collaborator Author

Some additional thoughts on this:

It's not uncommon to have a method that may produce an error, but no OK value. The simplest example is a method that validates some input, throws if it's invalid, and does nothing in particular when it's valid. With throws one would type such a method as fn foo !! ErrorType, but with Result types one would have to use fn foo -> Result[Nil, ErrorType]. This then means we allocate a useless Result type every time foo is called with valid input. Of course we could optimise Result into a tagged pointer at some point, but we might not be able to always apply that.

For iterators we have a somewhat opposite problem: throwing makes it impossible to map an iterator that throws A to an iterator that throws B. This is because methods such as e.g. map don't take the thrown error as input, and thus can't remap it to anything. The question here is if the case of mapping an error from A to B is common, and if so if that warrants switching to a Result type. Methods that don't return a value (e.g. Iter.each) wouldn't be able to produce an error even with Result, so it's really the mapping that's just a problem.

@yorickpeterse
Copy link
Collaborator Author

Worth reading: https://sabrinajewson.org/blog/errors

@yorickpeterse
Copy link
Collaborator Author

Worth adding: for the case of -> Result[Nil, Type], it might be better to use a dedicated return type instead, e.g. Option<Type> where a Some is returned in case of an error. Result should only be used if a method can produce both an OK and error value.

@yorickpeterse
Copy link
Collaborator Author

Another article worth reading: https://sled.rs/errors

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
compiler Changes related to the compiler std Changes related to the standard library
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant