ts-failable
v0.6.1
Published
Library for type safe error handling in Typescript
Downloads
1,502
Readme
ts-failable
This is a library for type safe error/null handling in Typescript.
If you've ever wished for Rust's Result
type along with
the syntactic sugar (?
operator) in Typescript, you might
find this useful.
API Docs
https://dhruvrajvanshi.github.io/ts-failable/docs/index.html
Installation
npm install ts-failable
For type safe optionals, you need Typescript version >= 2.8.1
because it uses conditional types. Also, your runtime needs to
support Proxy
support, which is not supported in IE.
For IE support, there would be a less transparent version of Optional in the future.
Optional
Optional type is exposed which lets you access deeply nested nullable properties as if each intermediate object was defined.
import { Optional } from "ts-failable/optional";
type T = {
x?: {
y?: {
z: string;
}
}
}
const t: T = {};
const optionalT = Optional.of(t)
console.log(optionalT.x.y.z.valueOf()) // null
const optionalT1 = Optional.of<T>({
x: {
y: {
z: "asdf"
}
}
})
console.log(optionalT.x.y.z.valueOf()) // asdf
So, any null
/undefined
value along the path short
circuits the evaluation. If you're familiar with Kotlin,
this is similar to
optionalT.x?.y?.z
Caveats
- Currently, this only works on immutable objects. Mutating the object might have unintended consequences. This restriction will be removed in a later version. Submit a PR if you want this now :)
- Because it iternally uses proxy, each intermediate lookup involves an extra heap lookup and a string comparison (to check against "valueOf") so, if you're using this in a hot loop with a lot of nested keys, it will cost you.
- Properties that have type
Something | undefined
are converted tonull
by value of so you only have to check fornull
. - Conditional types are required so use Typescript version >= 2.8.1
Result
Why
In Typescript, there's no way to check the type of exceptions
that a function can throw at compile time. When you catch an exception,
you get a value of type any
. You can encode a result type Either
/Result
type like Haskell or Rust that can either be a success value or failure value.
Unlike Haskell and Rust, Typescript doesn't have special syntax to make chaining
of Result
values easier.
This library helps in reducing the boilerplate related to error handling in
a type safe manner. Think of it like async/await
syntax for error handling.
If you're familiar with Haskell or Scala, you know this as do
notation or
for/yield
syntax both of which allow you to chain failable computations
without nesting using flatMap
or >>=
.
In short, instead of this
let result = computation1()
.flatMap(r1 =>
computation2(r1)
.flatMap(r2 => computation3(r1, r2))
)
We would like to write this
let r1 = run(computation1());
let r2 = run(computation2(r1));
let r3 = run(computation3(r1, r2))
Any failure in an intermediate step should short circuit the whole thing.
Usage
We'll walk through a simple function named getNumber that takes an optional string and returns a number if the string is present, contains an integer and the integer is greater than 10. If any of these conditions fail, it should return a description of the error.
Start with importing a few things
import { Failable, failable } from "ts-failable";
Lets start with defining types for each of our failure cases.
type NOT_FOUND = {
type: "NOT_FOUND"
}
type NOT_A_NUMBER = {
type: "NOT_A_NUMBER";
str: string;
}
type TOO_SMALL = {
type: "TOO_SMALL";
value: number;
}
Our function would can throw one of these error types so let's name the error type of our function.
type GetNumberError = NOT_FOUND | NOT_A_NUMBER | TOO_SMALL;
Our function needs to take a string | undefined
argument and
needs to return something that will have a number
in case all
the conditions are met, or a GetNumberError
. This can be encoded
in ts-failure
by IFailable<number, GetNumberError>
.
Our function is really small but it has three distinct steps. To demonstrate chaining, I'll make each step as a seperate function.
const getString = (str: string | undefined) =>
failable<string, NOT_FOUND>(({ success, failure }) => {
if (str !== undefined) {
return success(str);
} else {
return failure({ type: NOT_FOUND });
}
});
const parseInteger = (str: string) =>
failable<number, NOT_A_NUMBER>(({ success, failure }) => {
const num = parseInt(str);
if (num === NaN) {
return failure({
type: NOT_A_NUMBER,
str: str
});
} else {
return success(num);
}
});
const getNumber = (optionalString: string | undefined) =>
failable<number, GetNumberError>(({ success, failure, run }) => {
const str = run(getString(optionalString));
const num = run(parseInteger(str));
if (num < 10) {
return failure({
type: TOO_SMALL,
value: num
});
} else {
return success(num);
}
})
You can see that getNumber
doesn't need to check for error at each step.
It run will propagate the intermediate errors upwards just like exceptions.
Run will only accept computations whose failure type is a subtype of the current context's failure type so you have to declare downstream failures in the type.
To handle an IFailable<T, E>, you can use .match
method to pattern match
on the result.
const logError = (err: GetNumberError) => {
if (err.type === "NOT_FOUND") {
console.log("Value not found");
} else if (err.type === "NOT_A_NUMBER") {
console.log(`Expected ${err.str} to be a number.`);
} else {
console.log(`${err.value} is greater than 10`);
}
};
const num = getNumber("10").match({
success:value => value,
failure:err => {
logError(err);
return 0;
}
});
.match
needs 2 functions that handle both success and failure cases.
The success
function receives the successful value of the computation
and failure
receives the error object that was thrown. The result type
of both the functions must be the same.
Async
The the library exposes another function failableAsync
that is useful
for running asynchronous functions. If the intermediate steps in the previous
example were asynchronous and returned promises, we could've written it like this.
import { failableAsync } from "ts-failable";
const getNumber = (optionalString: string | undefined) =>
failableAsync<number, GetNumberError>(async ({ success, failure, run }) => {
const str = run(await getString(optionalString));
const num = run(await parseInteger(str));
if (num < 10) {
return failure({
type: TOO_SMALL,
value: num
});
} else {
return success(num);
}
})
Only three things have been changed here.
failable
->failableAsync
- Added
async
keyword to the argument function. - Added
await
in front of arguments torun
that returnFailablePromise
.
FailablePromise<T, E>
is a type alias defined as.
type FailablePromise<T, E> = Promise<IFailable<T, E>>;
While dealing with APIs that deal with Promises and exceptions, you can wrap the functions that return Promises into FailablePromise returning functions like this.
type DB_ERROR = {
type: "DB_ERROR";
error: any; // or whatever the type of error your DB driver returns
}
const query = (q: string) =>
failableAsync<Row[], DB_ERROR>(async ({ success, failure }) => {
try {
const rows = await db.query(q); // or whatever your DB driver exposes
return success(rows);
} catch (e) {
return failure({
type: "DB_ERROR",
error: e
});
}
})
Now, you can use the query function in any failableAsync
context
while ensuring that DB_ERROR is either handled by the caller or
is propagated upwards.