Giter Site home page Giter Site logo

proposal-try-expression's Introduction

Try expressions

Introduction

Catching and reifying an exception is a pretty common want and need:

All of these boil down to one of a few variations of the same utility function:

// tryCatch(func) -> [error, result]
function tryCatch(func) {
  try {
    return [null, func()]
  } catch (e) {
    return [e, null]
  }
}

// tryCatch(func) -> result | null
function tryCatch(func) {
  try {
    return func()
  } catch (e) {
    return null
  }
}

// tryCatch(func) -> result | error
function tryCatch(func) {
  try {
    return func()
  } catch (e) {
    return e
  }
}

// tryCatch(func) -> {caught, value}
function tryCatch(func) {
  try {
    return {caught: false, value: func()}
  } catch (e) {
    return {caught: true, value: e}
  }
}

I've seen all of these in the wild and personally have used all but the first in impromptu utilities on the fly. But this is an area where the engine could really help out massively - it could do this virtually zero-cost, and it could even make it work with async/await with basically no effort at all.

Proposal

I propose we should create a new try expression. This would simplify a lot of common error handling tasks, and it'd synergize very well with the pattern matching proposal. It would operate basically like this:

const result = try expr

This would desugar roughly to the following:

let $result // This variable is purely internal
try {
  $result = {caught: false, value: expr}
} catch (e) {
  $result = {caught: true, error: e}
}

const result = $result

This is compatible with both yield and await, and such expressions can be anywhere. No restrictions are placed on the operand aside from it must be a valid unary expression and it can't start with a literal { (to prevent ambiguity). For example:

const addResult = try await save(await transformData())

The above desugars to this:

let $result // This variable is purely internal
try {
  $result = {caught: false, value: await save(await transformData())}
} catch (e) {
  $result = {caught: true, result: e}
}

const addResult = $result

Spec

The grammar is super simple:

UnaryExpression ::
  `try` UnaryExpression

The semantics are also similarly simple:

UnaryExpression :: `try` UnaryExpression

  1. Let E be the result of evaluating UnaryExpression.
  2. If E.[[Type]] is normal, let caught be false and valueKey be "value".
  3. Else, if E.[[Type]] is throw, let caught be true and valueKey be "error".
  4. Else, return Completion(E).
  5. Let result be ObjectCreate(%ObjectPrototype%).
  6. Perform ! CreateDataProperty(result, "caught", caught).
  7. Perform ! CreateDataProperty(result, valueKey, E.[[Value]]).
  8. Return NormalCompletion(result).

Examples

Here, I've got two examples, one based on example code in a blog post and one based on code out in the wild.

Performing an async task

Adapted from https://blog.grossman.io/how-to-write-async-await-without-try-catch-blocks-in-javascript/

async function asyncTask() {
  const userReq = try await UserModel.findById(1)
  if (userReq.caught) throw new CustomerError('No user found')
  const user = userReq.value

  const taskReq = try await TaskModel({userId: user.id, name: 'Demo Task'})
  if (taskReq.caught) throw new CustomError('Error occurred while saving task', taskReq.error)

  if (user.notificationsEnabled) {
    const notificationReq = try await NotificationService.sendNotification(user.id, 'Task Created')
    if (notificationReq.caught) console.error('Error occurred while sending notification', notificationReq.error)
  }
}

Using try/catch instead:

async function asyncTask() {
  let user

  try {
    user = await UserModel.findById(1)
  } catch {
    throw new CustomerError('No user found')
  }

  try {
    await TaskModel({userId: user.id, name: 'Demo Task'})
  } catch {
    throw new CustomError('Error occurred while saving task')
  }

  if (user.notificationsEnabled) {
    try {
      await NotificationService.sendNotification(user.id, 'Task Created')
    } catch {
      console.error('Just log the error and continue flow')
    }
  }
}

Executing a test body while keeping time measurement as precise as possible

Adapted from https://github.com/isiahmeadows/thallium/blob/master/lib/core/tests.js#L369-L378

This is a case where things get a little awkward and boiilerplatey without it. In this case, I literally had to write a utility similar to what I'm proposing here just to better structure my code.

