Rust-like Result and Option for TypeScript
npm install @hazae41/result-and-optiondeno install jsr:@hazae41/result-and-option- 100% TypeScript and ESM
- No external dependencies
- Similar to Rust
When designing a function, you never know how to return that the action failed
This is the standard way of dealing with errors
But you are forced to try-catch, you also need to be aware that the function may throw
// does this throw? I don't know
function doSomething(): string
try {
const result = doSomething()
// use result
} catch(e: unknown) {
// use e (you don't know what it is)
}And the error is not typed, so you often end up checking if that's an error, and if it is not, you don't know what to do
try {
// ...
} catch(e: unknown) {
if (e instanceof Error)
// use e
else
// what should I do now? rethrow?
}The advantage is that the error is explicit (you know it can fail) and typed
But you have to check for instanceof Error each time
function doSomething(): string | Error
const result = doSomething()
if (result instanceof Error)
throw result
// use resultThe advantage is that you can use optional chaining ?.
function doSomething(): string | undefined
const maybeSlice = doSomething()?.slice(0, 5)But if you want to throw, you have to explicitly check for undefined, and the "burden of naming the error" is on you instead of the function you used
function doSomething(): string | undefined
const result = doSomething()
if (result === undefined)
throw new Error(`something failed, idk`)
// use resultAnd undefined may mean something else, for example, a function that reads from IndexedDB:
function read<T>(key: string): T | undefinedDoes undefined mean that the read failed? Or does it mean that the key doesn't exist?
This is the way
It's a simple object that allows you to do all of the methods above, and even more:
- Throw with
unwrap() - Get the data and error with
ok()anderr(), with support for optional chaining?. - Check the data and error with
isOk()andisErr()type guards - Map the data and error with
map()andmapErr() - Use a default value with
unwrapOr()
Use unwrap() to get the inner data if Ok or throw the inner error if Err
import { Result, Ok, Err } from "@hazae41/result-and-option"
function unwrapAndIncrement(result: Result<number>): number {
return result.unwrap() + 1
}
unwrapAndIncrement(Ok.new(0)) // will return 1
unwrapAndIncrement(Err.error("Error"))) // will throw Error("Error")Use ok() and err() to get an Option, and use inner to get the inner value if Some, or undefined if None
function maybeSlice(result: Result<string>): string | undefined {
return result.ok().inner?.slice(0, 5)
}
maybeSlice(new Ok("hello world")) // will return "hello"
maybeSlice(Err.error("Error")) // will return undefined You can easily map inner data if Ok and do nothing if Err, with support for async and sync
import { Result, Ok, Err } from "@hazae41/result-and-option"
function tryIncrement(result: Result<number, Error>): Result<number, Error> {
return result.mapSync(x => x + 1)
}
tryIncrement(new Ok(0)) // Ok(1)
tryIncrement(Err.error("Error")) // Err(Error("Error"))You can easily check for Ok or Err and it's fully type safe
import { Result, Ok, Err } from "@hazae41/result-and-option"
function incrementOrFail(result: Result<number, Error>): number | Error {
if (result.isOk())
result // Ok<number>
else
result // Err<Error>
}You can easily wrap try-catch patterns, with support for async and sync
const result = Result.runAndWrapSync(() => {
if (something)
return 12345
else
throw new Error("It failed")
})If another library implements its own Result type, as long as it has unwrap(), you can rewrap it to this library in one function
interface OtherResult<T> {
unwrap(): T
}
function rewrapAndIncrement(other: OtherResult<number>): Result<number> {
return Result.rewrap(other).mapSync(x => x + 1)
}When using Result, throwing is seen as "panicking", if something is thrown and is not expected, it should stop the software
So the try-catch pattern is prohibited in Result kingdom, unless you use external code from a library that doesn't use Result
try {
return new Ok(doSomethingThatThrows())
} catch(e: unknown) {
return new Err(e as Error)
}But, sometimes, you want to do a bunch of actions, unwrap everything, catch everyting and return Err
/**
* BAD EXAMPLE
**/
try {
const x = tryDoSomething().unwrap()
const y = tryDoSomething().unwrap()
const z = tryDoSomething().unwrap()
return new Ok(doSomethingThatThrows(x, y, z))
} catch(e: unknown) {
return new Err(e as Error)
}But what if you only want to catch errors thrown from Err.unwrap(), and not errors coming from doSomethingThatThrows()?
You can do so by using Result.unthrow(), it will do a try-catch but only catch errors coming from Err.throw()
return Result.unthrowSync<void, Error>(t => {
const x = tryDoSomething().throw(t)
const y = tryDoSomething().throw(t)
const z = tryDoSomething().throw(t)
return new Ok(doSomethingThatThrows(x, y, z))
})TLDR undefined is too low level and often leads to ugly and repetitive design patterns or bugs
When designing a function, you often encounter the case where you can't pass undefined.
function doSomething(text: string) {
return Buffer.from(text, "utf8").toString("base64")
}function bigFunction(text?: string) {
// ...
doSomething(text) // what if text is undefined?
// ...
}So you end up checking for undefined
function bigFunction(text?: string) {
// ...
if (text !== undefined) {
doSomething(text)
}
// ...
}This is annoying if we want to get the returned value
function bigFunction(text?: string) {
// ...
if (text !== undefined) {
const text2 = doSomething(text)
}
// can't use text2
}Checks become redundant if you need to map the value or throw an error
function bigFunction(text?: string) {
// ...
const text2 = text === undefined
? undefined
: doSomething(text)
// ...
const text3 = text2 === undefined
? undefined
: doSomethingElse(text2)
// ...
if (text3 === undefined)
throw new Error(`something is wrong`)
// use text3
}Why not check for undefined in the callee then?
function maybeDoSomething(text?: string) {
if (text === undefined) return
return Buffer.from(text, "utf8").toString("base64")
}If you know your argument is NOT undefined, it will force you to check for undefined after the call
function bigFunction(text: string) {
// ...
const text2 = doSomething(text) // text is never undefined
// text2 can now be undefined
}Or even worse, force you to use type assertion
function bigFunction(text: string) {
// ...
const text2 = doSomething(text) as string
// ...
}Let's keep the original function and create an intermediary function for undefined
function maybeDoSomething(text?: string) {
if (text === undefined) return
return doSomething(text)
}Now you have twice the amount of function in your app, and if one of them changes you have to change its intermediary function too
function bigFunction(text?: string) {
const maybeText = Option.from(text)
// ...
// you want to map it?
const maybeText2 = maybeText.mapSync(doSomething)
// ...
// you want to map it again?
const maybeText3 = maybeText2.mapSync(doSomethingElse)
// ...
// you want to quickly throw an error?
const text4 = maybeText3.unwrap() // string
// you want to throw a custom error?
const text4 = maybeText3.okOr(new Error(`Something is wrong`)).unwrap()
// you want to get a result?
const text4 = maybeText3.ok() // Result<string, Error>
// you want to get a custom result?
const text4 = maybeText3.okOr(new Error(`Something is wrong`))
// you want to come back to "string | undefined"?
const text4 = maybeText3.inner // string | undefined
// you want to do manual check?
if (maybeText3.isSome())
const text4 = maybeText3.inner // string
else
// ...
}