Skip to content

Instantly share code, notes, and snippets.

@psenger
Last active January 19, 2025 22:29
Show Gist options
  • Save psenger/b3c88c8b145612a9897d716761f1c9b9 to your computer and use it in GitHub Desktop.
Save psenger/b3c88c8b145612a9897d716761f1c9b9 to your computer and use it in GitHub Desktop.
[Async Await Without Try catch / optional catch binding ] #JavaScript #AsyncAwait #Promise

Why Use the tryCatch Function Over Traditional Error Handling

The tryCatch utility function provides several significant advantages over traditional try-catch blocks and Promise chains. Here's why you might want to consider this pattern:

1. Unified Error Handling for Sync and Async Operations

Traditional error handling requires different approaches for synchronous and asynchronous code:

// Traditional approach - try catch
try {
  const syncResult = JSON.parse(data);
} catch (error) {
  handleError(error);
}
// Traditional Chain approach
asyncOperation()
  .then(result => handleSuccess(result))
  .catch(error => handleError(error));

With tryCatch, you use the same pattern regardless of whether the operation is synchronous or asynchronous:

// tryCatch approach - consistent pattern
const [error, syncResult] = tryCatch(() => JSON.parse(data));
const [asyncError, asyncResult] = await tryCatch(async () => await asyncOperation());

2. Explicit Error Handling with Type Safety

Traditional try-catch can lead to forgotten error handling or unclear error states. The tuple return type forces explicit error checking and provides better type safety:

// Traditional approach - error handling can be forgotten
try {
  const result = riskyOperation();
  useResult(result); // Result might be undefined!
} catch {
  // Empty catch block - bad practice but common
}

// tryCatch approach - explicit error handling
const [error, result] = await tryCatch(() => riskyOperation());
if (error) {
  handleError(error);
  return;
}
// TypeScript knows result is defined here
useResult(result);

3. Cleaner Early Returns and Control Flow

Compare these approaches for handling errors in a function:

// Traditional approach - nested try-catch blocks
async function traditional() {
  try {
    const data = await fetchData();
    try {
      const processed = processData(data);
      return processed;
    } catch (processError) {
      logger.error('Processing failed:', processError);
      return null;
    }
  } catch (fetchError) {
    logger.error('Fetch failed:', fetchError);
    return null;
  }
}

// tryCatch approach - flat and clear
async function withTryCatch() {
  const [fetchError, data] = await tryCatch(() => fetchData());
  if (fetchError) {
    logger.error('Fetch failed:', fetchError);
    return null;
  }
  
  const [processError, processed] = await tryCatch(() => processData(data));
  if (processError) {
    logger.error('Processing failed:', processError);
    return null;
  }
  
  return processed;
}

4. Superior Array Operation Handling

The pattern excels when processing arrays of items where some operations might fail:

// Traditional approach - complex error handling
const results = await Promise.all(
  items.map(async item => {
    try {
      const result = await processItem(item);
      return { success: true, data: result };
    } catch (error) {
      return { success: false, error };
    }
  })
);

// tryCatch approach - cleaner and more explicit
const results = await Promise.all(
  items.map(async item => await tryCatch(async () => await processItem(item)))
);
const { successes, failures } = results.reduce((acc, [error, result]) => {
  if (error) {
    acc.failures.push(error);
  } else {
    acc.successes.push(result);
  }
  return acc;
}, { successes: [], failures: [] });

5. Better Testability

The tuple return type makes testing error cases much more straightforward:

// Testing with tryCatch
test('handles parsing error', () => {
  const [error, result] = tryCatch(() => JSON.parse('invalid'));
  expect(error).toBeInstanceOf(SyntaxError);
  expect(result).toBeUndefined();
});

test('handles successful parse', () => {
  const [error, result] = tryCatch(() => JSON.parse('{"valid": true}'));
  expect(error).toBeUndefined();
  expect(result).toEqual({ valid: true });
});

6. No Need for Optional Catch Binding

This pattern provides a more flexible alternative to the proposed JavaScript feature "optional catch binding" (Error.try). It gives you full control over error handling while maintaining code clarity and type safety.

Real-World Impact

This pattern particularly shines in scenarios where:

  • You're building robust APIs that need to handle multiple potential failure points
  • Working with external services or unreliable operations
  • Processing large sets of data where partial failures should be handled gracefully
  • Writing maintainable code that needs to be clear about error states
  • Creating testable code with predictable error handling

The small overhead of writing [error, result] instead of try-catch blocks pays off in terms of code maintainability, readability, and reliability.

