# until-async: turning a rejected Promise into a tuple instead of a catch block

> until-async is a tiny TypeScript library that wraps a promise-returning function and resolves to a two element tuple of error and data, so error handling becomes a branch rather than a try/catch, at the cost of giving up the exception model.

**kettanaito/until-async** — Gracefully handle Promises using async/await without try/catch.

- Repository: https://github.com/kettanaito/until-async
- Website: https://npmx.dev/until-async
- Stars: 627 · Forks: 13
- Language: TypeScript
- License: MIT
- Published: 2026-10-22 · Updated: 2026-10-22 · Language: en
- Canonical page: https://hysenlabs.com/projects/kettanaito-until-async

## The gap async/await left behind in ECMAScript 2017

The README starts from a specific gap. With the addition of the async and await keywords in ECMAScript 2017, handling Promises became much easier, but the await keyword provides no standard error handling API. The example given is a function that awaits a call to fetchUser, and the point is that if fetchUser throws, the entire getUser function scope terminates. The recommended fix at the language level is to wrap the await expression in a try and catch block, and the README shows that shape with a data variable initialised to null and a catch that logs the error. The library's argument against that pattern is not that it is wrong, since the README calls it a semantically valid approach, but that constructing try and catch around each awaited operation may be tedious and get overlooked at times. It also introduces separate closures for the execution and the error scenario of an asynchronous operation.

## A tuple return, which is the whole API

The answer the library gives is to encapsulate the try and catch inside a utility function that does not create a separate closure and exposes an API friendly to NodeJS. That API is the until function, and it returns a tuple. The returned array has the error in position zero and the data in position one, so the caller destructures it and branches on the error rather than catching an exception.

```js
import { until } from 'until-async'

async function getUserById(id) {
  const [error, data] = await until(() => fetchUser(id))

  if (error) {
    return handleError(error)
  }

  return data
}
```

The library is a single function with a single export in the usage example, and it is MIT licensed with no runtime dependencies in the manifest. The package metadata shows a modern build, with type set to module, an exports map that publishes only the default condition alongside types, and a dev toolchain of tsdown for the build, vitest for the tests, publint for linting the published package, and TypeScript 5.9. The scripts are named for what they do, with dev running tsdown in watch mode, build running tsdown, test running vitest, lint running publint, and release running the @ossjs/release publish flow. The last push to the repository was on 2026-10-16, and the recent releases are v3.0.0, v3.0.1 and v3.0.2, all published on 2025-09-20.

## The error type becomes part of the signature

The TypeScript usage is where the library shows what it is really for. The generic parameters on until carry the error type and the data type separately, so the compiler knows that the first element is a UserFetchError and the second is a User.

```ts
import { until } from 'until-async'

interface User {
  firstName: string
  age: number
}

interface UserFetchError {
  type: 'FORBIDDEN' | 'NOT_FOUND'
  message?: string
}

async function getUserById(id: string) {
  const [error, data] = await until<UserFetchError, User>(() => fetchUser(id))

  if (error) {
    return handleError(error.type, error.message)
  }

  return data.firstName
}
```

The declared error type here is a union of a discriminant string and an optional message, which is a common shape for a discriminated error result. Naming the error type in the generic means the error branch is type checked like any other value, and the optional message field forces the error handler to accept that it might be absent. That is a real gain over a bare try/catch where the caught value is typed as unknown and has to be narrowed by hand. The cost is that the error is no longer an exception, so anything that relies on the call stack unwinding, on a throw crossing a function boundary, or on an unhandled rejection being visible in logs, no longer behaves that way.

## Why until takes a function rather than a promise

The FAQ answers the question that anyone reading the usage example will ask, which is why until accepts a function and not a Promise directly. The answer is that this was intentionally introduced so that until awaits a single logical unit as opposed to a single Promise. The example makes the point concretely, with a single until invocation wrapping several awaited steps.

```js
// Notice how a single "until" invocation can handle
// a rather complex piece of logic. This way any rejections
// or exceptions happening within the given function
// can be handled via the same "error".
const [error, data] = await until(async () => {
  const user = await fetchUser()
  const nextUser = normalizeUser(user)
  const transaction = await saveModel('user', user)

  invariant(transaction.status === 'OK', 'Saving user failed')

  return transaction.result
})

if (error) {
  // Handle any exceptions happened within the function.
}
```