class Context {
  // ...

  invokeInit(count) {
    const test = this.root.current

    test.locked = false
    const start = Date.now()
    const tryBody = try (0, test.callback)()
    const syncEnd = Date.now()

    // Note: synchronous failures are test failures, not fatal errors.
    if (tryBody.caught) {
      if (count < test.attempts) return this.invokeInit(count + 1)
      test.locked = true
      return Promise.resolve(new Result(syncEnd - start, true, tryBody.error))
    }

    const tryThen = try getThen(tryBody.value)

    if (tryThen.caught) {
      if (count < test.attempts) return this.invokeInit(count + 1)
      test.locked = true
      return Promise.resolve(new Result(syncEnd - start, true, tryThen.error))
    }

    if (typeof tryThen.value !== "function") {
      test.locked = true
      return Promise.resolve(new Result(syncEnd - start, false))
    }

    return new Promise(resolve => {
      let state = new AsyncState(this, start, resolve, count)
      const result = try invokeThen(tryThen.value, tryBody.value,
        () => {
          if (state == null) return
          state.finish(false)
          state = undefined
        },
        e => {
          if (state == null) return
          state.finish(true, e)
          state = undefined
        })

      if (state == null) return
      if (result.caught) {
          state.finish(true, result.error)
          state = undefined
          return
      }

      // Set the timeout *after* initialization. The timeout will likely be
      // specified during initialization.
      const maxTimeout = test.timeout || Constants.defaultTimeout

      // Setting a timeout is pointless if it's infinite.
      if (maxTimeout !== Infinity) {
        state.timer = setTimeout(() => {
          if (state == null) return
          state.finish(true, new Error(`Timeout of ${maxTimeout} reached`))
          state = undefined
        }, maxTimeout)
      }
    })
  }
}

Using try/catch instead:

class Context {
  // ...

  invokeInit(count) {
    const test = this.root.current

    test.locked = false
    let caught = false
    let tryBody

    const start = Date.now()
    try {
      tryBody = (0, test.callback)()
    } catch (e) {
      // Note: synchronous failures are test failures, not fatal errors.
      const syncEnd = Date.now()
      if (count < test.attempts) return this.invokeInit(count + 1)
      test.locked = true
      return Promise.resolve(new Result(syncEnd - start, true, e))
    }
    const syncEnd = Date.now()
    let tryThen

    try {
      tryThen = getThen(tryBody.value)
    } catch (e) {
      if (count < test.attempts) return this.invokeInit(count + 1)
      test.locked = true
      return Promise.resolve(new Result(syncEnd - start, true, e))
    }

    if (typeof tryThen !== "function") {
      test.locked = true
      return Promise.resolve(new Result(syncEnd - start, false))
    }

    return new Promise(resolve => {
      let state = new AsyncState(this, start, resolve, count)
      try {
        try {
          invokeThen(tryThen.value, tryBody.value,
            () => {
              if (state == null) return
              state.finish(false)
              state = undefined
            },
            e => {
              if (state == null) return
              state.finish(true, e)
              state = undefined
            })
        } finally {
          if (state == null) return
        }
      } catch {
        state.finish(true, e)
        state = undefined
        return
      }

      // Set the timeout *after* initialization. The timeout will likely be
      // specified during initialization.
      const maxTimeout = test.timeout || Constants.defaultTimeout

      // Setting a timeout is pointless if it's infinite.
      if (maxTimeout !== Infinity) {
        state.timer = setTimeout(() => {
          if (state == null) return
          state.finish(true, new Error(`Timeout of ${maxTimeout} reached`))
          state = undefined
        }, maxTimeout)
      }
    })
  }
}

Rationale

The introduction covers it pretty well. But there's a few design decisions I want to go over.

Why an object?

Two reasons:

  1. It's easier to manage as a first-class citizen if you need to. Not all use cases boil down to in the small, and the names get invaluable if you use them anywhere else.
  2. It's not ambiguous which field corresponds to what. Some might associate the first item as the value, some the second, and an object allows both ways while an array doesn't.

Why not just an error + data pair?

