arthurfiorette / proposal-safe-assignment-operator

Draft for ECMAScript Error Safe Assignment Operator
https://arthur.run/proposal-safe-assignment-operator/
MIT License
1.49k stars 16 forks source link
catch effect error proposal result safe symbol tc39 try tuple


[!CAUTION] This proposal will change to try-expressions as its a more idiomatic apporach to this problem. Read more on #4 and #5.

Help on its rewriting is needed :)


ECMAScript Safe Assignment Operator Proposal

[!WARNING]
This proposal is actively under development, and contributions are welcome.


This proposal introduces a new operator, ?= (Safe Assignment), which simplifies error handling by transforming the result of a function into a tuple. If the function throws an error, the operator returns [error, null]; if the function executes successfully, it returns [null, result]. This operator is compatible with promises, async functions, and any value that implements the Symbol.result method.

For example, when performing I/O operations or interacting with Promise-based APIs, errors can occur unexpectedly at runtime. Neglecting to handle these errors can lead to unintended behavior and potential security vulnerabilities.


const [error, response] ?= await fetch("https://arthur.place")



Motivation


How often have you seen code like this?

async function getData() {
  const response = await fetch("https://api.example.com/data")
  const json = await response.json()
  return validationSchema.parse(json)
}

The issue with the above function is that it can fail silently, potentially crashing your program without any explicit warning.

  1. fetch can reject.
  2. json can reject.
  3. parse can throw.
  4. Each of these can produce multiple types of errors.

To address this, we propose the adoption of a new operator, ?=, which facilitates more concise and readable error handling.

async function getData() {
  const [requestError, response] ?= await fetch(
    "https://api.example.com/data"
  )

  if (requestError) {
    handleRequestError(requestError)
    return
  }

  const [parseError, json] ?= await response.json()

  if (parseError) {
    handleParseError(parseError)
    return
  }

  const [validationError, data] ?= validationSchema.parse(json)

  if (validationError) {
    handleValidationError(validationError)
    return
  }

  return data
}


Please refer to the What This Proposal Does Not Aim to Solve section to understand the limitations of this proposal.


Proposed Features

This proposal aims to introduce the following features:


Symbol.result

Any object that implements the Symbol.result method can be used with the ?= operator.

function example() {
  return {
    [Symbol.result]() {
      return [new Error("123"), null]
    },
  }
}

const [error, result] ?= example() // Function.prototype also implements Symbol.result
// const [error, result] = example[Symbol.result]()

// error is Error('123')

The Symbol.result method must return a tuple, where the first element represents the error and the second element represents the result.

Why Not data First?


The Safe Assignment Operator (?=)

The ?= operator invokes the Symbol.result method on the object or function on the right side of the operator, ensuring that errors and results are consistently handled in a structured manner.

const obj = {
  [Symbol.result]() {
    return [new Error("Error"), null]
  },
}

const [error, data] ?= obj
// const [error, data] = obj[Symbol.result]()
function action() {
  return 'data'
}

const [error, data] ?= action(argument)
// const [error, data] = action[Symbol.result](argument)

The result should conform to the format [error, null | undefined] or [null, data].

Usage in Functions

When the ?= operator is used within a function, all parameters passed to that function are forwarded to the Symbol.result method.

declare function action(argument: string): string

const [error, data] ?= action(argument1, argument2, ...)
// const [error, data] = action[Symbol.result](argument, argument2, ...)

Usage with Objects

When the ?= operator is used with an object, no parameters are passed to the Symbol.result method.

declare const obj: { [Symbol.result]: () => any }

const [error, data] ?= obj
// const [error, data] = obj[Symbol.result]()


Recursive Handling

The [error, null] tuple is generated upon the first error encountered. However, if the data in a [null, data] tuple also implements a Symbol.result method, it will be invoked recursively.

const obj = {
  [Symbol.result]() {
    return [
      null,
      {
        [Symbol.result]() {
          return [new Error("Error"), null]
        },
      },
    ]
  },
}

const [error, data] ?= obj
// const [error, data] = obj[Symbol.result]()

// error is  Error('string')

These behaviors facilitate handling various situations involving promises or objects with Symbol.result methods:

These cases may involve 0 to 2 levels of nested objects with Symbol.result methods, and the operator is designed to handle all of them correctly.


Promises

A Promise is the only other implementation, besides Function, that can be used with the ?= operator.

const promise = getPromise()
const [error, data] ?= await promise
// const [error, data] = await promise[Symbol.result]()

You may have noticed that await and ?= can be used together, and that's perfectly fine. Due to the Recursive Handling feature, there are no issues with combining them in this way.

const [error, data] ?= await getPromise()
// const [error, data] = await getPromise[Symbol.result]()

