Return undefined or throw error upon invalid function input?

I know this is a duplicate but the anwser over here simply does not satisfy me:

JS library best practice: Return undefined or throw error on bad function input?

I'd like to talk about a few things that were pointed out and what remained unclear to me.

First off I'd like to present one example where I personally would rather throw an error then return undefined.

function sum(a, b)

Say the consumer passes a string because he passed the direct value of an input box and the end user typed in something other than a number.

If I as the author of sum had returned undefined upon string input, then even if the dev had typed in a string at some point, nothing would have happened and he wouldnt have cared, as thats what was to be expected. But in this case, had I thrown an error, the dev would have realised that thats actually an edge case that had to be handled, because after all, no one wants errors in their program.

So basicly, why not make devs aware of edge cases by actually throwing errors?

This was a comment on the question mentioned above which pretty much is exactly what I am asking but no one replied yet:

"But since it takes 1 extra min for me to throw an error instead of dying silently, won't that save hours in debugging time for those who didn't take the time to read the docs?"

Another point that is in the accepted anwser from above:

"Catching an exception is a lot slower than testing a return value so if an error is a common occurrence, then exceptions will be a lot slower."

The only case I can think of where this qoute applies to is with I/O or networky stuff where the input is always in the correct format but e.g. a user does not exist with that id.

In cases like that, I understand why throwing an error would slow down the process. But again, what about a math library consisting of only pure, synchronous functions?

Isn't it smarter to check the input instead of checking the output? (Make sure the input will work instead of running the function and checking if undefined is returned)

A lot of my confusion really stems from type checking actually as I do come from the C# world and think a library should be used in its exact way as intended rather than it being merciful and working anyway.

1 answer

  • answered 2017-11-15 04:27 HMR

    I would argue that for an OO language it's common to return null even though that's bad practice and the inventor of null calls it the billion dollar mistake.

    Functional languages have solved this problem before OO even existed by having a Maybe type.

    When composing functions you could use a variation of a Maybe that contains a Success or Failure, Scott Wlaschin calls this railway orientated programming and Promises are a kind of railway orientated programming.

    The problem with using Maybe in OO is that you don't have union types. In F# your code would look like this:

    let x = 
      // 9/0 throws so divideBy returns None the caller of divideBy can
      //decide what to do this this.
      match (divideBy 9 0) with
      | Some result -> //process result
      | None -> //handle this case
    

    When matching something in F# you'll get a compile time error if you forget a case (not handle the None). In OO you won't and are stuck with runtime errors or failing quietly. Improvements in C# may come with the compiler warning you when you try to access nullable types but that only takes care of the if not null, it does not force you to provide an else.

    So in JavaScript I would advice using Promises or returning a result object. Promises are native to modern browsers and nodejs. In the browser it will shout at you in the console when you do not handle failed promises (errors in console and break at uncaught rejection in sources). In the future; for nodejs; it will cause your process to stop as with an unhandled exception.

    //example with promises
    const processNumber = compose([
      //assuming divideBy returns a promise that is rejected when dividing by zero
      divideBy(9)
      ,plus(1)
      ,minus(2)
      ,toString
    ])
    // 9/0 returns rejected promise so plus,minus and toString are never executed
    processNumber(0) 
    .then(
      success => //do something with the success value
      ,fail => //do something with the failure
    );
    
    //example of result type:
    const Success = {}
    ,Failure = {}
    ,result = (type) => (value) => 
      (type === Failure)
        //Failure type should throw when trying to get a value out of it
        ? Object.create({type:type,error:value}, {
          value: {
            configurable: false,
            get: function() { 
              throw "Cannot get value from Failure type"
            }
          }
        })
        : ({
          type:type
          ,value:value    
        })
    ;
    //convert a function (T->T) to (result T->result T)
    const lift = fn => arg => {
      //do not call funcion if argument is of type Failure
      if(arg.type === Failure){
        return arg;
      }
      try {
        const r = fn(arg.value);
        //return a success result
        return result(Success)(r);
      } catch (e) {
        //return a failure result
        return result(Failure)(e);
      }
    };
    //takes a result and returns a result
    const processNumber = compose(
      [
        //assuming divideBy throws error when dividing by zero
        divideBy(9)
        ,plus(1)
        ,minus(2)
        ,toString
      ].map( //lift (T->T) to (result T -> result T)
        x => lift(x)
      )
    );
    
    const r = processNumber(result(Success)(0));//returns result of type Failure
    if(r.type === Failure){
      //handle failure
    } else {
      //handle r.value
    }
    

    You could just return null or throw but more and more people in OO start to realize that's not the best way to handle things. Throwing is a side effect so makes the function impure (the more impure functions the harder it is maintain your code).

    Null is not good type to reflect functions that could fail. You don't know why it failed to return the expected type and now have to make assumptions as to why, making assumptions in code makes your code harder to maintain.