Two things are happening in that snippet. The awaited sequence spans three calls, and the invariant check throws a plain exception rather than rejecting a promise, and the error slot still catches it because the whole function body is inside the try and catch. The second is the point of taking a function. If until took a promise, it could only observe a rejection, and a synchronous throw inside your own code would escape. The README also credits giuseppegurgone with the discussion about the original until API, which tells you the shape of the function was argued over rather than settled by accident.

## Where the tuple approach stops being an improvement

The trade-off is real and it is worth being blunt about. The library removes the try and catch block, but it replaces it with a branch that every call site has to write and every reader has to check. In a codebase where the error path is genuinely exceptional, a try and catch around one await costs three lines and is skipped by readers who know the call cannot fail, whereas the tuple forces a destructuring and a conditional on every single call, including the ones that never fail. The second limit is scope. Because the error is a value in the tuple rather than a thrown exception, the library cannot be dropped into code that expects exceptions, and code that already has a central error handler that catches and rethrows has to be rewritten. The third is the promise argument. Passing a function rather than a promise is right for the multi step case, but it means you cannot use until on a promise you already hold, so a caller with an existing promise still writes try and catch. The alternative worth naming is the pattern that became standard in Rust and Go and was popularised in JavaScript libraries that return a Result type, which is the same idea with a more explicit shape, or simply structured error handling with typed thrown errors and a Result-like wrapper of your own. The difference in approach is that until-async fixes one tuple shape for everyone, while a local Result type lets you carry richer error information.

## Maintenance and the shape of a very small package

The repository is small in the way a good utility library should be. The top level entries are a README, a LICENSE, a src/ directory, a tsconfig.json, a pnpm-lock.yaml, a release.config.json, a .prettierrc, a vitest.config.ts and a tsdown.config.ts, plus the .github directory. There is no example directory and no benchmark suite in the tree, which tells you the surface area is a single function rather than a framework. The package publishes the build and src directories, so consumers can read the implementation directly, and the author is Artem Zakharchenko, who is also behind a good deal of the Node.js diagnostic tooling this library is in the same ecosystem as. The release history shows all three recent 3.0.x tags published on the same day, 2025-09-20, which reads as a 3.0 rewrite landing in one push rather than an incremental series. The last push was on 2026-10-16, so the repository is being worked on, and the package is published from npm as until-async with its documentation at npmx.dev/until-async.

## Conclusion

Adopt until-async if your team prefers explicit error branches over try/catch around every await, and if you want the error type to be part of the function signature. Do not adopt it if your codebase is built around error propagation with throw, since the library deliberately routes every failure through the first tuple slot. Verify first that you can live with the promise argument being a function rather than a promise, because that is a breaking design choice in the API, not an implementation detail.

## FAQ

### How do I install and use until-async?

Run npm install until-async, then import until and await it with a function that returns a promise. It resolves to a tuple where the first element is the error and the second is the data, so you branch on the error rather than catching it.

### Why does until take a function instead of a Promise?

The README says this was introduced intentionally so that until awaits a single logical unit as opposed to a single Promise, and so that rejections and exceptions inside the function are both handled through the same error slot.

### How do I type the error in until-async with TypeScript?

Pass the error type and the data type as the generic parameters, for example until<UserFetchError, User>(() => fetchUser(id)). The error type becomes part of the signature, so the error branch is type checked rather than narrowing an unknown value.

### Does until-async have any runtime dependencies?

The package manifest lists no dependencies field, only devDependencies for the build and test toolchain, which includes tsdown, vitest, publint and TypeScript. The package is MIT licensed and published as an ES module.

## Sources

- [kettanaito/until-async on GitHub](https://github.com/kettanaito/until-async)
- [License: MIT](https://github.com/kettanaito/until-async/blob/main/LICENSE)
- [Project website](https://npmx.dev/until-async)
- [README](https://github.com/kettanaito/until-async/blob/main/README.md)
- [Releases](https://github.com/kettanaito/until-async/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/kettanaito-until-async
