Skip to content

feat: allow returning Error instances in tryCatch #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tender-dogs-tie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@maxmorozoff/try-catch-tuple": minor
---

feat: allow returning Error instances in tryCatch

- Enhanced `tryCatch` to support returning an `Error` instance instead of throwing it.
20 changes: 20 additions & 0 deletions packages/try-catch-tuple/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ console.log(nullError.cause); // null

This ensures tryCatch always provides a proper error object.

#### Return Error

You can also return an `Error` instance instead of throwing it:

```ts
const [result, error] = tryCatch(() => {
if (Math.random() < 0.5) {
return new Error("Too small"); // Return Error instead of throwing it
}

return { message: "ok" };
});
if (!error) return result;
// ^? const result: { message: string }
error;
// ^? const error: Error
result;
// ^? const result: null
```

#### Extending Error types

##### Option 1: Manually Set Result and Error Type
Expand Down
52 changes: 42 additions & 10 deletions packages/try-catch-tuple/src/tryCatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ type DataErrorTuple<T, E> = Branded<
/**
* Represents a successful result where `data` is present and `error` is `null`.
*/
export type Success<T> = DataErrorTuple<T, null>;
export type Success<T> = DataErrorTuple<Exclude<T, Error>, null>;

/**
* Represents a failure result where `error` contains an error instance and `data` is `null`.
Expand All @@ -20,7 +20,9 @@ export type Failure<E extends Error> = DataErrorTuple<null, E | Error>;
/**
* Represents the result of an operation that can either succeed with `T` or fail with `E`.
*/
export type Result<T, E extends Error> = Success<T> | Failure<E>;
export type Result<T, E extends Error> =
| Success<T>
| Failure<E | Extract<T, Error>>;

/**
* Resolves the return type based on whether `T` is a promise:
Expand Down Expand Up @@ -107,7 +109,7 @@ export const tryCatch: TryCatch = <T, E extends Error = Error>(
if (result instanceof Promise)
return tryCatchAsync(result, operationName) as TryCatchResult<T, E>;

return [result, null] as TryCatchResult<T, E>;
return handleResult<T, E>(result, operationName) as TryCatchResult<T, E>;
} catch (rawError) {
return handleError(rawError, operationName) as TryCatchResult<T, E>;
}
Expand All @@ -119,7 +121,7 @@ export const tryCatchSync: TryCatch["sync"] = <T, E extends Error = Error>(
) => {
try {
const result = fn();
return [result, null] as Result<T, E>;
return handleResult<T, E>(result, operationName);
} catch (rawError) {
return handleError(rawError, operationName);
}
Expand All @@ -135,7 +137,7 @@ export const tryCatchAsync: TryCatch["async"] = async <
try {
const promise = typeof fn === "function" ? fn() : fn;
const result = await promise;
return [result, null] as Result<Awaited<T>, E>;
return handleResult<Awaited<T>, E>(result, operationName);
} catch (rawError) {
return handleError(rawError, operationName);
}
Expand All @@ -148,15 +150,45 @@ tryCatch.sync = tryCatchSync;
tryCatch.async = tryCatchAsync;
tryCatch.errors = tryCatchErrors;

// Handles a result which might be an error, annotates if needed
function handleResult<T, E extends Error>(
result: T,
operationName?: string,
): Result<T, E> {
if (!isError(result)) {
return [result, null] as Success<T>;
}

if (operationName) {
annotateErrorMessage(result, operationName);
}

return [null, result] as Failure<Extract<T, E>>;
}

// Handles a raw unknown error, ensures it's wrapped and annotated
function handleError(rawError: unknown, operationName?: string) {
const processedError =
rawError instanceof Error
? rawError
: new Error(String(rawError), { cause: rawError });
const processedError = isError(rawError)
? rawError
: new Error(String(rawError), { cause: rawError });

if (operationName) {
processedError.message = `Operation "${operationName}" failed: ${processedError.message}`;
annotateErrorMessage(processedError, operationName);
}

return [null, processedError] as Failure<typeof processedError>;
}

// Utility to prefix error messages with operation context
function annotateErrorMessage<E extends Error>(
error: E,
operationName: string,
): void {
error.message = `Operation "${operationName}" failed: ${error.message}`;
}

// Type guards
function isError(error: unknown): error is Error {
if (!error) return false;
return Error?.isError?.(error) ?? error instanceof Error;
}
60 changes: 60 additions & 0 deletions packages/try-catch-tuple/tests/tryCatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,66 @@ describe("tryCatch", () => {
});
});

describe("handleResult", () => {
test("should handle returned Error", () => {
const [result, error] = tryCatch(() => {
return new Error("test");
});
expect(result).toBeNil();
if (!error) expect.unreachable();
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("test");
error satisfies Error;
});

test("should handle returned async Error", async () => {
const [result, error] = await tryCatch(async () => {
return new Error("test");
});
expect(result).toBeNil();
if (!error) expect.unreachable();
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe("test");
error satisfies Error;
});

describe("should handle multiple errors", () => {
/**
* @note Explicit return type is required!
*
* TypeScript automatically collapses `SyntaxError` into `Error` because `SyntaxError` extends `Error`.
* Without an explicit return type, TypeScript infers `Error | number`, discarding `SyntaxError`.
*
* By explicitly declaring `Error | SyntaxError | number`, we force TypeScript to retain `SyntaxError`
* instead of generalizing it into `Error`.
*
* Use of `.error<E>()` helper also resolves this.
*/
const multipleErrorTypes = (n: number): Error | SyntaxError | number => {
if (n === 0) {
return new SyntaxError("test");
}
if (n === 1) {
return new Error("test");
}
return 1;
};

test.each([
[0, SyntaxError],
[1, Error],
] as const)("should handle %s", (n, errorClass) => {
const [result, error] = tryCatch(() => multipleErrorTypes(n));
// ^?
expect(result).toBeNil();
if (!error) expect.unreachable();
expect(error).toBeInstanceOf(errorClass);
expect(error.message).toBe("test");
error satisfies InstanceType<typeof errorClass>;
});
});
});

describe("handleError", () => {
const now = new Date();
test.each([
Expand Down