Small and simple pipeable Result library in TypeScript to handle errors in a more functional way.
The pipe of operators allows to compose processes on Results
Motivation
I wanted to create a simple library to handle errors following the Result pattern and saw it as a challenge to make it pipeable and supporting asynchronous operations.
I was inspired by the rxjs.dev library for adding the pipeable operators.
npm install pipeable-resultResult is a functionnal approach to handling error.
In most cases it can be used in place of the traditionnal try/catch method.
- Compile time error check (meaning you cannot forget to handle the error before accessing the value).
- Explicit and predictable.
- No uncaught exceptions.
- Improved type safety (with TypeScript).
- Composability (functional chaining (map, tap, chain, ...)).
- Easier testing.
- Adds verbosity (needs to explicitly wraps values and errors in a
Result) - Complexify debugging (when having multiple layers of function calls, debuggers can have a harder time following the flow)
With try/catch
function divideBy(dividend: number, divisor: number) : number {
if (divisor == 0) {
throw new Error("Cannot divide by zero.");
}
return dividend / divisor;
}
---------------
let divisionValue: number;
try {
divisionValue = divideBy(10, 0);
console.log(divisionValue);
} catch (error) {
console.error(error);
}
if (divisionValue === null) {
return;
}
const someOtherCalculation = someFunction(divisionValue);With Result
function divideBy(dividend: number, divisor: number) : Result<number> {
if (divisor == 0) {
defect(new ResultError("DivisionByZero", "Cannot divide by zero."));
}
return succeed(dividend / divisor);
}
---------------
const divisionResult = divideBy(10, 0).pipe(
tap(console.log),
tapErr(console.error)
);
if (divisionResult.isFailure()) {
return;
}
const someOtherCalculation = someFunction(divisionResult.value());While the divideBy implementation is not simpler with Result, error handling is implicit with try/catch in the first example, increasing the risk of bugs. On the other hand, the Result approach enforces explicit error handling when accessing the returned value.
Checking if the Result is a Success or a Failure is a common way to determine what action perform and then safely access the data with value().
With try/catch
try {
const content = readFileSync("example.txt");
console.log("File content:", content);
} catch (error) {
console.error("Error:", error.message);
}With Result: using try/catch to return Result
function readFile(path: string): Result<string> {
try {
return succeed(readFileSync(path));
} catch (error) {
return defect(new ResultError("FileReading", `Failed to read file: ${error.message}`));
}
}
---------------
readFile("example.txt").pipe(
tap(console.log),
tapErr(console.error)
);In the second example, the function catches the Error thrown by readFileSync and converts it into a Result containing either the file content or the error. Most libraries handle errors by throwing them.
Now, we can safely use the readFile function to access the content of a file.
With try/catch
async function getData<T>(url: string): Promise<T> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
return await response.json() as T;
} catch (error) {
throw new Error(`Error fetching data: ${error}`);
}
}
async function getUser(userId: string): Promise<User> {
const url = `${window.location.origin}/api/users/${userId}`;
return await getData<User>(url);
}
async function getDocuments(documentIds: string[]): Promise<Document[]> {
const url = `${window.location.origin}/api/documents/${documentIds.join(',')}`;
return await getData<Document[]>(url);
}
function checkDocumentHasBeenValidated(document: Document): boolean {
return document.status === 'VALIDATED';
}
---------------
const userId = 'user123';
let validDocuments: Document[] = [];
try {
const user = await getUser(userId);
const documents = await getDocuments(user.documents);
validDocuments = documents.filter(checkDocumentHasBeenValidated);
} catch (error) {
console.error(error);
validDocuments = [];
}
console.log(`All valid document found: ${validDocuments.map(doc => doc.name.join(', '))}`);With Result
async function getData<T>(url: string): Promise<Result<T>> {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Response status: ${response.status}`);
}
const data = await response.json() as T;
return succeed(data);
} catch (error) {
return defect(new ResultError('FetchError', `Error fetching data: ${error}`));
}
}
async function getUserAsync(userId: string): Promise<Result<User>> {
const url = `${"window.location.origin"}/api/users/${userId}`;
return await getData<User>(url);
}
async function getDocumentsAsync(documentIds: string[]): Promise<Result<Document[]>> {
const url = `${"window.location.origin"}/api/documents/${documentIds.join(',')}`;
return await getData<Document[]>(url);
}
function checkDocumentHasBeenValidated(document: Document): boolean {
return document.status === 'VALIDATED';
}
---------------
const userId = 'user123';
(await getUserAsync(userId)).pipe(
map(user => getDocumentsAsync(user.documentIds)),
map(documents => documents.filter(checkDocumentHasBeenValidated)),
tap(validDocuments => console.log(`All valid document found: ${validDocuments.map(doc => doc.name).join(', ')}`)),
tapErr(err => console.error(err))
);The library offers two factory functions to create Result instances: succeed and defect.
Creates a Success, a result containing optionnaly a value and no error.
import { succeed } from "pipeable-result";
const result1 = succeed(5); // Successful Result with value 5
const result2 = succeed("Success!"); // Successful Result with value "Success!"
const result3 = succeed(); // Successful Result with no valueCreates a Failure, a result containing an error.
Note: the idiomatic way is to provide a specific type for a specific error so that an action can be performed depending on what went wrong.
import { defect, ResultError } from "pipeable-result";
type HttpNotFoundError = { [ErrorTag]: "HttpNotFoundError", code: 404, ressourceType: string };
...
const result = defect<HttpNotFoundError>({ [ErrorTag]: "HttpNotFoundError", code: 404, ressourceType: "MediaFile" });
// => Result with an error of type HttpNotFoundErrorUsed to safely wrap up an exception and turn it into a ResultError
Creates a Result containing either the value of the operation if succesful or an error if the operation throws an exception.
It can be called in 3 differents ways:
- using no error handler in which case an error of type
UnknownErrorwill be created if the operation throws. - using a predefined error that will be used if the operation throws.
- using a handler function with the exception as parameter that will be used if the operation throws.
const result = safe(() => 8); // Returns a `Success` with value 8
const result = safe(() => 8/0); // Returns a `Failure` with a `UnknownError`
const result = safe<DivisionByZeroError>(() => 8/0, { [ErrorTag]: "DivisionByZeroError" }); // Returns a `Failure` with a `DivisionByZeroError`
const result = safe<DivisionByZeroError>(() => 8/0, (ex) => ({ [ErrorTag]: "DivisionByZeroError", ex })); // Returns a `Failure` with a `DivisionByZeroError`Note:
Async operations are also supporteddeclare function getDatabaseConnectionAsync(): Promise<DatabaseConnection> const databaseConnectionResult = await safe<DatabaseConnection, ConnectionError>(getDatabaseConnectionAsync, { [ErrorTag]: "ConnectionError" }); // => databaseConnectionResult is of type `Result<DatabaseConnection, ConnectionError>`
The Result object offers a set of methods for handling and inspecting its state. Below are the core methods provided.
Returns true if the Result is a Success, false otherwise.
if (result.isSuccess()) {
console.log("Operation succeeded.");
}Returns true if the Result is a Failure, false otherwise.
if (result.isFailure()) {
console.log("Operation failed.");
}Safely retrieves the value inside the Result. If the Result is a Failure, it calls the provided error handler to return a value.
const result = unsafeCalculation(); // some Result<number, ResultError> to handle
const value = result.unwrap(error => 0); // safely unwrap the value by handling the error caseunwrap also provides a matching structure to handle each error exhaustively.
const result = unsafeCalculation();
const value = result.unwrap({
HttpResponseError: (error) => 0, // handle HttpResponseError in a certain way
NetworkError: (error) => doMoreCalculation(), // handle NetworkError in a different way
});Returns a string representation of the Result.
succeed("Hello").inspect(); // => `Success("Hello")`
defect({ [ErrorTag]: "TestError", message: "Failed process", code: 40 })
.inspect(); // => `Failure(TestError): { message: "Failed process", code: 40 }`Unsafely retrieves the value inside the Result. Throws an error if the Result is a Failure.
try {
const value = result.value();
} catch (error) {
console.error("Failed to retrieve value:", error);
}Returns the error inside a Failure result or null if the Result is a Success.
const error = result.error();
if (error) {
console.error("Error:", error.message);
}The pipe method allows chaining of transformations and side-effects on the Result. Each transformation function receives the Result, do some operation on it and returns it.
const result = succeed("hello")
.pipe(
map((x) => x.toUpperCase()), // Transform the value
tap((x) => console.log(x)) // Logs the value
);Below are some of the provided operators that can be used within the pipe.
Transforms a Success result value and wraps the output in a new Success. If the Result is a Failure, it returns the original Failure.
const result = succeed(5)
.pipe(map((x) => x * 2)); // Result with value 10Transforms a Failure result error and wraps it in a new Failure. If the Result is a Success, it returns the original Success.
const result = defect<SomeLowLevelError>({ [ErrorTag]: "SomeLowLevelError", code: 16 })
.pipe(
mapErr((e) => ({ [ErrorTag]: "SomeOtherError", message: `An error occurred during operation with code ${e.code}` }) as SomeOtherError)
); // Result with the new errorChains another operation on a Success result that returns a new Result. If the Result is a Failure, it returns the original Failure.
const result = succeed(5)
.pipe(chain((x) => succeed(x * 2))); // Result with value 10Chains an operation on a Failure result that returns a new Result. If the Result is a Success, it returns the original Success.
const result = defect(new ResultError("Error", "Something went wrong"))
.pipe(chainErr(() => succeed("Default value")));Performs a side-effect on a Success result value. Returns the original Result.
succeed("Task completed").pipe(
tap((value) => console.log("Success:", value)) // Logs "Success: Task completed"
);Performs a side-effect on a Failure result error. Returns the original Result.
defect<TaskFailedError>({ [ErrorTag]: "TaskFailedError" }).pipe(
tapErr((err) => console.error("Failure:", err.message)) // Logs "Failure: Task failed"
);Matches the Result against success and error handlers, executing the appropriate one based on the state of the Result.
const result: Result<number, ErrorType1 | ErrorType2> = await unsafeCalculation();
result.pipe(
match({
Success: value => succeed(value * 2),
ErrorType1: error => succeed(error.code === 500 ? true : false),
ErrorType2: error => someOtherCalculation(error),
})
);Errors are represented by the type ResultError.
They must use the symbol ErrorTag with string so they can be distinguished from each other at runtime in the matching structures (see methods match, matchErrors, unwrap, etc...) and any number of other keys.
The prefered way to create an error is to first create an error with the correct shape
type ExampleError = { [ErrorTag]: "ExampleError", someKey: number };Or extends ResultError
interface AnotherError extends ResultError {
[ErrorTag]: "AnotherError";
message: string;
}And then create a Failure using the defect factory
const result = defect<ExampleError>({ [ErrorTag]: "ExampleError", someKey: 0 });