go-go-try
Version:
Tries to execute a sync/async function, returns a result tuple
640 lines (487 loc) • 20 kB
Markdown
# `go-go-try`
> Tries to execute a sync/async function, returns a Golang style result.
## Why
- Supports sync/async functions.
- Allows you to capture the thrown error.
- Written in TypeScript. The types are written in a way that reduce developer errors.
- Inspired by Golang error catching.
- Zero dependencies.
Why not just `try`/`catch`?
- In a lot of cases, `try`/`catch` is still the better option.
- Nested `try`/`catch` statements are hard to process mentally. They also indent the code and make it hard to read. A single `try`/`catch` does the same but to a lesser degree.
- If you [prefer const](https://eslint.org/docs/latest/rules/prefer-const), `try`/`catch` statements get in the way because you need to use `let` if you need the variable outside of the `try`/`catch` scope:
```ts
let todos
try {
todos = JSON.parse(localStorage.getItem('todos'))
} catch {}
return todos.filter((todo) => todo.done)
```
- It takes more space. It's slower to type.
## Install
```bash
npm install go-go-try
```
## Basic Usage
```ts
import { goTry, goTryRaw } from 'go-go-try'
// tries to parse todos, returns empty array if it fails
const [_, value = []] = goTry(() => JSON.parse(todos))
// fetch todos, on error, fallback to empty array
const [_, todos = []] = await goTry(fetchTodos())
// fetch todos, fallback to empty array, send error to your error tracking service
const [err, todos = []] = await goTry(fetchTodos()) // err is string | undefined
if (err) sendToErrorTrackingService(err)
// goTry extracts the error message from the error object, if you want the raw error object, use goTryRaw
const [err, value] = goTryRaw(() => JSON.parse('{/}')) // err is UnknownError | undefined, value is T | undefined
// fetch todos, fallback to empty array, send error to your error tracking service
const [err, todos = []] = await goTryRaw(fetchTodos()) // err is UnknownError | undefined
if (err) sendToErrorTrackingService(err)
```
## Advanced Usage
### Sequential Async Operations
Chain multiple async operations with clean error handling:
```ts
import { goTry } from 'go-go-try'
async function fetchUserData(userId: string) {
// Fetch user
const [fetchErr, user] = await goTry(fetch(`/api/users/${userId}`))
if (fetchErr) return [fetchErr, undefined] as const
// Parse response
const [parseErr, data] = await goTry(user!.json())
if (parseErr) return [parseErr, undefined] as const
// Validate/transform
const [validateErr, validated] = goTry(() => validateUser(data!))
if (validateErr) return [validateErr, undefined] as const
return [undefined, validated] as const
}
const [err, user] = await fetchUserData('123')
if (err) {
console.error('Failed to fetch user:', err)
} else {
console.log('User:', user)
}
```
### Parallel Execution with `goTryAll`
Execute multiple promises in parallel:
```ts
import { goTryAll } from 'go-go-try'
const [errors, results] = await goTryAll([
fetchUser(userId),
fetchPosts(userId),
fetchComments(userId)
])
// errors is [string | undefined, string | undefined, string | undefined]
// results is [User | undefined, Posts | undefined, Comments | undefined]
const [user, posts, comments] = results
```
### Safe Unwrapping with `goTryOr`
Like `goTry`, but returns a default value on failure instead of `undefined`:
> **Note:** For static default values, you can use destructuring instead:
> ```ts
> // These are equivalent for static defaults:
> const [err, config = {port: 3000}] = goTry(() => JSON.parse(configString))
> const [err, config] = goTryOr(() => JSON.parse(configString), {port: 3000})
> ```
> Use `goTryOr` when you need **lazy evaluation** (the default is only computed on failure):
```ts
import { goTryOr } from 'go-go-try'
// ✅ Use goTryOr with a function for lazy evaluation - default only computed on failure
const [err, user] = await goTryOr(fetchUser(id), () => ({
id: 'anonymous',
name: 'Guest',
createdAt: new Date() // This won't run on success
}))
// ❌ Avoid: wasteful - createDefault() runs even on success
const [err, config = createDefault()] = goTry(loadConfig())
// ✅ Better: lazy - createDefault() only runs on failure
const [err, config] = goTryOr(loadConfig(), () => createDefault())
```
### Express/Fastify Error Handling
Use in API route handlers:
```ts
import { goTry } from 'go-go-try'
import express from 'express'
const app = express()
app.post('/users', async (req, res) => {
const [err, user] = await goTry(createUser(req.body))
if (err) {
return res.status(400).json({ error: err })
}
res.json(user)
})
// Batch endpoint
app.post('/batch', async (req, res) => {
const [errors, results] = await goTryAll(
req.body.operations.map(op => processOperation(op))
)
const hasErrors = errors.some(e => e !== undefined)
res.status(hasErrors ? 207 : 200).json({
results,
errors: errors.filter(Boolean)
})
})
```
### Type Guards
Narrow types using `isSuccess` and `isFailure`:
```ts
import { goTry, isSuccess, isFailure } from 'go-go-try'
const result = goTry(() => riskyOperation())
if (isSuccess(result)) {
// result[1] is typed as T (not T | undefined)
console.log(result[1])
} else if (isFailure(result)) {
// result[0] is typed as E (not E | undefined)
console.error(result[0])
}
```
You can also narrow types by destructuring and checking the error:
```ts
const [err, value] = goTry(() => riskyOperation())
if (err === undefined) {
// value is typed as T (not T | undefined)
console.log(value)
} else {
// err is typed as string (not string | undefined)
console.error(err)
// value is typed as undefined in this branch
}
```
### Tagged Errors for Discriminated Unions
Create typed errors with a `_tag` property for pattern matching and discriminated unions:
```ts
import { taggedError, goTryRaw, failure, type Result } from 'go-go-try'
// Define error types
const DatabaseError = taggedError('DatabaseError')
const NetworkError = taggedError('NetworkError')
const ValidationError = taggedError('ValidationError')
// Create a union type
import type { TaggedUnion } from 'go-go-try'
// Option 1: Using TaggedUnion helper (cleaner)
const DatabaseError = taggedError('DatabaseError')
const NetworkError = taggedError('NetworkError')
const ValidationError = taggedError('ValidationError')
type AppError = TaggedUnion<[typeof DatabaseError, typeof NetworkError, typeof ValidationError]>
// Equivalent to: DatabaseError | NetworkError | ValidationError
// Option 2: Using InstanceType (standard TypeScript)
type AppErrorVerbose =
| InstanceType<typeof DatabaseError>
| InstanceType<typeof NetworkError>
| InstanceType<typeof ValidationError>
// Use in functions with typed error returns
async function fetchUser(id: string): Promise<Result<AppError, User>> {
const [dbErr, user] = await goTryRaw(queryDatabase(id), { errorClass: DatabaseError })
if (dbErr) return failure(dbErr)
const [netErr, enriched] = await goTryRaw(enrichUserData(user!), { errorClass: NetworkError })
if (netErr) return failure(netErr)
return [undefined, enriched] as const
}
// Pattern matching on errors
const [err, user] = await fetchUser('123')
if (err) {
switch (err._tag) {
case 'DatabaseError':
console.error('Database failed:', err.message)
break
case 'NetworkError':
console.error('Network issue:', err.message)
break
case 'ValidationError':
console.error('Invalid data:', err.message)
break
}
}
// Exhaustive switch with compile-time safety
function assertNever(value: never): never {
throw new Error(`Unhandled case: ${String(value)}`)
}
function handleError(err: AppError): string {
switch (err._tag) {
case 'DatabaseError':
return `DB: ${err.message}`
case 'NetworkError':
return `NET: ${err.message}`
case 'ValidationError':
return `VAL: ${err.message}`
default:
// TypeScript will error here if any case is missing above
return assertNever(err)
}
}
```
The `taggedError` function creates an error class with:
- `_tag`: A readonly string literal for discriminated unions
- `message`: The error message
- `cause`: Optional cause for error chaining
- `name`: Set to the tag value
### Helper Functions
Build custom utilities on top of the primitives:
```ts
import { goTry, success, failure, type Result } from 'go-go-try'
// Custom validation helper
function validateEmail(email: string): Result<string, string> {
if (!email.includes('@')) {
return failure('Invalid email format')
}
return success(email.toLowerCase().trim())
}
// Usage
const [err, normalizedEmail] = validateEmail('User.COM')
if (err) {
console.error(err) // Doesn't trigger
} else {
console.log(normalizedEmail) // 'user@example.com'
}
```
## API
### `goTry<T>(value)`
Executes a function, promise, or value and returns a Result type with error message as string.
```ts
function goTry<T>(value: T | Promise<T> | (() => T | Promise<T>)): Result<string, T> | Promise<Result<string, T>>
```
### `goTryRaw<T, E>(value, options?)`
Like `goTry` but returns the raw Error object instead of just the message.
By default, errors are wrapped in `UnknownError` (a tagged error class). You can customize this behavior with the options object:
```ts
type GoTryRawOptions<E> =
| { errorClass: ErrorConstructor<E> } // Wrap ALL errors in this class
| { systemErrorClass: ErrorConstructor<E> } // Only wrap non-tagged errors
| {} // Use defaults
```
> **Note:** `errorClass` and `systemErrorClass` are **mutually exclusive**. TypeScript will show an error if you try to pass both.
> - Use `errorClass` when you want all errors wrapped in a specific type
> - Use `systemErrorClass` when you want tagged errors to pass through and only wrap untagged errors
```ts
// Without options - err is UnknownError | undefined
function goTryRaw<T>(value: T | Promise<T> | (() => T | Promise<T>)): Result<UnknownError, T> | Promise<Result<UnknownError, T>>
// With options object - err is E | undefined
function goTryRaw<T, E>(value: T | Promise<T> | (() => T | Promise<T>), options: GoTryRawOptions<E>): Result<E, T> | Promise<Result<E, T>>
```
**Examples:**
```ts
const DatabaseError = taggedError('DatabaseError')
const NetworkError = taggedError('NetworkError')
// Default - errors wrapped in UnknownError
const [err1, data1] = await goTryRaw(fetchData())
// err1 is UnknownError | undefined
// err1?._tag === 'UnknownError'
// Options object - wrap all errors
const [err2, data2] = await goTryRaw(fetchData(), { errorClass: DatabaseError })
// err2 is DatabaseError | undefined
// err2?._tag === 'DatabaseError'
// Options object - systemErrorClass only wraps non-tagged errors
const [err3, data3] = await goTryRaw(fetchData(), { systemErrorClass: NetworkError })
// If fetchData throws a tagged error (e.g., DatabaseError), it passes through
// If fetchData throws a plain Error, it gets wrapped in NetworkError
```
### `goTryAll<T>(items, options?)`
Executes multiple promises or factory functions with optional concurrency limit. Returns a tuple of `[errors, results]` with fixed tuple types preserving input order.
```ts
interface GoTryAllOptions {
concurrency?: number // 0 = unlimited (default), 1 = sequential, N = max concurrent
}
function goTryAll<T extends readonly unknown[]>(
items: { [K in keyof T]: Promise<T[K]> | (() => Promise<T[K]>) },
options?: GoTryAllOptions
): Promise<[{ [K in keyof T]: string | undefined }, { [K in keyof T]: T[K] | undefined }]>
```
**Promise mode** (pass promises directly):
```ts
// Run all in parallel (default):
const [errors, results] = await goTryAll([
fetchUser(1), // Promise<User>
fetchUser(2), // Promise<User>
fetchUser(3), // Promise<User>
])
// errors: [string | undefined, string | undefined, string | undefined]
// results: [User | undefined, User | undefined, User | undefined]
```
**Factory mode** (pass functions that return promises):
```ts
// True lazy execution - factories only called when a slot is available
const [errors, results] = await goTryAll([
() => fetchUser(1), // Only called when concurrency slot available
() => fetchUser(2), // Only called when concurrency slot available
() => fetchUser(3), // Only called when concurrency slot available
() => fetchUser(4), // Only called when concurrency slot available
], { concurrency: 2 })
// Use factory mode when you need to:
// - Rate limit API calls (don't start HTTP requests until allowed)
// - Control database connection limits
// - Limit expensive computation resources
```
### `goTryAllRaw<T, E>(items, options?)`
Like `goTryAll`, but returns raw Error objects instead of error messages.
Non-tagged errors are wrapped in `UnknownError` by default (consistent with `goTryRaw`).
Tagged errors pass through unchanged.
Supports the same `errorClass` and `systemErrorClass` options as `goTryRaw`, plus `concurrency` control.
```ts
interface GoTryAllRawOptions<E> {
concurrency?: number
// errorClass and systemErrorClass are mutually exclusive
errorClass?: ErrorConstructor<E> // Wrap ALL errors
systemErrorClass?: ErrorConstructor<E> // Only wrap non-tagged errors
}
function goTryAllRaw<T extends readonly unknown[], E = UnknownError>(
items: { [K in keyof T]: Promise<T[K]> | (() => Promise<T[K]>) },
options?: GoTryAllRawOptions<E>
): Promise<[{ [K in keyof T]: E | undefined }, { [K in keyof T]: T[K] | undefined }]>
```
**Example:**
```ts
const DatabaseError = taggedError('DatabaseError')
// Default behavior - non-tagged errors wrapped in UnknownError
const [errors1] = await goTryAllRaw([
fetchUser(1),
fetchUser(2),
])
// Wrap all errors in DatabaseError
const [errors2] = await goTryAllRaw([
fetchUser(1),
fetchUser(2),
], { errorClass: DatabaseError })
// Combine concurrency control with errorClass
const [errors3] = await goTryAllRaw([
() => fetchUser(1),
() => fetchUser(2),
() => fetchUser(3),
], { concurrency: 2, errorClass: DatabaseError })
```
### `goTryOr<T>(value, defaultValue)`
Like `goTry`, but returns a default value on failure instead of `undefined`.
The default can be either a static value or a function (for lazy evaluation).
```ts
function goTryOr<T>(value: T | Promise<T> | (() => T | Promise<T>), defaultValue: T | (() => T)): Result<string, T> | Promise<Result<string, T>>
```
### `isSuccess(result)` / `isFailure(result)`
Type guards to check result status.
```ts
function isSuccess<E, T>(result: Result<E, T>): result is Success<T>
function isFailure<E, T>(result: Result<E, T>): result is Failure<E>
```
### `success(value)` / `failure(error)`
Helper functions to create Result tuples.
```ts
function success<T>(value: T): Success<T>
function failure<E>(error: E): Failure<E>
```
### `taggedError<T>(tag)`
Creates a tagged error class for discriminated error handling. Returns a class constructor that extends `Error` and includes a readonly `_tag` property.
```ts
function taggedError<T extends string>(tag: T): TaggedErrorClass<T>
// Returned class interface:
class TaggedErrorClass<T> extends Error implements TaggedError<T> {
readonly _tag: T
readonly cause?: unknown
constructor(message: string, options?: { cause?: unknown })
}
```
**Example:**
```ts
const DatabaseError = taggedError('DatabaseError')
const err = new DatabaseError('connection failed', { cause: originalError })
console.log(err._tag) // 'DatabaseError'
console.log(err.message) // 'connection failed'
console.log(err.name) // 'DatabaseError'
console.log(err.cause) // originalError
```
### `UnknownError`
A default tagged error class used by `goTryRaw` when no options are provided, or when `systemErrorClass` is not specified in the options object.
```ts
import { UnknownError, goTryRaw } from 'go-go-try'
// Errors are automatically wrapped in UnknownError
const [err, data] = goTryRaw(() => {
throw new Error('something went wrong')
})
if (err) {
console.log(err._tag) // 'UnknownError'
console.log(err.message) // 'something went wrong'
console.log(err.cause) // original Error
}
```
Since `UnknownError` is the default for `systemErrorClass`, you can distinguish between known tagged errors and unexpected system errors without specifying any options:
```ts
const DatabaseError = taggedError('DatabaseError')
function fetchData() {
// Operations that might throw DatabaseError should use errorClass to wrap them
const [err1, data1] = goTryRaw(() => queryDatabase(), { errorClass: DatabaseError })
// Other operations use the default behavior - non-tagged errors become UnknownError
const [err2, data2] = goTryRaw(() => parseData(data1))
// err2 is UnknownError | undefined
// Now you can distinguish between known and unknown error types
if (err1) {
console.log('Known DB error:', err1.message)
} else if (err2) {
console.log('Unexpected error:', err2.message)
}
}
```
### `TaggedUnion<T>`
Creates a union type from multiple tagged error classes.
```ts
type TaggedUnion<T extends readonly ErrorConstructor<unknown>[]> =
{ [K in keyof T]: T[K] extends ErrorConstructor<infer E> ? E : never }[number]
```
**Example:**
```ts
const DatabaseError = taggedError('DatabaseError')
const NetworkError = taggedError('NetworkError')
const ValidationError = taggedError('ValidationError')
// Before (verbose):
type AppErrorVerbose =
| InstanceType<typeof DatabaseError>
| InstanceType<typeof NetworkError>
| InstanceType<typeof ValidationError>
// After (clean):
type AppError = TaggedUnion<[typeof DatabaseError, typeof NetworkError, typeof ValidationError]>
// Results in: DatabaseError | NetworkError | ValidationError
```
#### Automatic Union Inference
When using `goTryRaw` with different error classes in the same function, TypeScript **automatically infers** the union type without needing explicit type annotations:
```ts
// No explicit return type needed!
async function fetchUserData(id: string) {
// First operation might fail with DatabaseError
const [dbErr, user] = await goTryRaw(queryDb(id), { errorClass: DatabaseError })
if (dbErr) return failure(dbErr) // returns Failure<DatabaseError>
// Second operation might fail with NetworkError
const [netErr, enriched] = await goTryRaw(enrichUser(user!), { errorClass: NetworkError })
if (netErr) return failure(netErr) // returns Failure<NetworkError>
return success(enriched) // returns Success<User>
}
// TypeScript infers: Promise<Result<DatabaseError | NetworkError, User>>
// No TaggedUnion or explicit types needed!
```
The inferred union enables exhaustive pattern matching:
```ts
const [err, user] = await fetchUserData('123')
if (err) {
switch (err._tag) {
case 'DatabaseError': /* handle db error */ break
case 'NetworkError': /* handle network error */ break
default: assertNever(err) // compile-time safety
}
}
```
## Types
```ts
type Success<T> = readonly [undefined, T]
type Failure<E> = readonly [E, undefined]
type Result<E, T> = Success<T> | Failure<E>
// Error type helpers
type TaggedInstance<T> = T extends ErrorConstructor<infer E> ? E : never
type TaggedUnion<T extends readonly ErrorConstructor<unknown>[]> =
{ [K in keyof T]: T[K] extends ErrorConstructor<infer E> ? E : never }[number]
// Options for goTryRaw (errorClass and systemErrorClass are mutually exclusive)
type GoTryRawOptions<E> =
| { errorClass: ErrorConstructor<E>; systemErrorClass?: never }
| { errorClass?: never; systemErrorClass: ErrorConstructor<E> }
| { errorClass?: never; systemErrorClass?: never }
// Options for goTryAllRaw (includes concurrency + error class options)
type GoTryAllRawOptions<E> =
| { concurrency?: number; errorClass: ErrorConstructor<E>; systemErrorClass?: never }
| { concurrency?: number; errorClass?: never; systemErrorClass: ErrorConstructor<E> }
| { concurrency?: number; errorClass?: never; systemErrorClass?: never }
```
## License
MIT