5
\$\begingroup\$

I have been struggling to make typescript happy about the types. I am pulling data from our backend API and I wanted to give context to the data. Basically it is a monad with 4 shapes:

  • Initial (nothing)
  • Loading (maybe with percent?)
  • Failed (maybe with error?)
  • Loaded(with the actual value).

I want it to have the usual .of(), .map(), .chain() functions that you would expect in a monadic data structure.

After trying many different ways (with average success). I came up with this implementation:

type Kind = 'initial' | 'loading' | 'loaded' | 'failed'
type DefaultError = any
export class Data<T, E = DefaultError> {
  private constructor(
    public readonly kind: Kind,
    public readonly data: T | undefined,
    private readonly error: E | undefined,
    private readonly percent: number | undefined,
  ) {}

  static initial<T, E>() {
    return new Data<T, E>('initial', undefined, undefined, undefined)
  }

  static loading<T, E>(percent?: number) {
    return new Data<T, E>('loading', undefined, undefined, percent)
  }

  static failed<T, E>(error?: E) {
    return new Data<T, E>('failed', undefined, error, undefined)
  }

  static loaded<T, E>(value: T) {
    return new Data<T, E>('loaded', value, undefined, undefined)
  }

  static orInitial<T, E>(value: Data<T, E> | undefined) {
    if (value === undefined) {
      return Data.initial<T, E>()
    }
    return value
  }

  static getData<T, E>(wrapped: Data<T, E>) {
    return wrapped.data
  }

  static getDataOrElse<T, E>(defaultValue: T) {
    return function(wrapped: Data<T, E>) {
      return wrapped.kind === 'loaded' ? wrapped.data! : defaultValue
    }
  }

  join() {
    return this.data!
  }

  map<R>(f: (wrapped: T) => R): Data<R, E> {
    switch (this.kind) {
      case 'loaded':
        return Data.loaded(f(this.data!))
      case 'loading':
        return Data.loading()
      case 'failed':
        return Data.failed(this.error)
      case 'initial':
        return Data.initial()
    }
  }




  flatMap<R, E>(f: (wrapped: T) => Data<R, E>): Data<R, E> {
    switch (this.kind) {
      case 'loaded':
        return f(this.data!)
      case 'loading':
        return Data.loading<R,E>()
      case 'failed':
        return Data.failed<R,E>((this.error as unknown) as E)
      case 'initial':
        return Data.initial<R,E>()
    }
  }

  match<O1, O2, O3, O4>({
    initial,
    loaded,
    loading,
    failed,
  }: {
    initial: () => O1
    loaded: (value?: T) => O2
    loading: (percent?: number) => O3
    failed: (error?: E) => O4
  }) {
    switch (this.kind) {
      case 'loaded':
        return loaded(this.data!)
      case 'loading':
        return loading(this.percent)
      case 'failed':
        return failed(this.error)
      case 'initial':
        return initial()
    }
  }

  static isInitial<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'initial' } {
    return wrapped.kind === 'initial'
  }

  static isLoading<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'loading'; percent?: number } {
    return wrapped.kind === 'loading'
  }

  static isFailed<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'failed'; error?: E } {
    return wrapped.kind === 'failed'
  }

  static isLoaded<T, E>(wrapped: Data<T, E>): wrapped is Data<T, E> & { kind: 'loaded'; data: T } {
    return wrapped.kind === 'loaded'
  }

  isInitial(): this is { kind: 'initial' } {
    return this.kind === 'initial'
  }

  isLoading(): this is { kind: 'loading'; percent?: number } {
    return this.kind === 'loading'
  }

  isFailed(): this is { kind: 'failed'; error?: E } {
    return this.kind === 'failed'
  }

  isLoaded(): this is { kind: 'loaded'; data: T } {
    return this.kind === 'loaded'
  }

  getValueOrElse(defaultValue: T) {
    return this.kind === 'loaded' ? this.data : defaultValue
  }

  getPercentOrElse(defaultPercent: number) {
    return this.kind === 'loading' ? this.percent : defaultPercent
  }

  getErrorOrElse(defaultError: E) {
    return this.kind === 'failed' ? this.error : defaultError
  }
}

I am not too happy with it. Mainly because I have to do null checks on error, value, percent fields. I wanted to have some sort of a tagged union type but I couldn't make it work. Also the isLoaded() type guard doesn't seem to really work when doing something like this:

// 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>

data.data // <-- says number | undefined, but I would really prefer it not even allowing me to access the field

if(data.isLoaded()) {
  data.data // <-- number, so that is correct because it know the data is loaded
}

const dataArray: Data<number>[] = [Data.failed(), Data.loaded(2)]

dataArray.filter(Data.isLoaded)
.map(Data.getData)
.map(x => x) // <-- number or undefined, that shouldn't happen

dataArray.filter(Data.isLoaded)
.map(x => x.data)
.map(x => x) // <-- number but not undefined?

How can I best modify it and have strong typing with it?

This is a crucial part in our project because we use it everywhere.

\$\endgroup\$
  • \$\begingroup\$ Welcome to Code Review! IMHO your question title can and should be improved. Be sure to check How do I ask a good question? and especially Titling your question in the Help Center. \$\endgroup\$ – AlexV Jun 11 at 8:00
  • \$\begingroup\$ I'm having difficulty understanding, what is the point of the initial state? If this is wrapping backend data it seems like it should only ever be loading, loaded, or failed. What does initial signify? \$\endgroup\$ – Gerrit0 Jun 20 at 2:51
1
\$\begingroup\$

Since this is Code Review, and not Stack Overflow where you'd just want an answer... first a couple comments on your code:

  1. 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.

  2. 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>
    
  3. 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.

  4. 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.

  5. 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;
    }
    
  6. 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.

  7. It might be worth making a const enum DataState { initial, loading, loaded, failed } to avoid typing strings everywhere. As const enums 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>

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.