iron-oxide
iron-oxide
offers some Rust style exception handling and rudimentary pattern matching.
The Problem
Exception handling in JavaScript and TypeScript can be notoriously cumbersome and confusing. For example, Array.prototype.find
returns undefined
and Array.prototype.indexOf
returns -1
; the return types for these exceptions don't match and thus can be confusing, especially for newer developers. JS also allows you to throw errors and catch them using try/catch
blocks. The problem compounds itself even more when we start using promises and asynchronous code.
The Solution
In Rust, we have two types in the standard library Option
and Result
for handling exceptions. Option
is used to catch failure in a part of the application where it doesn't make sense to panic
, or throw
in the case of JavaScript. Result
is similar to Option
except that it can contain the reason for the failure, allowing us to say why a function failed.
Option
comes in two flavors:
Some
representing success or a valueNone
representing failure or lack of a value
Result
also comes in two flavors:
Ok
representing success or a valueErr
representing failure, or lack of a value, with a reason
Pattern Matching
Rust provides pattern matching through the use of the match
keyword. iron-oxide
attempts to replicate this by including a match
function.
Installation
This project is distributed via npm which is bundled with node and should be installed as one of your project's dependencies
.
$ npm install iron-oxide
ℹ️
iron-oxide
is written in TypeScript and comes bundled with type declarations
Using Option
Option
is used to catch errors in a uniform way, where we don't necessarily care about the failure reason. They are generic over T
.
There are two variants of Option<T>
:
None
, to indicate failure or lack of a valueSome(value)
, a wrapper around ofvalue
of typeT
import { Option, Some, None } from 'iron-oxide';
// Array.prototype.find returns undefined if it doesn't find a value
// let's fix that
function find<T>(
arr: T[],
predicate: (value: T) => boolean
): Option<T> {
const value = arr.find(v => predicate(v));
// if the value is undefined, we return `None`, our failure state
if (value === undefined) {
return None();
}
// otherwise, we wrap the value in `Some`
return Some(value);
}
Methods
Option
comes with some built-in methods for interacting with the wrapped value and handling what happens when the Option
is None
.
Interacting with the value
From our introductory example, we now have a find
function with the signature
function find<T>(arr: T[], predicate: (value: T) => boolean): Option<T>;
Commonly, we use the map
method to transform the value.
// we have an array of people, and want to find Tom and increment
// his age
const people = [
{
name: "Bob",
age: 25
},
{
name: "Sally",
age: 26
},
{
name: "Tom",
age: 28
}
];
const newTom = find(people, ({ name }) => name === "Tom")
.map(tom => ({
...tom,
age: tom.age + 1
});
Here, we find Tom
and increment his age inside of map
. But what happens if we look for someone else that doesn't exist?
const newLucy = find(people, ({ name }) => name === "Lucy")
.map(lucy => ({
...lucy,
age: lucy.age + 1
});
Nothing will actually happen. We would expect that normally this would throw a nasty exception, something on the lines of lucy is not defined
. In our case, find
returns None
, which won't cause the rest of our code to fail. But how do we figure out if the operation failed?
Checking for failure
Option
comes with a few ways to check if the value is None
, such as the isNone
method
if (newLucy.isNone()) {
console.log("Oops, we couldn't find lucy!");
}
Sometimes, we would rather the application throw if we find a None
. We do this with the unwrap
method
try {
newLucy.unwrap();
catch (err) {
// Throws "Called 'Option.unwrap' on a 'None' value"
console.err(err);
}
isSome
Option.isSome
returns whether or not the Option
is a Some
.
Option<T>.isSome = () => boolean;
Example
const carson = find(people, person => person.name === "Carson")
if (carson.isSome()) {
console.log("We found Carson");
} else {
console.log("We didn't find Carson");
}
isNone
Option.isNone
returns whether or not the Option
is None
.
Option<T>.isNone = () => boolean;
Example
const carson = find(people, person => person.name === "Carson")
if (carson.isNone()) {
console.log("We didn't find Carson");
} else {
console.log("We found Carson");
}
unwrap
Option.unwrap
returns the value contained in a Some
and throws if the Option
is None
.
Option<T>.unwrap = () => T;
Example
const carson = find(people, person => person.name === "Carson");
try {
carson.unwrap();
} catch (err) {
console.error(err);
}
unwrapOr
Option.unwrapOr
returns the value contained in a Some
and returns the provided default if the Option
is None
.
Option<T>.unwrapOr = (def: T) => T;
Example
const carson = find(people, person => person.name === "Carson") // => None
.unwrapOr({ name: "Carson", age: 27 }); // => { name: "Carson", age: 27 }
unwrapOrElse
Option.unwrapOrElse
returns the value contained in a Some
and returns the result of calling the provided closure if the Option
is None
.
Option<T>.unwrapOrElse = (def: () => T) => T;
Example
const carson = find(people, person => person.name === "Carson") // => None
.unwrapOrElse(() => { name: "Carson", age: 27 }); // => { name: "Carson", age: 27 }
map
Option.map
transforms the contained value of the option using the provided projection.
Option<T>.map<U> = (proj: (value: T) => U) => Option<U>;
Example
find(people, person => person.name === 'Tom') // => Some({ name: "Tom", age: 28 })
.map(person => person.name) // => Some("Tom")
mapOr
Option.mapOr
transforms the contained value and returns it, returning the default value if the Option
is None
.
Option<T>.mapOr<U> = (def: U, proj: (value: T) => U) => U;
Example
find(people, person => person.name === 'Carson') // => None
.mapOr("Carson", person => person.name) // => "Carson"
mapOrElse
Option.mapOr
transforms the contained value and returns it, returning the value returned by the provided closure if the Option
is None
.
Option<T>.mapOrElse<U> = (def: () => U, proj: (value: T) => U) => U;
Example
const defaultName = "Carson";
find(people, person => person.name === 'Carson') // => None
.mapOrElse(() => defaultName, person => person.name) // => "Carson"
okOr
Option.okOr
transforms the Option
into a Result
. If the Option
is None
the provided error will be used as the error of Result
.
Option<T>.okOr<E> = (err: E) => Result<T, E>;
Example
find(people, person => person.name === 'Carson') // => None
.okOr("Couldn't find Carson...") // => Err("Couldn't find Carson...")
okOrElse
Option.okOrElse
transforms the Option
into a Result
. If the Option
is None
the result of the provided error closure will be used as the error of Result
.
Option<T>.okOr<E> = (err: () => E) => Result<T, E>
Example
const cantFindMessage = "Couldn't find Carson..."
find(people, person => person.name === 'Carson') // => None
.okOr(() => cantFindMessage) // => Err("Couldn't find Carson...")
and
Option.and
returns None
if the Option
is None
, otherwise returns optionB
.
Option<T>.and<U> = (optionB: Option<U>) => Option<U>
Example
find(people, person => person.name === 'Tom') // => Some({ name: "Tom", age: 28 })
.and(Some({ profession: "Engineer" })) // => Some({ profession: "Engineer" })
andThen
Option.andThen
returns None
if the Option
is None
, otherwise returns the option contained within the provided closure.
Option<T>.andThen<U> = (f: (value: T) => Option<U>) => Option<U>;
Example
find(people, person => person.name === 'Tom') // => Some({ name: "Tom", age: 28 })
.and(() => Some({ profession: "Engineer" })) // => Some({ profession: "Engineer")
or
Option.or
returns the option, or returns optionB
if the Option
is None
.
Option<T>.or = (optionB: Option<T>) => Option<T>;
Example
const carson = find(people, person => person.name === 'Carson'); // => None
carson.or(find(people,person => person.name === 'Tom')) // => Some({ name: "Tom", age: 28 })
orElse
Option.orElse
returns the option, or returns optionB
returned by the given closure if the Option
is None
.
Option<T>.orElse = (f: () => Option<T>) => Option<T>;
Example
const carson = find(people, person => person.name === 'Carson'); // => None
carson.orElse(() => find(people,person => person.name === 'Tom')) // => Some({ name: "Tom", age: 28 })
filter
Option.filter
returns the Some
if the contained value satisfies the predicated, returning None
otherwise.
Option<T>.filter = (predicate: (value: T) => boolean) => Option<T>;
Example
find(people, person => person.name === 'Tom') // => Some({ name: "Tom", age: 28 })
.filter(person => person.name === 'Carson') // => None
zip
Option.zip
combines two Option
s into a single Option
of type Option<T, U>
Option<T>.zip<U> = (other: Option<U>) => Option<[T, U]>;
Example
const tom = find(people, person => person.name === 'Tom'); // => Some({ name: "Tom", age: 28 })
const bob = find(people, person => person.name === 'Bob'); // => Some({ name: "Bob", age: 25 })
tom.zip(bob) // => Some([{ name: "Tom", age: 28 }, { name: "Bob", age: 25 } ]);
expect
Option.expect
returns the wrapped value, or throws an error with the provided message
Option<T>.expect = (message: string) => T;
Example
find(people, person => person.name === 'Carson') // => None
.expect("Couldn't find Carson!") // => throws!
Using Result
Sometimes we want to give more information about the failure of a function than just returning None
. The Result
type is used to do this. It is similar to Option
in that it can wrap a value, but it can also wrap an error. Result
is generic over T
, the type of the value, and E
, the type of the error.
There are two variants of Result<T, E>
:
Ok(value)
, which indicates success and wraps thevalue
of typeT
Err(why)
, which indicates failure and wraps awhy
of typeE
, which should explain the cause of the failure
import { Result, Ok, Err } from 'iron-oxide';
// Number.parseInt returns NaN, which we would need to check for later
// let's fix that
function parseInt(
maybeInt: string,
radix?: number
): Result<number, string> {
const result = parseInt(maybeInt, radix);
if (isNaN(result)) {
return Err(
`Attempted to parse ${maybeInt} as an integer`
);
}
return Ok(result);
}
Methods
Result
comes with some built-in methods for interacting with the wrapped value and handling what happens when the Result
is Err
.
Interacting with the value
From our introductory example, we have our parseInt
function with the signature
function parseInt(str: string, radix?: number): Result<number, string>;
Commonly, we use the map
method to transform the value.
const numTimesTwo = parseInt("2")
.map(n => n * 2)
Here, parseInt
returns Ok(2)
, which can then be map
ped as we see fit. In JavaScript, if this were to fail, it would result in NaN
, or not a number
, meaning we would need to work with isNaN
in order to guard against the failure. Here, though, that's been taken care of for us inside of parseInt
and it will return a nice error message, as an Err
containing information about why the parsing failed. How do we then work with this error?
Interacting with the error
Result
also comes with some methods for working with the error we receive. Most notably, errors can be mapped with mapErr
.
const erroredParse = parseInt("foo")
.mapErr(errorMessage => {
return "Oops, we couldn't parse that value!"
})
This can become very useful when we have much more complex return types
enum MathError {
DivisionByZero,
NonPositiveLogarithm,
NegativeSquareRoot
}
type MathResult = Result<number, MathError>;
function div(x: number, y: number): MathResult {
if (y === 0) {
return Err(MathError.DivisionByZero);
} else {
return Ok(x / y);
}
}
function ln(x: number): MathResult {
if (x < 0) {
return Err(MathError.NonPositiveLogarithm);
} else {
return Ok(Math.log10(x))
}
}
function sqrt(x: number): MathResult {
if (x < 0) {
return Err(MathError.NegativeSquareRoot);
} else {
return Ok(Math.sqrt(x));
}
}
In each of these instances, our error could be one of the enum MathError
. This means we can tailor how we handle the error accordingly.
div(1, 0)
.mapErr(err => {
if (err === MathError.DivisionByZero) {
throw new Error("You attempted to divide by zero!")
}
});
ln(-1)
.mapErr(err => {
if (err === MathError.NonPositiveLogarithm) {
throw new Error("Natural log is not defined for values less than 0");
}
});
sqrt(-1)
.mapErr(err => {
if (err === MathError.NegativeSquareRoot) {
throw new Error("The square root of a negative number is imaginary" )
}
})
map
Result.map
transforms the contained value using the provided projection.
Result<T, E>.map = <U>(proj: (a: T) => U) => Result<U, E>;
Example
div(2, 2).map(n => n * 2); // Ok(2)
mapErr
Result.mapErr
transforms the contained error using the provided projection.
Result<T, E>.mapErr = <F>(op: (err: E) => F) => Result<T, F>;
Example
div(2, 0).mapErr<string>(err => {
if (err === MathError.DivisionByZero) {
return 'You tried to divide by zero. That doesn\'t work'
}
return err;
}); // Err('You tried to divide by zero. That doesn\'t work')
mapOr
Result.mapOr
transforms the contained value and returns it, returning the default value if the result is Err
.
Result<T,E>.mapOr = <U>(def: U, proj: (a: T) => U) => U;
Example
div(2, 0).mapOr(0, n => n * 2) // Ok(0)
mapOrElse
Result.mapOr
transforms the contained value and returns it, returning the value returned by the provided closure if the result is Err
.
Result<T,E>.mapOrElse = <U>(def: () => U, proj: (a: T) => U) => U;
Example
div(2, 0).mapOr(() => 0, n => n * 2) // Ok(0)
isOk
Result.isOk
returns whether or not the result is Ok
Result<T, E>.isOk(): boolean;
Example
div(2, 2).isOk(); // => true
div(2, 0).isOk(); // => false
isErr
Result.isErr
returns whether or not the result is Err
Result<T, E>.isErr(): boolean;
Example
div(2, 2).isErr(); // => false
div(2, 0).isErr(); // => true
ok
Result.ok
converts Result<T, never>
into Option<T>
, None
if the result is Err
.
Result<T, E>.ok = () => Option<T>;
Example
div(2, 2) // => Ok(1)
.ok() // => Some(1)
err
Result.err
converts Result<never, E>
into Option<E>
, None
if the result is Ok
.
Result<T, E>.err = () => Option<E>;
Example
div(2, 2) // => Ok(1)
.err() // => None
and
Result.and
returns the supplied res
if the result is Ok
, returns Err<never>
otherwise.
Result<T, E>.and<U> = (res: Result<U, E>) => Result<U, E>;
Example
div(2, 2) // => Ok(1)
.and(div(3, 3)) // => Ok(1)
andThen
Result.andThen
returns the result of the supplied res
closure if the result is Ok
, returns Err<never>
otherwise.
Result<T, E>.andThen<U> = (op: (a: T) => Result<U, E>): Result<U, E>;
Example
div(2, 2) // => Ok(1)
.andThen(() => div(3, 3)) // => Ok(1)
S
or
Result.or
returns the option, or returns optionB
if the result is Err
.
Result<T, E>.or = (res: Result<T, E>) => Result<T, E>;
Example
div(2, 0) // => Err(MathError.DivisionByZero)
.or(div(1, 1)) // => Ok(1)
orElse
Result.orElse
returns the option, or returns the result of the optionB
closure if the result is Err
.
Result<T, E>.orElse<F> = (op: (a: E) => Result<T, F>) => Result<T, F>;
Example
div(2, 0) // => Err(MathError.DivisionByZero)
.orElse(() => div(1, 1)) // => Ok(1)
unwrap
Result.unwrap
returns the value contained in an Ok
and throws if the result is Err
.
Result<T,E>.unwrap = () => T;
Example
div(1, 1).unwrap(); // => 1
unwrapErr
Result.unwrapErr
returns the error contained in an Err
and throws if the result is Ok
.
Result<T,E>.unwrapErr = () => E;
Example
div(1, 0).unwrap(); // => MathError.DivisionByZero
unwrapOr
Result.unwrapOr
returns the value contained in an Ok
and returns the provided default if the result is Err
.
Result<T, E>.unwrapOr = (optb: T) => T;
Example
div(1, 0).unwrapOr(0); // => 0
unwrapOr
Result.unwrapOr
returns the value contained in an Ok
and returns the result of calling the provided closure if the result is Err
.
Result<T, E>.unwrapOrElse = (op: (err: E) => T) => T;
Example
div(1, 0).unwrapOrElse(() => 0); // => 0
expect
Result.expect
returns the contained value, throwing the provided message if the result is Err
.
Result<T, E>.expect = (msg: string) => T;
Example
div(2, 0) // => Ok(1)
.expect("Something went wrong while dividing") // => None
expectErr
Result.expectErr
returns the contained error, throwing the provided message if the result is Ok
.
Result<T, E>.expectErr = (msg: string) => E;
Example
div(2, 0) // => Ok(1)
.expectErr("Something went wrong while dividing") // => Some("Somethign went wrong while dividing")
Using match
iron-oxide
offers a rudimentary way to mimic rust
's match
keyword. In rust
the match
keyword is used similarly to the way a switch statement is used in JavaScript and TypeScript, except for one key difference: match
works with more than just primitive values.
API
match
takes two arguments
- The value, of type T, we are matching
- A list of
MatchStatement
s, which are generic overT
the type of the value, andR
the return value of thematch
Each match statement is a tuple of two things
- The value, of type T, or a predicate function which takes the value and returns a
boolean
- The function to call if the predicate matches. This receives the matched value
If no statement is matched, and matchUtils.fallback
is not given, match
will throw an error.
Example
With a primitive value
import { match } from 'iron-oxide';
function getParity(n: number) {
return match(n, [
[0, () => 'neitherEvenNorOdd'],
[x => x % 2 === 0, () => 'even'],
[x => x % 2 !== 0. () => 'odd']
]);
}
With an Option
(here, find returns Option<Person>
)
import { match } from 'iron-oxide`;
function getPerson(name: string) {
const maybePerson = find(people, person => person.name === name);
return match(maybePerson, [
[isNone, () => console.log("Couldn't find a person with the name:", name)],
[isSome, (person) => console.log("Found the person:", person)]
]);
}
matchUtils
iron-oxide
also exports a set of utilities that can be used with a matchStatement
, these are
isOption
, matches anyOption
isSome
, matches anySome
isNone
, matches anyNone
isResult
, matches anyResult
isOk
, matches anyOk
isErr
, matches anyErr
fallthrough
, which matches any and all values