DEV Community

Discussion on: Callback hell OR try catch hell (tower of terror)

Collapse
 
peerreynders profile image
peerreynders

All approaches have their trade-offs.

The Zen of Go:

Plan for failure, not success

Go programmers believe that robust programs are composed from pieces that handle the failure cases before they handle the happy path.

Go's errors are values philosophy is a recognized pain point.

Handling errors where they occur - midstream - obscures, in use case terminology, the happy path/basic flow/main flow/main success scenario. That doesn't mean that other flows/scenarios aren't important - on the contrary. That's why there are extensions/alternate flows/recovery flows/exception flows/option flows.

Clearly identifying the main flow in code is valuable.

Whether or not an IDE can collapse all the conditional error handling is beside the point - especially given that traditionally Java has been chastised for needing an IDE to compensate for all its warts.

to implement functional try/catch

Granted Go doesn't use a containing tuple like [error, result] but destructuring still directly exposes null or undefined values which is rather "un-functional" - typically the container (the tuple) is left as an opaque type while helper functions are used to work with the contained type indirectly (e.g. Rust's Result).

Now your criticism regarding try … catch is fair …

a variable ripe for mutation

… but JavaScript isn't a functional language (which typically is immutable by default and supports the persistent data structures to make that sustainable). However it is possible to take inspiration from Rust:

If a variable has unique access to a value, then it is safe to mutate it.

Rust: A unique perspective

e.g. for local variables with exclusive/unique access (i.e. aren't somehow shared via a closure) mutation can be acceptable.

So maintaining a context object to "namespace" all the values that need to stretch across the various try … catch scopes isn't too unreasonable:

const hasValue = (result) => result.hasOwnProperty('value');

class Result {
  static ok(value) {
    return new Result(value);
  }

  static err(error) {
    return new Result(undefined, error);
  }

  constructor(value, error) {
    if (typeof value !== 'undefined') this.value = value;
    else this.error = error;
  }

  get isOk() {
    return hasValue(this);
  }

  get isErr() {
    return !hasValue(this);
  }

  map(fn) {
    return hasValue(this) ? Result.ok(fn(this.value)) : Result.err(this.error);
  }

  mapOr(defaultValue, mapOk) {
    return hasValue(this) ? mapOk(this.value) : defaultValue;
  }

  mapOrElse(mapErr, mapOk) {
    return hasValue(this) ? mapOk(this.value) : mapErr(this.error);
  }

  andThen(fn) {
    return hasValue(this) ? fn(this.value) : Result.err(this.error);
  }

  // etc
}

const RESULT_ERROR_UNINIT = Result.err(new Error('Uninitialized Result'));

// To get around statement oriented
// nature of try … catch
// wrap it in a function
//
function doSomething(fail) {
  // While not strictly necessary
  // use `context` to "namespace"
  // all cross scope references
  // and initialize them to
  // sensible defaults.
  //
  const context = {
    success: false,
    message: '',
    result: RESULT_ERROR_UNINIT,
  };

  try {
    if (fail) throw new Error('Boom');
    context.success = true;
    context.result = Result.ok(context.success);
  } catch (err) {
    context.success = false;
    context.message = err.message;
    context.result = Result.err(err);
  } finally {
    console.log(context.success ? 'Yay!' : `Error: '${context.message}'`);
  }
  return context.result;
}

const isErrBoom = (error) => error instanceof Error && error.message === 'Boom';
const isErrNotTrue = (error) =>
  error instanceof Error && error.message === "Not 'true'";
const returnFalse = (_param) => false;
const isTrue = (value) => typeof value === 'boolean' && value;
const isFalse = (value) => typeof value === 'boolean' && !value;
const negate = (value) => !value;
const negateOnlyTrue = (value) =>
  isTrue(value) ? Result.ok(false) : Result.err(new Error("Not 'true'"));

// hide try … catch inside `doSomething()` to produce a `Result`
const result1 = doSomething(false); // 'Yay'
console.assert(result1.isOk, 'Should have been OK');
console.assert(result1.mapOr(false, isTrue), "Ok value isn't 'true'");
console.assert(
  result1.map(negate).mapOr(false, isFalse),
  "'map(negate)' didn't produce 'Ok(false)'"
);
console.assert(
  result1.andThen(negateOnlyTrue).mapOr(false, isFalse),
  "'andThen(negateOnlyTrue)' didn't produce 'Ok(false)'"
);
console.assert(
  result1
    .map(negate)
    .andThen(negateOnlyTrue)
    .mapOrElse(isErrNotTrue, returnFalse),
  "'andThen(negateOnlyTrue)' didn't produce 'Error(\"Not 'true'\")"
);

const result2 = doSomething(true); // "Error: 'Boom'"
console.assert(result2.isErr, 'Should have been Error');
console.assert(
  result2.mapOrElse(isErrBoom, returnFalse),
  "Message isn't 'Boom'"
);
console.assert(
  result2.map(negate).mapOrElse(isErrBoom, returnFalse),
  "'map(negate)' didn't preserve Error"
);
console.assert(
  result2.andThen(negateOnlyTrue).mapOrElse(isErrBoom, returnFalse),
  "'andThen(negateOnlyTrue)' didn't preserve Error"
);
Enter fullscreen mode Exit fullscreen mode