[!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 :)
[!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")
data
First??=
with Functions and Objects Without Symbol.result
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.
fetch
can reject.json
can reject.parse
can throw.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.
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.
?=
)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]
.
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, ...)
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]()
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:
async function(): Promise<T>
function(): T
function(): T | Promise<T>
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.
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:
getPromise[Symbol.result]()
might throw an error when called (if it's a synchronous function returning a promise).error
, and execution will halt.data
. Since data
is a promise and promises have a Symbol.result
method, it will be handled recursively.error
, and execution will stop.data
.using
StatementThe 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.
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
}
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.
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]()
?=
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
.
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
}
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.
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.
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.
While this proposal is still in its early stages, we are aware of several limitations and areas that need further development:
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.
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.
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
}
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!
tuple-it
npm package, which introduces a similar concept but modifies the Promise
and Function
prototypes—an approach that is less ideal.This proposal is licensed under the MIT License.