Last active
June 21, 2023 20:11
-
-
Save rlamacraft/bf1727c63087098da10817e5351712b2 to your computer and use it in GitHub Desktop.
Roc's Result type in TypeScript; an arguably a better version of Maybe type
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* This type defines the internal state of our class so | |
* that we can use type refinement to check each branch. | |
* It should not be exported from this module and is | |
* purely an implementation detail. | |
*/ | |
type InternalResultState<Value, Error> = { | |
tag: "Ok", | |
value: Value, | |
} | { | |
tag: "Error" | |
error: Error, | |
}; | |
/* | |
* Roc (roc-lang.org) has neither a null type nor a Maybe type. | |
* Instead it has a Result type which behaves just like a Maybe | |
* type except that rather than the Nothing branch containing no | |
* information, here the Error branch contains a set of strings | |
* that describe the possible reasons for not having a value. This | |
* only works in languages like TypeScript and Roc which have true | |
* union types. In TypeScript we use union of string literals as a | |
* way of modeling a finite set of possible values. In Roc, there | |
* are Tags which are like tagged unions / algebraic data types | |
* from languages like Haskell and Elm but are defined on the fly. | |
*/ | |
class Result<Value, Error extends string> { | |
private state: InternalResultState<Value, Error>; | |
// DO NOT USE; use Result.Ok or Result.Error instead | |
constructor(state: InternalResultState<Value, Error>) { | |
this.state = state; | |
} | |
/* | |
* This static constructor is the equivalent of "Just" | |
* or "Some" in languages which have a Maybe type. | |
*/ | |
static Ok<Value, Error extends string>(value: Value): Result<Value, Error> { | |
return new Result({ | |
tag: "Ok", | |
value, | |
}); | |
} | |
/* | |
* This static constructor is the equivalent of "Nothing" or | |
* "None" in languages which have a Maybe type, except note | |
* how this function takes as argument the error string which | |
* is a justification for why there is no value. | |
*/ | |
static Error<Value, Error extends string>(error: Error): Result<Value, Error> { | |
return new Result({ | |
tag: "Error", | |
error, | |
}); | |
} | |
isOk(): boolean { | |
return this.state.tag === "Ok"; | |
} | |
isErr(): boolean { | |
return this.state.tag === "Error"; | |
} | |
/* | |
* All Maybe definitions have a way of providing some | |
* default value for the Nothing case, thereby always | |
* returning a valid value. No different here, the error | |
* information is simply discarded. | |
*/ | |
withDefault(defaultValue: Value): Value { | |
if(this.state.tag === "Ok") return this.state.value; | |
return defaultValue; | |
} | |
/* | |
* Standard map function, just like any other functor. | |
*/ | |
map<NewValue>(f: (value: Value) => NewValue): Result<NewValue, Error> { | |
if(this.state.tag === "Ok") return Result.Ok(f(this.state.value)); | |
return Result.Error(this.state.error); | |
} | |
/* | |
* Being able to map over the err too makes a bifunctor, | |
* just like Either or Pair, I suppose. | |
*/ | |
mapErr<NewError extends string>(f: (error: Error) => NewError): Result<Value, NewError> { | |
if(this.state.tag === "Ok") return Result.Ok(this.state.value); | |
return Result.Error(f(this.state.error)); | |
} | |
/* | |
* This method is the equivalent of `bind` or `andThen`, where | |
* we're doing monadic composition to chain functions that return | |
* Results. Note how we're accumulating all of the possible errors | |
* by taking the union of `NewError` and `Error` in the return type. | |
*/ | |
try<NewValue, NewError extends string>(f: (value: Value) => Result<NewValue, NewError>): Result<NewValue, NewError | Error> { | |
if(this.state.tag === "Ok") return f(this.state.value); | |
return Result.Error(this.state.error); | |
} | |
/* | |
* This method is much like `try`, but instead of applying the | |
* function when we have the `Ok` branch we're instead applying | |
* it when we have the `Error` branch. Only difference being that | |
* here we're replacing the value being passed along where in `try` | |
* we're accumulating the errors. | |
*/ | |
onErr<NewError extends string>(f: (error: Error) => Result<Value, NewError>): Result<Value, NewError> { | |
if(this.state.tag === "Ok") return Result.Ok(this.state.value); | |
return f(this.state.error); | |
} | |
} | |
/* | |
* Now an example of how this is used with two simple functions that can error. | |
*/ | |
function divide(m: number, n: number): Result<number, "DivideByZero"> { | |
if(n === 0) return Result.Error("DivideByZero"); | |
return Result.Ok(m / n); | |
} | |
function head(list: number[]): Result<number, "ListIsEmpty"> { | |
if(list.length > 0) return Result.Ok(list[0]); | |
return Result.Error("ListIsEmpty"); | |
} | |
/* | |
* Note how the errors accumulate and how we can then handle | |
* them both with one call to `withDefault`. I really like | |
* how the type just visually looks: we could either have a | |
* number, or a divide-by-zero error, or a list-is-empty error. | |
* Much more self-documenting than if all we had was `Maybe<number>`. | |
*/ | |
const result: Result<number, "DivideByZero" | "ListIsEmpty"> = head([0, 1, 2]).try(n => divide(3, n)); | |
const orElse: number = result.withDefault(0); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment