Shulk
Introduction Get startedBasics
Tagged unions Pattern matchingMonads
Result Maybe LoadingAsync
Procedure ConcurrentlyOthers
WrappersThe Result monad for error handling
Why: try/catch is unsafe
To handle errors (or exceptions), most languages have implemented the try/catch
instruction, which is wacky in more ways than one.
First, it’s syntactically strange. We are incitated to focus on the successful execution path, as if the catch
instruction was ‘just in case something bad might happen’.
We never write code like this in other situation. We never assume a value is equal to another, we first check that assumption in an if
or a match
. In the same logic, we should never assume that our code will always work.
Moreover, in some languages such as TypeScript, we can’t even declare what kind of error we can throw, making the type safety in a catch
block simply non-existent.
The solution: Use the Result monad
The Result monad is a generic type (but really an union under the hood) that will force you to handle errors by wrapping your return types.
Type definition
type Result<ErrType, OkType> =
| { val: ErrType; _state: "Err" }
| { val: OkType; _state: "Ok" };
Constructor
Err
A Result representing a failure can be constructed with the Err
function.
function Err<T>(val: T): Result<T, never>;
Ok
A Result representing a success can be constructed with the Ok
function.
function Ok<T>(val: T): Result<never, T>;
Methods
The Result
monad exposes several methods to unwrap its value.
You can see a usage of each method in the example section below.
map
The map
method allows you to map the success state of a Result
to a new value.
function map<T>(fn: (val: OkType) => T): Result<ErrType, T>;
flatMap
The flatMap
method allows you to map the success state of a Result
to a new Result
.
function flatMap<E, O>(fn: (val: OkType) => Result<E, O>): Result<E, O>;
flatMapAsync
The flatMapAsync
method allows you to map the success state of a Result
to a new AsyncResult
.
function flatMapAsync<E, O>(
fn: (val: OkType) => AsyncResult<E, O>,
): AsyncResult<E, O>;
mapErr
The mapErr
method allows you to map the failure state of a Result
to a new value.
function mapErr<T>(fn: (val: ErrType) => T): Result<T, OkType>;
filter
The filter
method allows you to map a success state to a failure state if a condition isn’t met.
function filter<E>(
condition: (val: OkType) => boolean,
otherwise: () => E,
): Result<E | ErrType, OkType>;
filterType
The filterType
method allows you to map a success state to a failure state if a condition isn’t met, while narrowing the type of the success state.
function filter<E, O extends OkType>(
condition: (val: OkType) => val is O,
otherwise: () => E,
): Result<E, O>;
toMaybe
The toMaybe
method allows you to map a Result
to a Maybe
monad.
function toMaybe(): Maybe<OkType>;
toLoading
The toLoading
method allows you to map a Result
to a Loading
monad.
function toLoading(): Loading<ErrType, OkType>;
Result and pattern matching
Result is an union, which means you can handle it with match
.
import { Result, Ok, Err } from "shulk";
function divide(dividend: number, divisor: number): Result<string, number> {
if (divisor == 0) {
return Err("Cannot divide by 0!");
} else {
return Ok(dividend / divisor);
}
}
match(divide(2, 2)).case({
Err: () => console.log("Could not compute result"),
Ok: ({ val }) => console.log("Result is ", val),
});
Examples
Let’s make a function that divides 2 number and can return an error:
import { Result, Ok, Err } from "shulk";
function divide(dividend: number, divisor: number): Result<string, number> {
if (divisor == 0) {
return Err("Cannot divide by 0!");
} else {
return Ok(dividend / divisor);
}
}
// We can then handle our Result in a few different ways
// unwrap() is unsafe as it will throw the Error state, but can be useful for prototyping
divide(2, 2).unwrap(); // 1
divide(2, 0).unwrap(); // Uncaught Cannot divide by 0!
// expect() throws a custom message when it encounters an error state
// Like unwrap(), you shoudn't use it in a production context
divide(2, 2).expect("Too bad!"); // 1
divide(2, 0).expect("Too bad!"); // Uncaught Too bad!
// unwrapOr() will return the provided default value when encountering an error state
// It is safe to use in a production context, as the program cannot crash
divide(2, 2).unwrapOr("Not a number"); // 1
divide(2, 0).unwrapOr("Not a number"); // "Not a number"
// isOk() will return true if the Result has an Ok state
// When true, the compiler will infer that val has an OkType
divide(2, 2).isOk(); // true
divide(2, 0).isOk(); // false
// isErr() will return true if the Result has an Err state
// When true, the compiler will infer that val has an ErrType
divide(2, 2).isErr(); // false
divide(2, 0).isErr(); // true
// The val property contains the value returned by the function
// It is safe to use
divide(2, 2).val; // 1
divide(2, 0).val; // "Cannot divide by 0!"
// map() takes a function as an argument and return its value wrapped in an Ok state, or an Err state
divide(2, 2)
.map((res) => res.toString())
.unwrap(); // "1"
divide(2, 0)
.map((res) => res.toString())
.unwrap(); // Uncaught Cannot divide by 0!
// flatMap() takes a function that returns a Result, and return its value
divide(2, 2)
.flatMap((res) => Ok(res.toString()))
.unwrap(); // "1"
divide(2, 0)
.flatMap((res) => Ok(res.toString()))
.unwrap(); // Uncaught Cannot divide by 0!
// flatMapAsync() takes a function that returns a Result, and return its value in a Promise
divide(2, 2).flatMap((res) => Ok(res.toString())); // Promise
divide(2, 0).flatMap((res) => Ok(res.toString())); // Promise
// filter() evaluates a condition and returns a new Result
divide(2, 2).filter(
(res) => res == 1,
() => new Error("Result is not 1"),
); // 1
divide(4, 2).filter(
(res) => res == 1,
() => "Result is not 1",
); // "Result is not 1"
// filterType() evaluates a condition and returns a new Result wrapping the new type
divide(2, 2).filterType(
(res): res is number => res == 1,
() => new Error("Result is not 1"),
); // 1
divide(4, 2).filterType(
(res): res is number => res == 1,
() => "Result is not 1",
); // "Result is not 1"