Since this is Code Review, and not Stack Overflow where you'd just want an answer... first a couple comments on your code:
First off, this is not a monad with 4 shapes. This is a monad with 6 shapes. "maybe with" indicates you have more than one shape. This is a lot of shapes to keep track of with a single class. The data class is really: Maybe<OneOf<Maybe<Error>, Maybe<Percent>, Data>>
... and that's bound to be messy.
If you declare the type of the variable, TypeScript will not infer a more specific type. There's no cast needed here.
// I cast to make typescript forget about the fact that the data is loaded
const data: Data<number> = Data.loaded(3) as any as Data<number>
Don't lie to the compiler. The flatMap
implementation has:
case 'failed':
return Data.failed<R, E>((this.error as unknown) as E);
This can (and likely will) result in an error which isn't correct. You should either return Data<R, E | E2>
or force the callback to return Data<R, E>
where E
is the same as the container.
Does it make sense to have a Data<Data<number, Error>, Error>
? I don't think it does... this is the same problem that the authors of the Promise
spec had to solve, and they decided to solve it by breaking the monad laws. It may make sense to do the same. Data
seems conceptually closer to Promise
than Either
to me.
Since you have the join
accessor, I would make data
private. You can then use the this
parameter to force an error if TS doesn't know that there is data present.
static getData<T, E>(wrapped: Data<T, E> & { kind: "loaded", data: T }) {
return wrapped.join();
}
join(this: { data: T }) {
return this.data;
}
I would expect the get*OrElse
functions to return NonNullable
types. When I call getPercentOrElse(5)
I'd expect to receive a number, but I still might get undefined
.
It might be worth making a const enum DataState { initial, loading, loaded, failed }
to avoid typing strings everywhere. As const enum
s are inlined by the compiler, there will be no performance loss (and you can still use strings if you like).
Here's one possible fix for your Data.getData
function. I assume you don't want it to work unless you know Data<T, E>
is actually loaded
, so make that explicit, and your number | undefined
issue goes away.
static getData<T, E>(wrapped: Data<T, E> & { kind: "loaded", data: T }) {
wrapped.data;
}
Alternatively, you can use conditional types to optionally include undefined
in the return type, but this would be messier.
Putting this last as it is more of a frame challenge than a review of your code:
I believe the Data
class does too much. I'd rather deal with multiple less complicated objects.
What is the problem we are trying to solve here? We are pulling data from a back end API. This API might return the data we want, an error, or the network response itself might error. Thus, we have Either<NetworkError | APIError, Data>
.
Now, before the request resolves (either with data or an error) we will be in a loading state. We could overload our Either<NetworkError | APIError, Data>
with another possible state, or we could let our caller deal with that, and only call this class once a concrete result is available.
I prefer the second option. This will be achieved with a Request
which will always have a "percent" completion (just 0 if not updated by the user) and a result. For convenience, a Request
(unlike Either
) will be mutable. Also for convenience, it will include the Left
side of an Either
result as its own Error
state.
If you might not have actually made a request yet, we might not have a percent completion or a result. Instead of adding an "initial" state, I'll just wrap cases which require this in Maybe
.
With these changes, here's what a simple app that makes an API request when the user clicks a button and displays the result could look like.
// This would be nicer to do with a React component
let state: Maybe<Request<string, string>> = Maybe.nothing();
const button = document.querySelector('button')!;
const result = document.querySelector('#result') as HTMLDivElement;
function render() {
state.match({
nothing() {
button.hidden = false;
result.hidden = true;
},
just(request) {
button.hidden = true;
result.hidden = false;
request.match({
loading(percent) {
result.textContent = `Loading: ${percent * 100}%`;
},
error(error) {
result.textContent = `ERROR: ${error}`;
},
result(data) {
result.textContent = data;
}
});
}
});
}
// Todo
function makeResponse(): Request<string, string> {}
button.addEventListener('click', () => {
state = Maybe.just(makeResponse());
render();
});
render();
I don't actually need fully fleshed out Maybe
and Either
classes, so here's what I'll use:
export class Maybe<T> {
static nothing<T>() { return new Maybe<T>(undefined) }
static just<T>(value?: T) { return new Maybe(value) }
private constructor(private value?: T) {}
match<A, B>({ nothing, just }: {
nothing: () => A,
just: (value: T) => B
}): A | B {
return this.value == null ? nothing() : just(this.value)
}
}
// It isn't safe to use `undefined` as an empty signal since we could have `Either<undefined, number>`
// Technically this applies to Maybe<T> as well, but I find it useful to allow undefined to signal nothing.
const EMPTY: unique symbol = Symbol()
type Empty = typeof EMPTY
export class Either<Left, Right> {
private constructor(private data: [Left, Empty] | [Empty, Right]) {}
static left<Left = unknown, Right = unknown>(value: Left): Either<Left, Right> {
return new Either<Left, Right>([value, EMPTY]);
}
static right<Left = unknown, Right = unknown>(value: Right): Either<Left, Right> {
return new Either<Left, Right>([EMPTY, value]);
}
match<T>(left: (left: Left) => T, right: (right: Right) => T) {
return this.data[0] !== EMPTY ?
left(this.data[0]) :
right(this.data[1] as Right) // Unfortunately TS isn't smart enough to infer
}
}
Now to implement our Request
... it is surprisingly easy to model! This is an indication that the design is a good idea, since good design should make it easy to achieve our goals.
import { Either } from "./either";
export class Request<TData, TError = unknown> {
private constructor(private percent: number, private data: Either<TError, TData> | undefined) {}
static incomplete() {
return new Request(0, undefined);
}
match<T>({ loading, error, result }: {
loading: (percent: number) => T,
error: (error: TError) => T,
result: (result: TData) => T
}) {
if (this.data === undefined) {
return loading(this.percent);
}
return this.data.match(error, result);
}
setPercent(percent: number) {
this.percent = percent;
}
setResult(result: TData) {
this.data = Either.right(result);
}
setError(error: TError) {
this.data = Either.left(error);
}
}
With everything at once, here's our little demo app.
class Maybe<T> {
static nothing<T>() { return new Maybe<T>(undefined) }
static just<T>(value?: T) { return new Maybe(value) }
private constructor(private value?: T) {}
match<A, B>({ nothing, just }: {
nothing: () => A,
just: (value: T) => B
}): A | B {
return this.value == null ? nothing() : just(this.value)
}
}
const EMPTY: unique symbol = Symbol()
type Empty = typeof EMPTY
class Either<Left, Right> {
private constructor(private data: [Left, Empty] | [Empty, Right]) {}
static left<Left = unknown, Right = unknown>(value: Left): Either<Left, Right> {
return new Either<Left, Right>([value, EMPTY]);
}
static right<Left = unknown, Right = unknown>(value: Right): Either<Left, Right> {
return new Either<Left, Right>([EMPTY, value]);
}
match<T>(left: (left: Left) => T, right: (right: Right) => T) {
return this.data[0] !== EMPTY ?
left(this.data[0]) :
right(this.data[1] as Right); // Unfortunately TS isn't smart enough to infer
}
}
class Request<TData, TError = unknown> {
private constructor(private percent: number, private data: Either<TError, TData> | undefined) {}
static incomplete<TData, TError>() {
return new Request<TData, TError>(0, undefined);
}
match<T>({ loading, error, result }: {
loading: (percent: number) => T,
error: (error: TError) => T,
result: (result: TData) => T
}) {
if (this.data === undefined) {
return loading(this.percent);
}
return this.data.match(error, result);
}
setPercent(percent: number) {
this.percent = percent;
}
setResult(result: TData) {
this.data = Either.right(result);
}
setError(error: TError) {
this.data = Either.left(error);
}
}
// This would be nicer to do with a React component
let state: Maybe<Request<string, string>> = Maybe.nothing();
const button = document.querySelector('button')!;
const result = document.querySelector('#result') as HTMLDivElement;
function render() {
state.match({
nothing() {
button.hidden = false;
result.hidden = true;
},
just(request) {
button.hidden = true;
result.hidden = false;
request.match({
loading(percent) {
result.textContent = `Loading: ${percent * 100}%`;
},
error(error) {
result.textContent = `ERROR: ${error}`;
},
result(data) {
result.textContent = data;
}
});
}
});
}
function makeResponse(): Request<string, string> {
const response = Request.incomplete<string, string>();
setTimeout(function () {
response.setPercent(.99);
render();
}, 500)
setTimeout(function () {
if (Math.random() < 0.5) {
response.setResult('Got a result!')
} else {
response.setError('Got an error!')
}
render();
}, 2000)
return response;
}
button.addEventListener('click', () => {
state = Maybe.just(makeResponse());
render();
});
render();
And here's the app with the compiled source.
class Maybe {
constructor(value) {
this.value = value;
}
static nothing() { return new Maybe(undefined); }
static just(value) { return new Maybe(value); }
match({ nothing, just }) {
return this.value == null ? nothing() : just(this.value);
}
}
const EMPTY = Symbol();
class Either {
constructor(data) {
this.data = data;
}
static left(value) {
return new Either([value, EMPTY]);
}
static right(value) {
return new Either([EMPTY, value]);
}
match(left, right) {
return this.data[0] !== EMPTY ?
left(this.data[0]) :
right(this.data[1]);
}
}
class Request {
constructor(percent, data) {
this.percent = percent;
this.data = data;
}
static incomplete() {
return new Request(0, undefined);
}
match({ loading, error, result }) {
if (this.data === undefined) {
return loading(this.percent);
}
return this.data.match(error, result);
}
setPercent(percent) {
this.percent = percent;
}
setResult(result) {
this.data = Either.right(result);
}
setError(error) {
this.data = Either.left(error);
}
}
let state = Maybe.nothing();
const button = document.querySelector('button');
const result = document.querySelector('#result');
function render() {
state.match({
nothing() {
button.hidden = false;
result.hidden = true;
},
just(request) {
button.hidden = true;
result.hidden = false;
request.match({
loading(percent) {
result.textContent = `Loading: ${percent * 100}%`;
},
error(error) {
result.textContent = `ERROR: ${error}`;
},
result(data) {
result.textContent = data;
}
});
}
});
}
function makeResponse() {
const response = Request.incomplete();
setTimeout(function () {
response.setPercent(.99);
render();
}, 500);
setTimeout(function () {
if (Math.random() < 0.5) {
response.setResult('Got a result!');
}
else {
response.setError('Got an error!');
}
render();
}, 2000);
return response;
}
button.addEventListener('click', () => {
state = Maybe.just(makeResponse());
render();
});
render();
<button>Click me!</button>
<div id="result"></div>
initial
state? If this is wrapping backend data it seems like it should only ever be loading, loaded, or failed. What doesinitial
signify? \$\endgroup\$