| name | error-handling |
| description | Error handling patterns using wellcrafted trySync and tryAsync. Use when writing or reviewing try-catch blocks, refactoring try-catch to linear control flow, working with Result types, or returning HTTP error responses from route handlers. |
| metadata | {"author":"epicenter","version":"1.2"} |
Error Handling with wellcrafted trySync and tryAsync
Use trySync/tryAsync Instead of try-catch for Graceful Error Handling
When handling errors that can be gracefully recovered from, use trySync (for synchronous code) or tryAsync (for asynchronous code) from wellcrafted instead of traditional try-catch blocks. This provides better type safety and explicit error handling.
Related Skills: See services-layer skill for defineErrors patterns and service architecture. See query-layer skill for error transformation to WhisperingError.
The Pattern
import { trySync, tryAsync, Ok, Err } from 'wellcrafted/result';
const { data, error } = trySync({
try: () => {
const parsed = JSON.parse(jsonString);
return validateData(parsed);
},
catch: (e) => {
console.log('Using default configuration');
return Ok(defaultConfig);
},
});
await tryAsync({
try: async () => {
const child = new Child(session.pid);
await child.kill();
console.log(`Process killed successfully`);
},
catch: (e) => {
console.log(`Process was already terminated`);
return Ok(undefined);
},
});
const syncResult = trySync({
try: () => riskyOperation(),
catch: (error) => {
return Ok('fallback-value');
return CompletionError.ConnectionFailed({ cause: error });
},
});
Key Rules
- Choose the right function - Use
trySync for synchronous code, tryAsync for asynchronous code
- Always await tryAsync - Unlike try-catch, tryAsync returns a Promise and must be awaited
- trySync returns immediately - No await needed for synchronous operations
- Match return types - If the try block returns
T, the catch should return Ok<T> for graceful handling
- Use Ok(undefined) for void - When the function returns void, use
Ok(undefined) in the catch
- Return Err for propagation - Use custom error constructors that return
Err when you want to propagate the error
- Transform cause in the constructor, not the call site - When wrapping a caught error, pass the raw error as
cause: unknown and let the defineErrors constructor call extractErrorMessage(cause) inside its message template. Don't call extractErrorMessage at the call site. This centralizes message extraction where the message is composed:
catch: (error) => CompletionError.ConnectionFailed({ cause: error })
catch: (error) => CompletionError.ConnectionFailed({ underlyingError: extractErrorMessage(error) })
- CRITICAL: Wrap destructured errors with Err() - When you destructure
{ data, error } from tryAsync/trySync, the error variable is the raw error value, NOT wrapped in Err. You must wrap it before returning:
const { data, error } = await tryAsync({...});
if (error) return error;
const { data, error } = await tryAsync({...});
if (error) return Err(error);
This is different from returning the entire result object:
const userResult = await tryAsync({...});
if (userResult.error) return userResult;
Examples
const { data: config } = trySync({
try: () => JSON.parse(configString),
catch: (e) => {
console.log('Invalid config, using defaults');
return Ok({ theme: 'dark', autoSave: true });
},
});
const { data: exists } = trySync({
try: () => fs.existsSync(path),
catch: () => Ok(false),
});
await tryAsync({
try: async () => {
await process.kill();
},
catch: (e) => {
console.log('Process already dead, continuing...');
return Ok(undefined);
},
});
const { data: content } = await tryAsync({
try: () => readFile(path),
catch: (e) => {
console.log('File not found, using default');
return Ok('default content');
},
});
const { data, error } = await tryAsync({
try: () => criticalOperation(),
catch: (error) =>
CompletionError.ConnectionFailed({ cause: error }),
});
if (error) return Err(error);
When to Use trySync vs tryAsync vs try-catch
Wrapping Patterns: Minimal vs Extended
The Minimal Wrapping Principle
Wrap only the specific operation that can fail. This captures the error boundary precisely and makes code easier to reason about.
const { data: stream, error: streamError } = await tryAsync({
try: () => navigator.mediaDevices.getUserMedia({ audio: true }),
catch: (error) =>
DeviceStreamError.PermissionDenied({ cause: error }),
});
if (streamError) return Err(streamError);
const mediaRecorder = new MediaRecorder(stream);
mediaRecorder.start();
const { data, error } = await tryAsync({
try: async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorder.start();
await someOtherAsyncCall();
return processResults();
},
catch: (error) => Err(error),
});
The Immediate Return Pattern
Return errors immediately after checking. This creates clear control flow and prevents error nesting.
const { data: devices, error: enumerateError } = await enumerateDevices();
if (enumerateError) return Err(enumerateError);
const { data: stream, error: streamError } = await getStreamForDevice(
devices[0],
);
if (streamError) return Err(streamError);
return Ok(stream);
const { data: devices, error: enumerateError } = await enumerateDevices();
if (!enumerateError) {
const { data: stream, error: streamError } = await getStreamForDevice(
devices[0],
);
if (!streamError) {
return Ok(stream);
} else {
return Err(streamError);
}
} else {
return Err(enumerateError);
}
When to Extend the Try Block
Sometimes it makes sense to include multiple operations in a single try block:
- Atomic operations - When operations must succeed or fail together
- Same error type - When all operations produce the same error category
- Cleanup logic - When you need to clean up on any failure
const { data: mediaRecorder, error: recorderError } = trySync({
try: () => {
const recorder = new MediaRecorder(stream, { bitsPerSecond: bitrate });
recorder.addEventListener('dataavailable', handleData);
recorder.start(TIMESLICE_MS);
return recorder;
},
catch: (error) =>
RecorderError.InitFailed({ cause: error }),
});
Real-World Examples from the Codebase
Minimal wrap with immediate return:
async function getStreamForDeviceIdentifier(
deviceIdentifier: DeviceIdentifier,
) {
return tryAsync({
try: async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { ...constraints, deviceId: { exact: deviceIdentifier } },
});
return stream;
},
catch: (error) =>
DeviceStreamError.DeviceConnectionFailed({ deviceId: deviceIdentifier, cause: error }),
});
}
Multiple minimal wraps with immediate returns:
startRecording: async (params, { sendStatus }) => {
if (activeRecording) {
return RecorderError.AlreadyRecording();
}
const { data: streamResult, error: acquireStreamError } =
await getRecordingStream({ selectedDeviceId, sendStatus });
if (acquireStreamError) return Err(acquireStreamError);
const { stream, deviceOutcome } = streamResult;
const { data: mediaRecorder, error: recorderError } = trySync({
try: () => new MediaRecorder(stream, { bitsPerSecond: bitrate }),
catch: (error) => RecorderError.InitFailed({ cause: error }),
});
if (recorderError) {
cleanupRecordingStream(stream);
return Err(recorderError);
}
mediaRecorder.start(TIMESLICE_MS);
return Ok(deviceOutcome);
},
Summary: Wrapping Guidelines
| Scenario | Approach |
|---|
| Single risky operation | Wrap just that operation |
| Sequential operations | Wrap each separately, return immediately on error |
| Atomic operations that must succeed together | Wrap together in one block |
| Different error types needed | Separate blocks with appropriate error types |
| Need cleanup on failure | Wrap, check error, cleanup if needed, return |
The goal: Each trySync/tryAsync block should represent a single "unit of failure" with a specific, descriptive error message.
Using trySync/tryAsync in HTTP Handlers
Not all error handling involves propagating Result types up a service chain. In HTTP route handlers (Elysia, Express, SvelteKit, etc.), you often want to convert errors directly into HTTP status responses. The same trySync/tryAsync patterns apply; you just return a status response instead of Err(...).
The Pattern: trySync → early return with status
async ({ body, headers, status }) => {
if (!isSupportedProvider(provider)) {
return status('Bad Request', `Unsupported provider: ${provider}`);
}
const { data: stream, error: chatError } = trySync({
try: () =>
chat({
adapter,
messages,
abortController,
}),
catch: (e) => Err(e instanceof Error ? e : new Error(String(e))),
});
if (chatError) {
if (chatError.name === 'AbortError' || abortController.signal.aborted) {
return status(499, 'Client closed request');
}
return status('Bad Gateway', `Provider error: ${chatError.message}`);
}
return toServerSentEventsResponse(stream, { abortController });
};
Key Differences from Service-Layer Usage
| Service layer | HTTP handler |
|---|
catch: (e) => ServiceErr({ message: '...' }) | catch: (e) => Err(e instanceof Error ? e : new Error(String(e))) |
if (error) return Err(error) | if (error) return status(502, error.message) |
| Propagates typed errors up the chain | Converts errors to HTTP responses immediately |
| Caller decides what to do with the error | Handler IS the final caller |
In HTTP handlers, you're the last stop. There's no caller above you to propagate to; you convert the error into a response and return it. The trySync pattern still gives you linear control flow and surgical error boundaries—you just use return status(...) instead of return Err(...).
Refactoring try-catch to trySync in Handlers
Before (try-catch with throw):
try {
const result = riskyCall();
return buildResponse(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw status(500, message);
}
After (trySync with early return):
const { data: result, error } = trySync({
try: () => riskyCall(),
catch: (e) => Err(e instanceof Error ? e : new Error(String(e))),
});
if (error) return status(500, error.message);
return buildResponse(result);
The trySync version wraps only the risky call, uses return consistently (no throw vs return mismatch), and keeps the happy path at the bottom of the function.