Skip to content

awaitly vs try/catch

Traditional JavaScript uses try/catch for error handling. awaitly uses Result types that make errors explicit in the type system. This guide shows the same operations in both styles.

Start with a normal function signature:

async function fetchUser(id: string): Promise<User>

It promises a User. It says nothing about what happens when the user isn’t found, the network drops, or the server returns a 500. Those failures are real, but the type ignores them. You learn about them by reading the implementation or by hitting them in production.

awaitly moves the failures into the return type:

async function fetchUser(id: string): AsyncResult<User, 'NOT_FOUND' | 'NETWORK_ERROR'>

Now the signature tells you both outcomes: a User if it works, or 'NOT_FOUND' or 'NETWORK_ERROR' if it doesn’t. TypeScript then makes you handle the failure before you can touch the value.

The two approaches side by side:

try/catch Result types
───────────────── ─────────────────
• Errors are implicit • Errors are explicit
• Signature hides failure • Signature is the contract
• Caught at runtime • Caught at compile time
• Easy to forget handling • TypeScript enforces handling

When try/catch is fine: small scripts, internal tools, low-risk apps. Plain async/await with careful error handling works well there.

Where it starts to hurt: once failures are a normal part of what your code does, once several layers have to agree on how errors flow, or once you want the compiler to catch a missed case when you refactor.

import { Awaitly } from 'awaitly';
const parseJson = (str: string) => Awaitly.from(() => JSON.parse(str));
const result = parseJson('{"name": "Alice"}');
if (result.ok) {
console.log(result.value.name);
} else {
console.log('Parse error:', result.error.message);
}
/*
Output:
Alice
*/
import { Awaitly, type AsyncResult } from 'awaitly';
type FetchError = 'NETWORK_ERROR' | 'NOT_FOUND' | 'SERVER_ERROR';
const fetchUser = async (id: string): AsyncResult<User, FetchError> => {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) {
if (res.status === 404) return Awaitly.err('NOT_FOUND');
return Awaitly.err('SERVER_ERROR');
}
return Awaitly.ok(await res.json());
} catch {
return Awaitly.err('NETWORK_ERROR');
}
};
// Caller MUST handle the error - TypeScript enforces it
const result = await fetchUser('123');
if (!result.ok) {
switch (result.error) {
case 'NOT_FOUND':
console.log('User not found');
break;
case 'NETWORK_ERROR':
console.log('Check your connection');
break;
case 'SERVER_ERROR':
console.log('Try again later');
break;
}
}
/*
Output (on success):
{ ok: true, value: { id: '123', name: 'Alice' } }
Output (on 404):
{ ok: false, error: 'NOT_FOUND' }
*/
// The function signature tells you exactly what can fail
const processPayment = async (
amount: number
): AsyncResult<Receipt, 'INSUFFICIENT_FUNDS' | 'CARD_DECLINED' | 'NETWORK_ERROR'> => {
// ...
};
// TypeScript knows all possible errors
const result = await processPayment(100);
if (!result.ok) {
// Autocomplete shows: 'INSUFFICIENT_FUNDS' | 'CARD_DECLINED' | 'NETWORK_ERROR'
console.log(result.error);
}
import { run } from 'awaitly/run';
import { type ErrorsOf } from 'awaitly';
const deps = { fetchUser, validateAge, createAccount };
type Errors = ErrorsOf<typeof deps>;
const result = await run<Account, Errors>(async ({ step }) => {
const user = await step('fetchUser', () => fetchUser('123'));
// If fetchUser fails, we stop here and return the error
const validated = await step('validateAge', () => validateAge(user));
// If validateAge fails, we stop here
const account = await step('createAccount', () => createAccount(validated));
// If createAccount fails, we stop here
return account;
});
// Error is: 'NOT_FOUND' | 'UNDERAGE' | 'DUPLICATE_EMAIL' | UnexpectedError
import { Awaitly } from 'awaitly';
const results = await Promise.all([
fetchUser('1'),
fetchUser('2'),
fetchUser('3'),
]);
// Collect all outcomes (successes and failures)
const settled = Awaitly.allSettled(results);
// See [Awaitly.allSettled](/reference/api/) for the exact return shape.
// Partition into successes and failures
const [successes, failures] = Awaitly.partition(results);
console.log(`Found ${successes.length} users`);
console.log(`Failed to find ${failures.length} users`);
import { Awaitly } from 'awaitly';
const result = Awaitly.ok(5);
// Transform the value
const doubled = Awaitly.map(result, n => n * 2);
// { ok: true, value: 10 }
// Chain operations that might fail
const validated = Awaitly.andThen(result, n =>
n > 0 ? Awaitly.ok(n) : Awaitly.err('NEGATIVE')
);
  • Working with legacy code that throws
  • Wrapping third-party libraries (then convert to Result)
  • Truly exceptional conditions (out of memory, etc.)
  • Quick scripts where type safety isn’t critical
  • Your app is small, internal, or low-risk — plain async/await with disciplined error handling is perfectly reasonable
  • Building new APIs or functions
  • Errors are part of normal flow (validation, not found, etc.)
  • You want TypeScript to track error types
  • You need to compose multiple fallible operations
  • You want self-documenting error handling

You don’t have to rewrite everything. Wrap existing code:

import { Awaitly } from 'awaitly';
// Sync: Awaitly.from(fn, onError)
const result1 = Awaitly.from(() => JSON.parse(jsonString), () => 'PARSE_ERROR' as const);
// Async (outside workflows): Awaitly.fromPromise(promise, onError)
const result2 = await Awaitly.fromPromise(
fetch('/api/data').then(r => r.json()),
() => 'FETCH_ERROR' as const
);
// With error context from the cause
const result3 = Awaitly.from(
() => JSON.parse(data),
(cause) => ({ type: 'PARSE_ERROR' as const, message: String(cause) })
);
// Inside workflows: step.try('id', () => …, { error: 'MY_ERROR' })
Aspecttry/catchResult types
Error visibilityHidden in runtimeExplicit in types
Compiler helpNoneFull type checking
DocumentationSeparate (if any)In function signature
CompositionNested try/catchClean chaining
Partial failureManual handlingBuilt-in (allSettled)
Learning curveFamiliarNew pattern

The difference comes down to one thing: try/catch leaves failure out of the type, so you track it in your head. awaitly puts failure in the type, so the compiler tracks it for you.