0

Over time I become used to use the type of the returned value of a function as a piece of additional information.

For example:
A function that is supposed to return or an array if the arguments are correct and can find a result, can pass a specific enum when arguments are incorrect or the expected result cannot be found.
Then I can test the type to sort the behaviour of the receiving code.

I can't always use a typed function in the same way because the value that I reserve to explain what's wrong removes a possible valid result.

I can see some other benefits and the main is that testing the type of the returned value is a very separated operation from the normal flow of the code and it seems more methodical to me. Also, the segmentation now possible increases the flexibility of the code.
So, I'm quite sure that I write less code and, once one gets used, the code is readable.

  1. Is it acceptable?
  2. I'm going to work with an agency on some projects, can I ask if I can use this approach or is it against the best practices?

3 Answers 3

4

The problem that you have is:

  • You do not ensure a proper separation of concerns, since you combine error notification (exceptional situation) with returning a result.
  • It's not fully in line with the principle of least astonishment (POLA): if the user is used to get a result, it might come as a surprise when the result is very different from expectations. If the types differ only rarely, it's a serious source of errors.
  • It's not a common language idiom. In many language, you may return a nil or a nullptr or something similar to express the absence of result. You could argue that this null value is also of a different type, since it usually cannot be processed the same way. But such idioms are in line with POLA when it's a common idiom.
  • Lastly, one could claim that this is a kind of infringement of the interface segregation principle, since you require to using context to depend on more return types than strictly needed.

For all these reasons I'd advise against it.

4

In strongly/statically typed languages, this is generally discouraged, as the static type information is used to determine the legal operations on the results, with opportunities to do type inference and to eliminate certain runtime checks on the returned values.

Dynamically typed languages (such as Python or Smalltalk) allow returning different types to express exceptional conditions, but this may lead to nasty bugs when a caller forgets to check the return value and treats it as the wrong type, so this style is generally frowned upon.

Rust (just an example that I've made myself familiar with recently) has wrappers like Option<> and Result<> which allow programmers to express the notion of "no result" or "error" in a type-safe way. The compiler can verify that you always handle both cases when dealing with values of these types, and that in each branch you only deal with the correct value.

3
  • That's a beautiful feature. Thank you for the answer
    – DT1
    Commented Sep 26, 2020 at 18:04
  • 1
    @porkaloca Lots of languages have features like Option and Result. It's fashionable lately.
    – joshp
    Commented Sep 26, 2020 at 18:56
  • @porkaloca: It's not even a language feature, it can be implemented in most languages as a library. In my answer, I have an example of what it would look like in Scala (the real type in the standard library has more features and some performance optimizations, but the structure is the same), and it doesn't actually use any Scala-specific features, it would look more or less the same in Java, C#, C++, Kotlin, etc. In Rust, it is also a library feature and can be fully written in Rust. The only language feature is the ? operator which saves some typing, but it is not essential. Commented Sep 27, 2020 at 22:14
1

There are some communities where this or similar things are fairly common. For example, in C, many functions that can normally only return positive integers will return an error code as a negative integer in case of failure. For example, printf returns the total number of characters written (which can never be negative, obviously). However, its return type is not typed as unsigned int but as int, and it returns a negative number in case of failure.

I am pretty sure I have seen the exact thing you describe done in some dynamic language, but I can't come up with an example right now.

However, there are almost always better alternatives.

If your language has something like conditions (CommonLisp, Dylan, Clojure, …) or exceptions (ECMAScript, PHP, Python, Ruby, …), then you can use those.

If your language supports multiple return values, then you can use an extra return value for the error. For example, in Go, it is idiomatic to return the error as an extra return value:

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("math: square root of negative number")
    }
    // implementation
}

And the caller would use it like this:

n, err := Sqrt(-1)
if err != nil {
    log.Fatal(err)
}
// Do something with `n`

In languages with lightweight syntax for tuples or records, and destructuring assignment, you can easily "fake" support for multiple return values (e.g. TypeScript):

