Skip to content

Instantly share code, notes, and snippets.

@spion
Last active March 13, 2018 18:24
Show Gist options
  • Save spion/3a1cad2debc14c56f353 to your computer and use it in GitHub Desktop.
Save spion/3a1cad2debc14c56f353 to your computer and use it in GitHub Desktop.

Part 1: Why bubbling is good

We start with the following code

function startListening(server, cb) {
  server.listen(99999, 'localhost', cb)
}

This is clearly a programmer error. The programmer should know that a port number like that is invalid. Node will yell at the programmer and throw an error there.

Now lets consider the following change. Exibit 2:

function startListening(server, config, cb) {
  server.listen(config.port, config.host, cb)
}

This is no longer clearly a programmer error. The programmer may be assuming that the other programmer will specify a valid configuration object. But lets consider that kind of assumptions a programmer error anyway and try to fix it.

function startListening(server, config, cb) {
  if (!isValidPort(config.port)) throw new Error("Invalid port in configuration!")
  server.listen(config.port, config.host, cb);
}

Now we are covering the problematic case beforehand. But consider that another function uses this function:

function applyOptions(options, cb) {
  var server = http.createServer(options.serverSpec);
  startListening(server, options.configSpec, cb);
}

The programmer calls startListening without checking for errors. That appears to be an error. What can we do? We can add a check again of course!

function applyOptions(options, cb) {
  if (!isValidPort(options.configSpec.port)) throw new Error("Invalid port in configuration!")
  var server = http.createServer(options.serverSpec);
  startListening(server, options.configSpec, cb);
}

Now we add applyConfigFromEnvironment:

function applyOptionsFromEnvironment(cb) {
   var options = readOptionsFromEnv(process.ENV);
   applyConfigurations(options, cb);
}

And we want to cleanly exit this function if there is an error applying the options and try an alternative (apply default configuration)

Because we called applyOptions within it, without checking for errors, we are going to get an exception instead

At this point, we need to check if the entire configuration tree is valid. Then of course every single applied configuration will also check its part of the tree. Then the actual functions will check the arguments. Then the node functions will check the arguments again. Finally we will start listening.

This is why exceptions were invented in the first place.

Part 2: Why isProgrammerError? <=> solving the halting problem

Consider the function

function crop(image, x1, y1, x2, y2) { // throws if x1,y1,x2,y2 are out of bounds }

Is this a programmer error? That depends on what your interpretation of "programmer error" is. This may or may not be an obvious programmer error - we may be able to handle the case where the crop fails. We could make a case like above that "clearly, the programmer must check if the coordinates are out of image bounds before running the crop.

But again, it depends on the consumer, not the producer of the API whether this is really a "programmer error". And the following will demonstrate that solving this is akin to solving the halting problem:

Imagine that the crop function is called as part of the transform method in a class Crop, which lets you apply a transformation on an image

Consider:

function cropRotate(image) {
  // Coordinates come from API
  var transforms = [new Crop(x1, y1, x2, y2), new Rotate(90)]
  return applyTransforms(transforms, image)
}

function applyTransforms(transforms, image) {
  return transforms.reduce((transform, image) => transform.apply(image))
}

class Crop {
  apply(image) { actually throws the error }
}

You can implement transform.isValidOn(image) and try to call it beforehand:

function cropRotate(image, x1, y1, x2, y2, angle) {
  // Coordinates and angle come from API
  var transforms = [new Crop(x1, y1, x2, y2), new Rotate(angle)]
  var allValid = _.all(transforms.map(t => t.isValidOn(image)))
  if (!allValid) { respondWithError(...); }
  else {
   return respondWith(applyTransforms(transforms, image));
  }
}

But what if the array contains 2 crop transforms? The second one may become out of bounds only after the first one is applied. Which means that applyTransforms must not throw, but must return Result, even if it uses transform.isValidOn(image)

Here is an alternate implementation where we switch to returning Result types:

class Crop {
  apply(image) {
    if (!this.isValidOn(image)) { throw new Error("Coordinates out of bounds!"); }
    else { return Result.Ok(...); }
  }
}

function applyTransforms(transforms, image) {
  transforms.reduce((transform, image) => { 
    if (Result.isOk(image) && transform.isValidOn(image.value)) return transform.apply(image.value))
    else return image; // manually bubble
  }, Result.Ok(new Image()));
}

function cropRotate(image) {
  // Coordinates come from API
  var transforms = [new Crop(x1, y1, x2, y2), new Rotate(90)]
  var res = applyTransforms(transforms, image);
  if (Result.isOk(res)) { respondWithImage(res.value); }
  else { respondWithError(res.error); }
}

Lets abstract the "manually bubble" part to map and flatMap. Now we have a sync version of promises:

class Crop {
  apply(image) {
    if (!this.isValidOn(image)) { return Result.Error("Coordinates out of bounds!"); }
    else { return Result.Ok(...); }
  }
}

function applyTransforms(transforms, image) {
  transforms.reduce((transform, image) => { 
    return image.flatMap(validImage => transform.apply(image.value))
  }, Result.Ok(new Image()));
}

function cropRotate(image) {
  // Coordinates come from API
  var transforms = [new Crop(x1, y1, x2, y2), new Rotate(90)]
  var res = applyTransforms(transforms, image);
  res.map(respondWithImage).orElse(respondWithError);
}

Turns out it wasn't a programmer error after all. Or it depends on the context (consumer) of the API.

Hopefully this is enough to demonstrate that figuring out whether an error is operational is equivalent to solving the halting problem. In this particular case it may be possible to just do all the calculations before hand, before executing the operation. In others, it might not be, so the validity of the arguments may not be known before hand.

In a language without exceptions, most of these can be solved by adding if checks in every layer before every operation, and switching to a Result type once its no longer possible to check the validity of an operation beforehand. But if we assume that exceptions are not in fact broken (because we have resource cleanup guarantees), we might want to reconsider using them...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment