Skip to content

Instantly share code, notes, and snippets.

@rlamacraft
Last active June 21, 2023 20:11
Show Gist options
  • Save rlamacraft/bf1727c63087098da10817e5351712b2 to your computer and use it in GitHub Desktop.
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 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