@tiddo/async-value
v1.0.3
Published
A structured and (type)safe representation for asynchronous values.
Downloads
3
Readme
Consistent, (type)safe representation of an asynchronous value.
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 AsyncValue
s 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 unknown
1.
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:
- 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 theAsyncValue
would necessarily have to reduce the error type tounknown