function sqrt(n: number): { result?: number, error?: string } {
  if (n < 0) {
    return { error: `Argument must be non-negative but you passed ${n}!` };
  }
  // implementation
}

const { result, error } = sqrt(-1);
if (error) {
  // handle error
}

In more verbose languages like Java, you can still build a simple result type, although it starts to get a little wordy:

record DoubleResult(double result, Throwable error);

DoubleResult sqrt(double d) {
    if (d < 0d) {
        return new DoubleResult(0d, new ArgumentException("Bla bla"));
    }
    // implementation
}

var dr = sqrt(-1d);

if (dr.error != null) {
    // handle error
}

However, my personal favorite is the following: If you are using a statically typed language with parametric polymorphism, you can build an Error type, something like this:

sealed trait Try[+T] {
  val isSuccess: Boolean
  val isFailure: Boolean

  // @returns the result value if successful or the default value if failed
  def getOrElse[U >: T](default: => U): U

  // execute `f` with the result if success
  def foreach[U](f: T => U): Unit

  // transform result using `f` if success
  def map[U](f: T => U): Try[U]

  // transform result using `f` if failure
  def recover[U >: T](f: Throwable => U): Try[U]
}

final case class Failure[+T](exception: Throwable) extends Try[T] {
  override val isSuccess = false
  override val isFailure = true

  override def getOrElse[U >: T](default: => U) = default
  override def foreach[U](f: T => U) = ()
  override def map[U](f: T => U) = this.asInstanceOf[Failure[U]]

  override def recover[U >: T](f: Throwable => U) = Success(f(exception))
}

final case class Success[+T](value: T) extends Try[T] {
  override val isSuccess = true
  override val isFailure = false

  override def getOrElse[U >: T](default: => U) = value
  override def foreach[U](f: T => U) = f(value)
  override def map[U](f: T => U) = Success[U](f(value))

  override def recover[U >: T](f: Throwable => U) = this
}

This is an Error type which you might typically find in a functional language, but there is nothing inherently tied to functional programming about it. In fact, the sketch above is completely OO, using subtyping and method overriding.

The idea behind this type is that it has an abstract superclass that defines operations both for processing the result if it exists, and for handling the failure if it happens. Then, it has two concrete subclasses, one for the successful case, and one for the failure case.

And we are using simple method overriding, so that the Success class overrides or implements all the failure handling methods as no-ops, and the Failure class handles all the success handling methods as no-ops.

The real trick though, is that this class implements also a collection-style interface: it has foreach and map, etc. In the success case, it behaves like a collection with one element, and in the failure case, it behaves like an empty collection.

So, in many cases, I don't even need to check whether it was successful or not! If I want to transform the result, I can simply call map, because map on an empty collection is a no-op.

We can implement additional collection methods, I just didn't show them in the sketch. E.g. flatten, which will flatten a nested tower of Trys into a single level of Try which is either the last Error if there was at least one, or a Success containing the value. Similarly, we can implement flatMap.

I can pass this object on to a different method, and that method can simply call map as well. Only the method that actually wants to handle the error and get rid of the Try wrapper around the value needs to actually know about the error.

The fact that this is essentially a collection, means that all the things that we know about collections, all the methods in the standard library that deal with collections, can be applied to this. We can simply transparently treat it as a collection and it will automatically "do the right thing". We only need to care about the error at the place where we actually care about the error.

I have avoided the "M" word for now, but you might have already spotted that this is a monad (well, not quite, I left out the flatMap method in the sketch). Several languages nowadays have special syntax sugar for monadic operations, e.g. C#, Visual Basic.NET, Scala, and Haskell. In those languages, dealing with Errors using an Error Monad is very simple.

Such an error type is nowadays built into the standard libraries of many languages. For example, Scala has scala.util.Try, Rust has std::result::Result, Elm has Result, and so on.

These work best in languages that have monad comprehensions and pattern matching, but those features are not necessary. In some cases (e.g. Rust) there is special syntax for dealing with result types.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.