The execution will follow this order:

  1. getPromise[Symbol.result]() might throw an error when called (if it's a synchronous function returning a promise).
  2. If an error is thrown, it will be assigned to error, and execution will halt.
  3. If no error is thrown, the result will be assigned to data. Since data is a promise and promises have a Symbol.result method, it will be handled recursively.
  4. If the promise rejects, the error will be assigned to error, and execution will stop.
  5. If the promise resolves, the result will be assigned to data.


using Statement

The using or await using statement should also work with the ?= operator. It will perform similarly to a standard using x = y statement.

Note that errors thrown when disposing of a resource are not caught by the ?= operator, just as they are not handled by other current features.

try {
  using a = b
} catch(error) {
  // handle
}

// now becomes
using [error, a] ?= b

// or with async

try {
  await using a = b
} catch(error) {
  // handle
}

// now becomes
await using [error, a] ?= b

The using management flow is applied only when error is null or undefined, and a is truthy and has a Symbol.dispose method.


Try/Catch Is Not Enough

The try {} block is rarely useful, as its scoping lacks conceptual significance. It often functions more as a code annotation rather than a control flow construct. Unlike control flow blocks, there is no program state that is meaningful only within a try {} block.

In contrast, the catch {} block is actual control flow, and its scoping is meaningful and relevant.

Using try/catch blocks has two main syntax problems:

// Nests 1 level for each error handling block
async function readData(filename) {
  try {
    const fileContent = await fs.readFile(filename, "utf8")

    try {
      const json = JSON.parse(fileContent)

      return json.data
    } catch (error) {
      handleJsonError(error)
      return
    }
  } catch (error) {
    handleFileError(error)
    return
  }
}

// Declares reassignable variables outside the block, which is undesirable
async function readData(filename) {
  let fileContent
  let json

  try {
    fileContent = await fs.readFile(filename, "utf8")
  } catch (error) {
    handleFileError(error)
    return
  }

  try {
    json = JSON.parse(fileContent)
  } catch (error) {
    handleJsonError(error)
    return
  }

  return json.data
}


Why Not data First?

In Go, the convention is to place the data variable first, and you might wonder why we don't follow the same approach in JavaScript. In Go, this is the standard way to call a function. However, in JavaScript, we already have the option to use const data = fn() and choose to ignore the error, which is precisely the issue we are trying to address.

If someone is using ?= as their assignment operator, it is because they want to ensure that they handle errors and avoid forgetting them. Placing the data first would contradict this principle, as it prioritizes the result over error handling.

// ignores errors!
const data = fn()

// Look how simple it is to forget to handle the error
const [data] ?= fn()

// This is the way to go
const [error, data] ?= fn()

If you want to suppress the error (which is different from ignoring the possibility of a function throwing an error), you can simply do the following:

// This suppresses the error (ignores it and doesn't re-throw it)
const [, data] ?= fn()

This approach is much more explicit and readable because it acknowledges that there might be an error, but indicates that you do not care about it.

The above method is also known as "try-catch calaboca" (a Brazilian term) and can be rewritten as:

let data
try {
  data = fn()
} catch {}

Complete discussion about this topic at https://github.com/arthurfiorette/proposal-safe-assignment-operator/issues/13 if the reader is interested.


Polyfilling

This proposal can be polyfilled using the code provided at polyfill.js.

However, the ?= operator itself cannot be polyfilled directly. When targeting older JavaScript environments, a post-processor should be used to transform the ?= operator into the corresponding [Symbol.result] calls.

const [error, data] ?= await asyncAction(arg1, arg2)
// should become
const [error, data] = await asyncAction[Symbol.result](arg1, arg2)
const [error, data] ?= action()
// should become
const [error, data] = action[Symbol.result]()
const [error, data] ?= obj
// should become
const [error, data] = obj[Symbol.result]()


Using ?= with Functions and Objects Without Symbol.result

If the function or object does not implement a Symbol.result method, the ?= operator should throw a TypeError.


Comparison

The ?= operator and the Symbol.result proposal do not introduce new logic to the language. In fact, everything this proposal aims to achieve can already be accomplished with current, though verbose and error-prone, language features.

try {
  // try expression
} catch (error) {
  // catch code
}

// or

promise // try expression
  .catch((error) => {
    // catch code
  })

is equivalent to:

const [error, data] ?= expression

if (error) {
  // catch code
} else {
  // try code
}


Similar Prior Art

This pattern is architecturally present in many languages:

While this proposal cannot offer the same level of type safety or strictness as these languages—due to JavaScript's dynamic nature and the fact that the throw statement can throw anything—it aims to make error handling more consistent and manageable.


What This Proposal Does Not Aim to Solve

  1. Strict Type Enforcement for Errors: The throw statement in JavaScript can throw any type of value. This proposal does not impose type safety on error handling and will not introduce types into the language. It also will not be extended to TypeScript. For more information, see microsoft/typescript#13219.

  2. Automatic Error Handling: While this proposal facilitates error handling, it does not automatically handle errors for you. You will still need to write the necessary code to manage errors; the proposal simply aims to make this process easier and more consistent.


Current Limitations

While this proposal is still in its early stages, we are aware of several limitations and areas that need further development:

  1. Nomenclature for Symbol.result Methods: We need to establish a term for objects and functions that implement Symbol.result methods. Possible terms include Resultable or Errorable, but this needs to be defined.

  2. Usage of this: The behavior of this within the context of Symbol.result has not yet been tested or documented. This is an area that requires further exploration and documentation.

  3. Handling finally Blocks: There are currently no syntax improvements for handling finally blocks. However, you can still use the finally block as you normally would:

try {
  // try code
} catch {
  // catch errors
} finally {
  // finally code
}

// Needs to be done as follows

const [error, data] ?= action()

try {
  if (error) {
    // catch errors
  } else {
    // try code
  }
} finally {
  // finally code
}


Help Us Improve This Proposal

This proposal is in its early stages, and we welcome your input to help refine it. Please feel free to open an issue or submit a pull request with your suggestions.

Any contribution is welcome!


Authors


Inspiration

Inspiration


License

This proposal is licensed under the MIT License.