1.0.3 • Published 5 years ago

@tiddo/async-value v1.0.3

Weekly downloads
-
License
MIT
Repository
-
Last release
5 years ago

Consistent, (type)safe representation of an asynchronous value.

Skip to documentation

Simple example

import { AsyncValue, pending, resolved, error } from '@tiddo/async-value';

interface UserSummaryState {
  user: AsyncValue<User>
}

class UserSummary extends React.Component<Props, UserSummaryState> {
  state: UserSummaryState = { user: pending() }

  componentDidMount() {
    fetchUser()
      .then(user => this.setState({ user : resolved(user) })
      .catch(e => this.setState({ user : error(e) })
  }

  render() {
    return this.state.user.resolve({
      pending: () => <Loader />,
      success: user => <p>Welcome {user.name}!</p>,
      error: e => <p className="error">Oops, something went wrong: {e.toString()}</p>
    });
  }
}

What problem does this solve?

async-value provides a structured approach to compose and deal with asynchronous values. Moreover, this approach is fully type-safe without needing to rely on any advanced typing features.

Motivation

In React, we often need to deal with rendering asynchronous values. However, in the rendering pipeline we cannot deal with promises. A common approach is to "flatten" a promise into an object, but this is usually done in a way that is not typesafe and which composes poorly with other asynchronous values. For example, a common approach looks something like this:

interface AsyncUser {
  isLoading: boolean,
  error?: any,
  value?: User
}

interface UserSummaryState {
  user: AsyncUser
}

/* .. */

class UserSummary extends React.Component<Props, UserSummaryState> {

  state: UserSummaryState = { user : { isLoading: true } };

  componentDidMount() {
    fetchUser()
      .then(user => this.setState({ user : { isLoading: false, value: user } }))
      .catch(e => this.setState({ user: { isLoading: false, error: e } } ));
  }

  render() {
    const { user } = this.state;

    if (user.isLoading) {
      return <Loader />
    } else if (user.value) {
      return <p>Hello {user.value.name}!</p>
    } else {
      return <p className="error">Oops, something went wrong: {user.error.toString()}</p>
    }
  }
}

For typescript users, we already run into our first problem here: this isn't type safe. Most importantly, it doesn't check the relation between the isLoading, error and value properties. E.g. we can have both an error and a value at the same time, or have isLoading=false without any value or error set. These are errors, but won't be caught by the typesystem.

Another problem is that we need to add unnecessary type hints. For example, if we try to compile the above code, TypeScript will complain that user.error is possibly undefined. To solve this we need to add another assertion that user.error is set.

But perhaps even more problematic, this approach makes it difficult to deal with multiple asynchronous values simultaneously. E.g. consider that we want to add a message count and a notification count:

const { user, messages, notifications } = this.state; //1

if (user.isLoading || messages.isLoading || notifications.isLoading) { //2
  return <Loader />;
} else if (user.value && messages.value && notifications.value) { //4
  return <p>Hello {user.value.name}, you've got {messages.value.length} new messages and {notifications.value.length} new notifications.</p>; //5
} else {
  const error = user.error || messages.error || notifications.error; //6
  return <p className="error">Oops, something went wrong: {error.toString()}</p>
}

For each new value, we had make a change in 5(!) places, which gives us 5 opportunities to introduce mistakes.

Also, at this point we might want to extract some parts to make it more readable:

if (this.isLoading()) {
  return <Loader />;
} else if (this.isFullyLoaded()) {
  return <Greeting
    name={user.value.name}
    messages={message.value.length}
    notifications={notifications.value.length } />
} else {
  const error = user.error || messages.error || notifications.error;
  return <p className="error">Oops, something went wrong: {error.toString()}</p>
}

If you're using TypeScript, you'll now run into yet another problem: TypeScript can't infer anymore that in the isFullyLoaded() branch the values are all set, and will complain about this. We either need to introduce unsafe casts, custom type guards, or revert our refactoring, none of which is a particular elegant solution.

With async-value, all the problems above will be solved: composing becomes trivial, and our entire system becomes type safe:

import { all } from '@tiddo/async-value';

interface State {
  user: AsyncValue<User>,
  messages: AsyncValue<Message[]>,
  notifications: AsyncValue<Notification[]>
}

render() {
  return all(this.state)
    .resolve({
      loading: () => <Loader />,
      success: ({ user, messages, notifications }) =>
        <Greeting
          name={user.name}
          messages={messages.length}
          notifications={notifications.length } />
      error: e => <p className="error">Oops, something went wrong: {error.toString()}</p>
    });
}

Documentation

Core concepts

An AsyncValue is a value that's in one of 3 states:

  • pending
  • error
  • success

In pending state it won't have a payload, but in the error & success states it will have an error and value respectively.

An AsyncValue does not give direct access to these underlying values, since that would make it easy to misuse. Instead, it gives utilities to compose AsyncValues without having to unpack them first. This will automatically handle composing errors/loading states for you. On top of that it exposes the resolve method and the get*() methods to safely unpack any async value.

For typescript users, the AsyncValue construct is typesafe. Given an AsyncValue<T>, the value will be of type T and the error of type unknown1.


pending()/success(value)/error(err)

pending(): AsyncValue<never>
success<T>(value: T): AsyncValue<T>
error(err: unknown): AsyncValue<never>

Create an async value.

Examples
import { pending, error, success } from '@tiddo/async-value';

const pendingValue = pending();
const errorValue = error(new Error());
const successValue = success(123);

value.resolve()

AsyncValue<T>.resolve<R>({
  pending(): R,
  success(value: T): R,
  error(err: unknown): R
}) : R

Resolve unpacks an async-value into a concrete value. The function takes an object as argument with 3 functions, pending/error/success, each responsible for unpacking one of the states. All 3 functions are required.

Examples
function AsyncHelloWorld({ asyncName }) {
  return asyncName.resolve({
    pending: () => <h1>loading...</h1>,
    error: (err) => <h1>Oops, something went wrong: { err }</h1>,
    success: (name) => <h1>Hello, { name }!</h1>
  });
}

value.getOrDefault(default)

AsyncValue<T>.getOrDefault<D>(default: D): T | D

Given a success value, it returns the contained value. Otherwise, it returns the default.

Examples
pending().getOrDefault(3) === 3
success(1).getOrDefault(3) === 1
error(err).getOrDefault(3) === 3

value.getOrGetDefault(getDefault)

AsyncValue<T>.getOrGetDefault<D>(getDefault: () => D): T | D

Similar to getOrDefault, but uses a factory function to create the default value. The factory function will only be called when necessary.

Examples
pending().getOrGetDefault(() => 3) === 3
success(1).getOrGetDefault(() => 3) === 1
error(err).getOrGetDefault(() => 3) === 3

value.getErrorOrDefault(default)

AsyncValue<T>.getErrorOrDefault(default: unknown): unknown

Given an error value, it returns the contained error. Otherwise it returns the default.

Examples
pending().getErrorOrDefault("No error") === "No error"
success(1).getErrorOrDefault("No error") === "No error"
error("Error").getErrorOrDefault("No error") === "Error"

value.getErrorOrGetDefault(getDefault)

AsyncValue<T>.getErrorOrGetDefault(getDefault: () => unknown): unknown

Similar to getErrorOrGetDefault, but uses a factory function to create the default value. The factory function will only be called when necessary.

Examples
pending().getErrorOrGetDefault(() => "No error") === "No error"
success(1).getErrorOrGetDefault(() => "No error") === "No error"
error("Error").getErrorOrGetDefault(() => "No error") === "Error"

all([values])/all({ values })

all([AsyncValue<T1>, AsyncValue<T2>, ...]): AsyncValue<[T1, T2, ...]>
all({ a: AsyncValue<T1>, b: AsyncValue<T2>, ... }) : AsyncValue<{ a: T1, b: T2, ... }>

Returns a single AsyncValue that is success when all inputs are success. It works on both lists and objects.

The state of the resulting value is determined as followed:

  • If all inputs are in success state, then the result is in success state. This include empty arrays/objects.
  • If any input is in error state, then the result is in error state.
  • Otherwise, the result is in pending state.

This function combines errors in a similar way as values. E.g. all([error("x"), pending()]) -> error(["x", null])

Examples
all([]) === success([])
all([success(1), success(2)]) === success([1,2])
all([success(1), pending()]) === pending()
all([success(1), error('x')]) === error([null, 'x'])
all([pending(), error('x')]) === error([null, 'x'])
all([error('x'), error('y')]) === error(['x', 'y'])

all({}) === success({})
all({ a: success(1), b: success(2)}) === success({a: 1, b: 2})
all({ a: success(1), b: pending()}) === pending()
all({ a: success(1), b: error('x')}) === error({a: null, b: 'x'})
all({ a: pending(), b: error('x')}) === error({a: null, b: 'x'})
all({ a: error('x'), b: error('y')}) === error({a: 'x', b: 'y'})

some([values])/some({ values })

some([AsyncValue<T1>, AsyncValue<T2>, ...]): AsyncValue<[T1?, T2?, ...]>
some({ a: AsyncValue<T1>, b: AsyncValue<T2>, ... }) : AsyncValue<{ a: T1?, b: T2?, ... }>

Returns a single AsyncValue that is success when any of the inputs is success. It works on both lists and objects.

The state of the resulting value is determined as followed:

  • If any inputs is in success state, then the result is in success state.
  • If all inputs are in error state, then the result is in error state. This includes empty arrays/objects.
  • Otherwise, the result is in pending state.
Examples
some([]) === pending()
some([success(1), success(2)]) === success([1,2])
some([success(1), pending()]) === success([1, null])
some([success(1), error('x')]) === success([1, null])
some([pending(), error('x')]) === pending()
some([error('x'), error('y')]) === error('x', 'y')

some({}) === pending()
some({ a: success(1), b: success(2)}) === success({a: 1, b: 2})
some({ a: success(1), b: pending()}) === success({a: 1, b: null})
some({ a: success(1), b: error('x')}) === success({a: 1, b: null})
some({ a: pending(), b: error('x')}) === pending()
some({ a: error('x'), b: error('y')}) === error({a: 'x', b: 'y'})

value.flatMap()

AsyncValue<T>.flatmap<R>(mapper: (value: T) => AsyncValue<R>): AsyncValue<R>

value.map flatmaps a success value using the given mapping function. Pending and error values are returned unchanged. This is useful to combine multiple async values, or to chain them.

Examples
const mapToPending = val => pending();

success("hello world").map(mapToPending) === pending()
pending().map(mapToPending) === pending()
error(err).map(mapToPending) === error(err)

const fail = val => { throw new Error("oops"); }

success("hello world").map(fail) === error(new Error("oops"))
pending().map(stringlength) === pending()
error(err).map(stringlength) === error(err)

Chaining:

function getUser() : AsyncValue<User> { /* ... */ }
function getMessages(userId: number) : AsyncValue<Messages> { /* ... */ }

const asyncMessages = getUser()
  .flatMap(user => getMessages(user.id));

asyncMessages.resolve({
  loading: () => <h1>loading messages...</h1>,
  success: (messages) => <h1>You got { messages.length } new messages</h1>,
  error: () => <h1>Could not get your messages</h1>
});

value.map()

AsyncValue<T>.map<R>(mapper: (value: T) => R): R

value.map maps a success value using the given mapping function. Pending and error values are returned unchanged.

Examples
const stringlength = val => val.length;

success("hello world").map(stringlength) === success(11)
pending().map(stringlength) === pending()
error(err).map(stringlength) === error(err)

const fail = val => { throw new Error("oops"); }

success("hello world").map(fail) === error(new Error("oops"))
pending().map(stringlength) === pending()
error(err).map(stringlength) === error(err)

value.flatMapError()

AsyncValue<T>.flatMapError<R>(mapper: (error: unknown) => AsyncValue<R>): AsyncValue<R>

Same as flatMap, but instead maps when the AsyncValue is in error state. An example use case is to provide a fallback for a failed operation.

Examples
const mapToPending = val => pending();

success("hello world").flatMapError(mapToPending) === success("hello world")
pending().flatMapError(mapToPending) === pending()
error(err).flatMapError(mapToPending) === pending()

As fallback:

const user = loadUserFromCache()
  .flatMapError(err => {
    console.log("Could not load the user from cache, loading from API instead");
    return loadUserFromApi();
  });

value.mapError()

AsyncValue<T>.mapError(mapper: (error: unknown) => unknown): AsyncValue<T>

Same as map, but instead maps the error value. This can be useful to map error codes to human readable messages.

Examples
const codeToMessage = {
  404: "The requested resource does not exist",
  500: "Oops, something went wrong"
}

const errorToMessage = (err: unknown) => return codeToMessage[err.statusCode || 500);

success("hello world").mapError(errorToMessage) === success(11)
pending().map(errorToMessage) === pending()
error({ statusCode: 404 }).map(errorToMessage) === error("The requested resource does not exist")

It can also be useful to combine multiple error messages. E.g.:

all([ user, messages ])
  .mapError((errors) => {
    const errorMessage = errors.filter(error => error !== null).join('\n');
    return `Something went wrong: \n${errorMessage}`;
  });

value.isPending()/value.isSuccess()/value.isError()

AsyncValue<T>.isPending(): boolean
AsyncValue<T>.isSuccess(): boolean
AsyncValue<T>.isError(): boolean

Used to check a state of an async value. You typically don't need this.

As a general rule of thumb, you should only use these checks when you're interested in the state only, i.e. not in the contained value.

Below are some anti-examples to show how NOT to use this, while providing alternative solutions.

ANTI-EXAMPLES

DON'T DO THIS

// BAD. Don't do this.
if (value.isError()) {
  return "An error occurred";
} else {
  return value.getOrDefault("Loading...");
}

// WORSE. Definitely don't do this.
if (value.isError()) {
  return "An error occurred";
} else if (value.isLoading()) {
  return "Loading...";
} else {
  return value.orGetDefault(() => throw new Error("No value set"));
}

// GOOD. Do this instead.
return value.resolve({
  success: value => value,
  error: err => "An error occurred",
  pending: () => "Loading..."
})
// BAD. Don't do this
if (value.isSuccess()) {
  return value.getOrGetDefault(() => throw new Error("No value set"));
} else {
  return "Default value";
}

// GOOD. Do this instead
return value.getOrDefault("Default value");
// BAD. Think of the children.
if (value.isPending()) {
  return "Loading...";
} else if (value.isSuccess()) {
  return value.getOrGetDefault(() => throw new Error("No value set"));
} else {
  return value.getErrorOrDefault("Unknown error");
}

// GOOD. Do this instead
return value.resolve({
  pending:() => "Loading...",
  success: val => val,
  error: err => err.toString()
});
Good examples

There are use cases where these methods are useful, such as in unit tests. E.g.:

describe("getUser()", () => {
  it("should return an error async value if no user id is given", () => {
    const result = getUser();
    expect(result.isError()).toBe(true);
  });
});

See also the notes on testing below.

Another example would be a progress bar, where we're only interested in the number of pending values:

function getProgress(values: AsyncValue<unknown>[]) {
  const totalValues = values.length;
  const pendingValues = values.filter(value => value.isPending()).length;
  return 1 - (pendingValues / totalValues);
}


Testing

In tests, it's likely that you want to compare an AsyncValue with an expected result. You'll however soon find out that

success(1) !== success(1)

As of now, we have limited utilities to help with this:

import { UNSAFE_getError, UNSAFE_getValue } from '@tiddo/async-value/test-utils'

These 2 functions extract the value/error from an AsyncValue and throw when the expected value isn't found. These are very useful for tests, and are in fact used throughout the tests in this repository as well. Since these functions can throw, they aren't suitable for user-facing code (use resolve/get* for those).

In the future, we plan to ship more tailor made functions to make unit testing better.


Footnotes:

  1. We chose unknown because concrete types for errors are largely impossible. TypeScript doesn't type exceptions, and literally any value can be thrown. Even if we would use concrete types for errors, any transformation over the AsyncValue would necessarily have to reduce the error type to unknown
1.0.3

5 years ago

1.0.1

5 years ago

1.0.0

5 years ago