From 275d88def5c0542fb225ffbd162cb2eb006d1951 Mon Sep 17 00:00:00 2001 From: Max Morozov Date: Fri, 4 Apr 2025 05:02:30 +0200 Subject: [PATCH 1/2] refactor: improve error handling and message annotation --- packages/try-catch-tuple/src/tryCatch.ts | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/try-catch-tuple/src/tryCatch.ts b/packages/try-catch-tuple/src/tryCatch.ts index 83437c3..0307f23 100644 --- a/packages/try-catch-tuple/src/tryCatch.ts +++ b/packages/try-catch-tuple/src/tryCatch.ts @@ -148,15 +148,29 @@ tryCatch.sync = tryCatchSync; tryCatch.async = tryCatchAsync; tryCatch.errors = tryCatchErrors; +// 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; } + +// Utility to prefix error messages with operation context +function annotateErrorMessage( + 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; +} From 10d1509a523f09c96613a1df1af83244dd258337 Mon Sep 17 00:00:00 2001 From: Max Morozov Date: Fri, 4 Apr 2025 05:18:04 +0200 Subject: [PATCH 2/2] feat: allow returning Error instances in tryCatch --- .changeset/tender-dogs-tie.md | 7 +++ packages/try-catch-tuple/README.md | 20 +++++++ packages/try-catch-tuple/src/tryCatch.ts | 28 +++++++-- .../try-catch-tuple/tests/tryCatch.test.ts | 60 +++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 .changeset/tender-dogs-tie.md diff --git a/.changeset/tender-dogs-tie.md b/.changeset/tender-dogs-tie.md new file mode 100644 index 0000000..30e2b02 --- /dev/null +++ b/.changeset/tender-dogs-tie.md @@ -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. diff --git a/packages/try-catch-tuple/README.md b/packages/try-catch-tuple/README.md index 409ed14..77f53fc 100644 --- a/packages/try-catch-tuple/README.md +++ b/packages/try-catch-tuple/README.md @@ -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 diff --git a/packages/try-catch-tuple/src/tryCatch.ts b/packages/try-catch-tuple/src/tryCatch.ts index 0307f23..431d14b 100644 --- a/packages/try-catch-tuple/src/tryCatch.ts +++ b/packages/try-catch-tuple/src/tryCatch.ts @@ -10,7 +10,7 @@ type DataErrorTuple = Branded< /** * Represents a successful result where `data` is present and `error` is `null`. */ -export type Success = DataErrorTuple; +export type Success = DataErrorTuple, null>; /** * Represents a failure result where `error` contains an error instance and `data` is `null`. @@ -20,7 +20,9 @@ export type Failure = DataErrorTuple; /** * Represents the result of an operation that can either succeed with `T` or fail with `E`. */ -export type Result = Success | Failure; +export type Result = + | Success + | Failure>; /** * Resolves the return type based on whether `T` is a promise: @@ -107,7 +109,7 @@ export const tryCatch: TryCatch = ( if (result instanceof Promise) return tryCatchAsync(result, operationName) as TryCatchResult; - return [result, null] as TryCatchResult; + return handleResult(result, operationName) as TryCatchResult; } catch (rawError) { return handleError(rawError, operationName) as TryCatchResult; } @@ -119,7 +121,7 @@ export const tryCatchSync: TryCatch["sync"] = ( ) => { try { const result = fn(); - return [result, null] as Result; + return handleResult(result, operationName); } catch (rawError) { return handleError(rawError, operationName); } @@ -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, E>; + return handleResult, E>(result, operationName); } catch (rawError) { return handleError(rawError, operationName); } @@ -148,6 +150,22 @@ tryCatch.sync = tryCatchSync; tryCatch.async = tryCatchAsync; tryCatch.errors = tryCatchErrors; +// Handles a result which might be an error, annotates if needed +function handleResult( + result: T, + operationName?: string, +): Result { + if (!isError(result)) { + return [result, null] as Success; + } + + if (operationName) { + annotateErrorMessage(result, operationName); + } + + return [null, result] as Failure>; +} + // Handles a raw unknown error, ensures it's wrapped and annotated function handleError(rawError: unknown, operationName?: string) { const processedError = isError(rawError) diff --git a/packages/try-catch-tuple/tests/tryCatch.test.ts b/packages/try-catch-tuple/tests/tryCatch.test.ts index 6297e87..65c71da 100644 --- a/packages/try-catch-tuple/tests/tryCatch.test.ts +++ b/packages/try-catch-tuple/tests/tryCatch.test.ts @@ -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()` 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; + }); + }); + }); + describe("handleError", () => { const now = new Date(); test.each([