Because it's lossy and {error: undefined, value: undefined} could be reasonably parsed as either an error or success depending on which value you check first. It's possible undefined could be thrown, and if you use != null to track the presence/absence of an exception, it will lead you into problems. (In simpler cases, this won't come up, but it does come up frequently in advanced cases.)

Note that the differing objects do mean you could still do const {caught, error, value} = try .... You can have informative names; you just have to check a third variable each time.

Why an operator and not a function?

A function provides a lot more overhead, and while it's polyfillable, it's not ideal: you'd need separate variants for sync results and async values, and generators for each variant. Also, this naturally glides right in with the try/catch statement, which is itself already present as syntax and not as a special function of some sort.

Language precedent

There's a few languages that themselves implement exception/error handling like this normally.

  • Lua does almost exactly this via its built-in pcall. pcall(func, ...) calls func(...) and returns true, result, ... for success, false, error for caught errors. (They support multiple return values, but you can't throw multiple errors at once.) So this exists as pretty strong precedent.
  • Perl does similar with eval { ... } + $@. eval { ... } returns true on success and false on error, and $@ returns the caught error (if applicable). A common idiom eval { ... } or do { ... } provides a try/catch-like syntax similar to languages like C++ and Java, and a pod exists to add a much more traditional try/catch statement common among most C-like languages supporting exceptions.
  • Rust does simple error handling via returning Result<T, E> (with Ok(T) and Err(E) variants), but its std::panic::catch_unwind catches panics (its stack-unwinding exceptions) and converts them to an Ok(result) on success, Err(error) on error. They recommend using the Result<T, E> directly for what you would normally use try/catch for, and only this if you have no other option (like avoiding UB with C interop).
  • And of course, Go uses the err, data idiom where err is a possible error (or nil on success) and data is the result if successful (or nil on error). But this runs into a pitfall I address in my rationale, and so I avoided that idiom specifically even though it was partial inspiration for this proposal.

proposal-try-expression's People

Contributors

dead-claudia avatar

Stargazers

Rémy Abitbol avatar Federico Brigante avatar Anne Thorpe avatar Yuki Ito avatar Matthew Francis Brunetti avatar Jack Bates avatar Jan Buschtöns avatar Andreas Linnert avatar Luiz Felipe Gonçalves avatar David Michael Gregg avatar Karl Horky avatar Dan avatar MooYeol Prescott Lee avatar Stav Alfi avatar Jakub Jirutka avatar Harry Solovay avatar Aleksey Bobyr avatar  avatar ΝΙΚΟΛΑΣ avatar Mehdi Hasan Khan avatar Toni Villena avatar Fredrik Forsmo avatar Tobias Timm avatar Gal Schlezinger avatar Nemanja Stojanovic avatar @davalapar avatar Jordan Harband avatar

Watchers

Jordan Harband avatar HE Shi-Jun avatar Yuki Ito avatar  avatar Jack Works avatar

Forkers

davalapar

proposal-try-expression's Issues

A note on precedent

I'm not sure if this is a serious proposal that might be going somewhere but I've wanted it for years. I didn't see it mentioned in the part about previous language precedent but Kotlin has had try expressions for a long time. See here: https://kotlinlang.org/docs/exceptions.html#try-is-an-expression

I don't know if there is anything worthwhile from a proposal or implementation perspective that can be gleaned from that but Kotlin syntax generally feels quite similar to JS and can transpile to JS so I thought I'd bring it up in case it hasn't already been reviewed or considered.

maybe make "try" really an expression?

kotlin allows you to use try as an expression:

// ? means nullable
val a: Int? = try { parseInt(input) } catch (e: NumberFormatException) { null }

this way, "throwing" expressions could be paired with newly-added null coalescing ?? in case catch doesn't really return anything. the good side effect that you wouldn't need extra "try await" syntax, you can use inside the expression like you normally would (the same way you can use await inside loops)

const alwaysValue = try { await some() } catch (e) { null } ?? 'fallback'

this reads better, you don't need to shove variables outside of the block scope inside just to assign a variable. this is important because try also has "finally", that executes regardless of failure, and wouldn't be able to fit the way you proposed

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.