/**
* Error handler that wraps both synchronous and asynchronous operations.
* Returns a tuple where the first element is the error (if any) and the second is the result (if successful).
*
* I dont like the purposed JavaScript feature called "optional catch binding"
* const [error, result] = Error.try(() => someOperation());
*
* so I came up with this...
*
* @template T The expected return type of the function
* @param {() => T | Promise<T>} fn - Function to execute
* @returns {Promise<[Error|undefined, T]>|[Error|undefined, T]} Returns [undefined, result] on success or [error, undefined] on failure
*
* @example
* // Synchronous success case
* const [error, result] = tryCatch(() => JSON.parse('{"success": true}'));
* if (!error) console.log(result.success); // true
*
* @example
* // Synchronous error case
* const [error, result] = tryCatch(() => JSON.parse('invalid'));
* if (error) console.error(error.message); // "Unexpected token 'i', ..."
*
* @example
* // Asynchronous success case
* const [error, user] = await tryCatch(async () => {
* const response = await fetch('https://api.example.com/user/1');
* return response.json();
* });
* if (!error) console.log(user.name);
*
* @example
* // Asynchronous error case with early return
* const getUserData = async (id) => {
* const [error, data] = await tryCatch(async () => {
* const response = await fetch(`https://api.example.com/user/${id}`);
* if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
* return response.json();
* });
*
* if (error) {
* logger.error('Failed to fetch user:', error);
* return null;
* }
*
* return data;
* };
*
* @example
* // Using with array methods
* const processItems = async (items) => {
* const results = await Promise.all(
* items.map(item =>
* tryCatch(async () => {
* const processed = await processItem(item);
* return { id: item.id, result: processed };
* })
* )
* );
*
* return results.reduce((acc, [error, result]) => {
* if (error) {
* acc.failures.push({ id: result?.id, error: error.message });
* } else {
* acc.successes.push(result);
* }
* return acc;
* }, { successes: [], failures: [] });
* };
*/
const tryCatch = async (fn) => {
try {
const result = fn();
return result instanceof Promise
? result.then(data => [undefined, data]).catch(err => [err, undefined])
: [undefined, result]
} catch (error) {
// If the error is a Promise, handle it like we handle Promises in the success case
if (error instanceof Promise) {
return error.then(data => [undefined, data]).catch(err => [err, undefined])
}
return [error, undefined]
}
}
describe('tryCatch', () => {
describe('synchronous operations', () => {
it('should handle successful synchronous operations', async () => {
const [error, result] = await tryCatch(() => JSON.parse('{"success": true}'))
expect(error).toBeUndefined()
expect(result).toEqual({ success: true })
})
it('should handle synchronous errors', async () => {
const [error, result] = await tryCatch(() => JSON.parse('invalid'))
expect(error).toBeInstanceOf(SyntaxError)
expect(result).toBeUndefined()
})
it('should handle null return values', async () => {
const [error, result] = await tryCatch(() => null)
expect(error).toBeUndefined()
expect(result).toBeNull()
})
it('should handle undefined return values', async () => {
const [error, result] = await tryCatch(() => undefined)
expect(error).toBeUndefined()
expect(result).toBeUndefined()
})
it('should handle throwing non-Error objects', async () => {
const [error, result] = await tryCatch(() => {
throw 'string error'
})
expect(error).toBe('string error')
expect(result).toBeUndefined()
})
})
describe('asynchronous operations', () => {
it('should handle successful async operations', async () => {
const promise = Promise.resolve({ data: 'success' })
const [error, result] = await tryCatch(() => promise)
expect(error).toBeUndefined()
expect(result).toEqual({ data: 'success' })
})
it('should handle async errors', async () => {
const expectedError = new Error('Async error')
const promise = Promise.reject(expectedError)
const [error, result] = await tryCatch(() => promise)
expect(error).toBe(expectedError)
expect(result).toBeUndefined()
})
it('should handle async operations that throw synchronously', async () => {
const [error, result] = await tryCatch(async () => {
throw new Error('Sync error in async function')
})
expect(error).toBeInstanceOf(Error)
expect(error.message).toBe('Sync error in async function')
expect(result).toBeUndefined()
})
it('should handle null in async operations', async () => {
const [error, result] = await tryCatch(async () => null)
expect(error).toBeUndefined()
expect(result).toBeNull()
})
it('should handle undefined in async operations', async () => {
const [error, result] = await tryCatch(async () => undefined)
expect(error).toBeUndefined()
expect(result).toBeUndefined()
})
})
describe('edge cases', () => {
it('should handle non-function arguments', async () => {
expect(async () => await tryCatch(null)).not.toThrow()
expect(async () => await tryCatch(undefined)).not.toThrow()
expect(async () => await tryCatch(42)).not.toThrow()
expect(async () => await tryCatch('string')).not.toThrow()
})
it('should handle nested tryCatch calls', async () => {
const [outerError, outerResult] = await tryCatch(async () => {
const [innerError, innerResult] = await tryCatch(async () => 'nested')
return innerResult
})
expect(outerError).toBeUndefined()
expect(outerResult).toBe('nested')
})
it('should handle promises that resolve to promises', async () => {
const [error, result] = await tryCatch(() =>
Promise.resolve(Promise.resolve('nested promise'))
)
expect(error).toBeUndefined()
expect(result).toBe('nested promise')
})
it('should handle throwing promises', async () => {
const [error, result] = await tryCatch(() => {
throw Promise.reject(new Error('thrown promise'))
})
expect(error).toBeInstanceOf(Error)
expect(result).toBeUndefined()
})
})
describe('array method usage', () => {
it('should work with Promise.all and map', async () => {
const items = [1, 2, 3]
const results = await Promise.all(
items.map(item =>
tryCatch(async () => item * 2)
)
)
const processed = results.reduce((acc, [error, result]) => {
if (error) {
acc.failures.push({ error: error.message })
} else {
acc.successes.push(result)
}
return acc
}, { successes: [], failures: [] })
expect(processed.successes).toEqual([2, 4, 6])
expect(processed.failures).toEqual([])
})
it('should handle mixed success/failure in array operations', async () => {
const items = [1, 'error', 3]
const results = await Promise.all(
items.map(item =>
tryCatch(async () => {
if (item === 'error') throw new Error('Failed item')
return item * 2
})
)
)
const processed = results.reduce((acc, [error, result]) => {
if (error) {
acc.failures.push({ error: error.message })
} else {
acc.successes.push(result)
}
return acc
}, { successes: [], failures: [] })
expect(processed.successes).toEqual([2, 6])
expect(processed.failures).toHaveLength(1)
expect(processed.failures[0].error).toBe('Failed item')
})
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment