From f936cabb9af6aac9e61d4e48d03f46da531c78c1 Mon Sep 17 00:00:00 2001 From: Jakub Stasiak Date: Mon, 4 Mar 2024 14:29:16 +0100 Subject: [PATCH] Add AsyncOption (#114) Something that comes up every now and then and I've been missing it from the ts-results-es API: an asynchronous counterpart to Option. Also a counterpart to AsyncResult added in [1]. This addition completes the sync/async, Option/Result matrix. [1] 3f55d157c925 ("Take a first stab at async results (#87)") --- docs/reference/api/asyncoption.rst | 150 +++++++++++++++++++++++++++++ docs/reference/api/asyncresult.rst | 11 +++ docs/reference/api/index.rst | 1 + docs/reference/api/option.rst | 14 +++ src/asyncoption.ts | 133 +++++++++++++++++++++++++ src/asyncresult.ts | 9 ++ src/index.ts | 1 + src/option.ts | 16 +++ test/asyncoption.test.ts | 63 ++++++++++++ test/asyncresult.test.ts | 6 ++ test/option.test.ts | 5 + 11 files changed, 409 insertions(+) create mode 100644 docs/reference/api/asyncoption.rst create mode 100644 src/asyncoption.ts create mode 100644 test/asyncoption.test.ts diff --git a/docs/reference/api/asyncoption.rst b/docs/reference/api/asyncoption.rst new file mode 100644 index 0000000..26ba100 --- /dev/null +++ b/docs/reference/api/asyncoption.rst @@ -0,0 +1,150 @@ +AsyncOption +=========== + +An async-aware :doc:`option` counterpart. + +Can be combined with asynchronous code without having to ``await`` anything right until +the moment when you're ready to extract the final ``Option`` out of it. + +Can also be combined with synchronous code for convenience. + +.. code-block:: typescript + + // T is the value type + AsyncOption + +Imports: + +.. code-block:: typescript + + import { AsyncOption } from 'ts-results-es' + +Construction: + +You can construct it directly from ``Option`` or ``Promise>``: + +.. code-block:: typescript + + const option1 = new AsyncOption(Some(1)) + const option2 = new AsyncOption((async () => None)()) + +Or you can use the :ref:`Option.toAsyncOption() ` method: + +.. code-block:: typescript + + const option3 = Some(1).toAsyncOption() + +``andThen()`` +------------- + +.. code-block:: typescript + + andThen( + mapper: (val: T) => Option | Promise> | AsyncOption, + ): AsyncOption + + +Calls ``mapper`` if the option is ``Some``, otherwise keeps the ``None`` value intact. +This function can be used for control flow based on ``Option`` values. + +Example: + +.. code-block:: typescript + + let hasValue = Some(1).toAsyncOption() + let noValue = None.toAsyncOption() + + await hasValue.andThen(async (value) => Some(value * 2)).promise // Some(2) + await hasValue.andThen(async (value) => None).promise // None + await noValue.andThen(async (value) => Ok(value * 2)).promise // None + +``map()`` +--------- + +.. code-block:: typescript + + map(mapper: (val: T) => U | Promise): AsyncOption + +Maps an ``AsyncOption`` to ``AsyncOption`` by applying a function to a contained +``Some`` value, leaving a ``None`` value untouched. + +This function can be used to compose the results of two functions. + +Example: + +.. code-block:: typescript + + let hasValue = Ok(1).toAsyncOption() + let noValue = None.toAsyncOption() + + await hasValue.map(async (value) => value * 2).promise // Some(2) + await noValue.map(async (value) => value * 2).promise // None + + +``or()`` +-------- + +.. code-block:: typescript + + or(other: Option | AsyncOption | Promise>): AsyncOption + +Returns the value from ``other`` if this ``AsyncOption`` contains ``None``, otherwise returns self. + +If ``other`` is a result of a function call consider using :ref:`AsyncOption.orElse` instead, it will +only evaluate the function when needed. + +Example: + +.. code-block:: typescript + + const noValue = new AsyncOption(None) + const hasValue = new AsyncOption(Some(1)) + + await noValue.or(Some(123)).promise // Some(123) + await hasValue.or(Some(123)).promise // Some(1) + +.. _AsyncOption.orElse: + +``orElse()`` +------------ + +.. code-block:: typescript + + orElse(other: () => Option | AsyncOption | Promise>): AsyncOption + +Returns the value obtained by calling ``other`` if this ``AsyncOption`` contains ``None``, otherwise +returns self. + +Example: + +.. code-block:: typescript + + const noValue = new AsyncOption(None) + const hasValue = new AsyncOption(Some(1)) + + await noValue.orElse(() => Some(123)).promise // Some(123) + await hasValue.orElse(() => Some(123)).promise // Some(1) + + + +``promise`` +----------- + +.. code-block:: typescript + + promise: Promise> + +A promise that resolves to a synchronous result. + +Await it to convert ``AsyncResult`` to ``Result``. + + +``toResult()`` +-------------- + +.. code-block:: typescript + + toResult(error: E): AsyncResult + +Converts an ``AsyncOption`` to an ``AsyncResult`` so that ``None`` is converted to +``Err(error)`` and ``Some`` is converted to ``Ok``. diff --git a/docs/reference/api/asyncresult.rst b/docs/reference/api/asyncresult.rst index 50a77e6..1327788 100644 --- a/docs/reference/api/asyncresult.rst +++ b/docs/reference/api/asyncresult.rst @@ -161,3 +161,14 @@ Example: A promise that resolves to a synchronous result. Await it to convert ``AsyncResult`` to ``Result``. + + +``toOption()`` +-------------- + +.. code-block:: typescript + + toOption(): AsyncOption + +Converts from ``AsyncResult`` to ``AsyncOption`` so that ``Err`` is converted to ``None`` +and ``Ok`` is converted to ``Some``. diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index e85f39a..2400c83 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -12,6 +12,7 @@ API reference option result + asyncoption asyncresult rxjs diff --git a/docs/reference/api/option.rst b/docs/reference/api/option.rst index 46fffc3..a9150f2 100644 --- a/docs/reference/api/option.rst +++ b/docs/reference/api/option.rst @@ -142,6 +142,20 @@ Example: Some(1).orElse(() => Some(2)) // => Some(1) None.orElse(() => Some(2)) // => Some(2) +.. _toAsyncOption: + +``toAsyncOption()`` +------------------- + +.. code-block:: typescript + + toAsyncOption(): AsyncOption + +Creates an `AsyncOption` based on this `Option`. + +Useful when you need to compose results with asynchronous code. + + ``toResult()`` -------------- diff --git a/src/asyncoption.ts b/src/asyncoption.ts new file mode 100644 index 0000000..776771f --- /dev/null +++ b/src/asyncoption.ts @@ -0,0 +1,133 @@ +import { AsyncResult } from './asyncresult.js' +import { Option, Some } from './option.js' + +/** + * An async-aware `Option` counterpart. + * + * Can be combined with asynchronous code without having to ``await`` anything right until + * the moment when you're ready to extract the final ``Option`` out of it. + * + * Can also be combined with synchronous code for convenience. + */ +export class AsyncOption { + /** + * A promise that resolves to a synchronous ``Option``. + * + * Await it to convert `AsyncOption` to `Option`. + */ + promise: Promise> + + /** + * Constructs an `AsyncOption` from an `Option` or a `Promise` of an `Option`. + * + * @example + * ```typescript + * const option = new AsyncOption(Promise.resolve(Some('username'))) + * ``` + */ + constructor(start: Option | Promise>) { + this.promise = Promise.resolve(start) + } + + /** + * Calls `mapper` if the option is `Some`, otherwise keeps the `None` value intact. + * This function can be used for control flow based on `Option` values. + * + * @example + * ```typescript + * let hasValue = Some(1).toAsyncOption() + * let noValue = None.toAsyncOption() + * + * await hasValue.andThen(async (value) => Some(value * 2)).promise // Some(2) + * await hasValue.andThen(async (value) => None).promise // None + * await noValue.andThen(async (value) => Ok(value * 2)).promise // None + * ``` + */ + andThen(mapper: (val: T) => Option | Promise> | AsyncOption): AsyncOption { + return this.thenInternal(async (option) => { + if (option.isNone()) { + return option + } + const mapped = mapper(option.value) + return mapped instanceof AsyncOption ? mapped.promise : mapped + }) + } + + /** + * Maps an `AsyncOption` to `AsyncOption` by applying a function to a contained + * `Some` value, leaving a `None` value untouched. + * + * This function can be used to compose the results of two functions. + * + * @example + * ```typescript + * let hasValue = Ok(1).toAsyncOption() + * let noValue = None.toAsyncOption() + * + * await hasValue.map(async (value) => value * 2).promise // Some(2) + * await noValue.map(async (value) => value * 2).promise // None + * ``` + */ + map(mapper: (val: T) => U | Promise): AsyncOption { + return this.thenInternal(async (option) => { + if (option.isNone()) { + return option + } + return Some(await mapper(option.value)) + }) + } + + /** + * Returns the value from `other` if this `AsyncOption` contains `None`, otherwise returns self. + * + * If `other` is a result of a function call consider using `orElse` instead, it will + * only evaluate the function when needed. + * + * @example + * ``` + * const noValue = new AsyncOption(None) + * const hasValue = new AsyncOption(Some(1)) + * + * await noValue.or(Some(123)).promise // Some(123) + * await hasValue.or(Some(123)).promise // Some(1) + * ``` + */ + or(other: Option | AsyncOption | Promise>): AsyncOption { + return this.orElse(() => other) + } + + /** + * Returns the value obtained by calling `other` if this `AsyncOption` contains `None`, otherwise + * returns self. + * + * @example + * ``` + * const noValue = new AsyncOption(None) + * const hasValue = new AsyncOption(Some(1)) + * + * await noValue.orElse(() => Some(123)).promise // Some(123) + * await hasValue.orElse(() => Some(123)).promise // Some(1) + * ``` + */ + orElse(other: () => Option | AsyncOption | Promise>): AsyncOption { + return this.thenInternal(async (option): Promise> => { + if (option.isSome()) { + return option + } + const otherValue = other() + return otherValue instanceof AsyncOption ? otherValue.promise : otherValue + }) + } + + /** + * Converts an `AsyncOption` to an `AsyncResult` so that `None` is converted to + * `Err(error)` and `Some` is converted to `Ok`. + */ + toResult(error: E): AsyncResult { + return new AsyncResult(this.promise.then(option => option.toResult(error))) + } + + private thenInternal(mapper: (option: Option) => Promise>): AsyncOption { + return new AsyncOption(this.promise.then(mapper)) + } +} diff --git a/src/asyncresult.ts b/src/asyncresult.ts index d8f9122..579ebc5 100644 --- a/src/asyncresult.ts +++ b/src/asyncresult.ts @@ -1,3 +1,4 @@ +import { AsyncOption } from './asyncoption.js' import { Err, Result, Ok } from './result.js' /** @@ -144,6 +145,14 @@ export class AsyncResult { }) } + /** + * Converts from `AsyncResult` to `AsyncOption` so that `Err` is converted to `None` + * and `Ok` is converted to `Some`. + */ + toOption(): AsyncOption { + return new AsyncOption(this.promise.then(result => result.toOption())) + } + private thenInternal(mapper: (result: Result) => Promise>): AsyncResult { return new AsyncResult(this.promise.then(mapper)) } diff --git a/src/index.ts b/src/index.ts index 484875d..5b20d99 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './asyncoption.js'; export * from './asyncresult.js'; export * from './result.js'; export * from './option.js'; diff --git a/src/option.ts b/src/option.ts index 95046b4..4547671 100644 --- a/src/option.ts +++ b/src/option.ts @@ -1,3 +1,4 @@ +import { AsyncOption } from './asyncoption.js' import { toString } from './utils.js'; import { Result, Ok, Err } from './result.js'; @@ -100,6 +101,13 @@ interface BaseOption extends Iterable ? U : never * Maps an `Option` to a `Result`. */ toResult(error: E): Result; + + /** + * Creates an `AsyncOption` based on this `Option`. + * + * Useful when you need to compose results with asynchronous code. + */ + toAsyncOption(): AsyncOption } /** @@ -165,6 +173,10 @@ class NoneImpl implements BaseOption { toString(): string { return 'None'; } + + toAsyncOption(): AsyncOption { + return new AsyncOption(None) + } } // Export None as a singleton, then freeze it so it can't be modified @@ -251,6 +263,10 @@ class SomeImpl implements BaseOption { return Ok(this.value); } + toAsyncOption(): AsyncOption { + return new AsyncOption(this) + } + /** * Returns the contained `Some` value, but never throws. * Unlike `unwrap()`, this method doesn't throw and is only callable on an Some diff --git a/test/asyncoption.test.ts b/test/asyncoption.test.ts new file mode 100644 index 0000000..0a32d86 --- /dev/null +++ b/test/asyncoption.test.ts @@ -0,0 +1,63 @@ +import { + AsyncOption, + None, + Some, +} from '../src/index.js'; + +test('the constructor should work', async () => { + const option = new AsyncOption(Some(1)) + expect(await option.promise).toEqual(Some(1)) +}) + +test('andThen() should work', async () => { + const noValue = new AsyncOption(None) + const hasValue = new AsyncOption(Some(1)) + + expect(await noValue.andThen(() => {throw new Error('Should not be called')}).promise).toEqual(None) + + expect(await hasValue.andThen((value) => Some(value * 3)).promise).toEqual(Some(3)) + expect(await hasValue.andThen(async (value) => Some(value * 3)).promise).toEqual(Some(3)) + expect(await hasValue.andThen((value) => new AsyncOption(Some(value * 3))).promise).toEqual(Some(3)) +}) + +test('map() should work', async () => { + const noValue = new AsyncOption(None) + const hasValue = new AsyncOption(Some(1)) + + expect(await noValue.map(() => {throw new Error('Should not be called')}).promise).toEqual(None) + expect(await hasValue.map((value) => value * 2).promise).toEqual(Some(2)) + expect(await hasValue.map(async (value) => value * 2).promise).toEqual(Some(2)) +}) + +test('or() should work', async () => { + const noValue = new AsyncOption(None) + const hasValue = new AsyncOption(Some(1)) + + expect(await noValue.or(Some(200)).promise).toEqual(Some(200)) + expect(await hasValue.or(Some(200)).promise).toEqual(Some(1)) + + expect(await noValue.or(new AsyncOption(Some(200))).promise).toEqual(Some(200)) + expect(await hasValue.or(new AsyncOption(Some(200))).promise).toEqual(Some(1)) + + expect(await noValue.or(Promise.resolve(Some(200))).promise).toEqual(Some(200)) + expect(await hasValue.or(Promise.resolve(Some(200))).promise).toEqual(Some(1)) +}) + +test('orElse() should work', async () => { + const noValue = new AsyncOption(None) + const hasValue = new AsyncOption(Some(1)) + + function notExpectedToBeCalled(): never { + throw new Error('Not expected to be called') + } + + expect(await hasValue.orElse(notExpectedToBeCalled).promise).toEqual(Some(1)) + expect(await noValue.orElse(() => Some(200)).promise).toEqual(Some(200)) + expect(await noValue.orElse(() => new AsyncOption(Some(200))).promise).toEqual(Some(200)) + expect(await noValue.orElse(() => Promise.resolve(Some(200))).promise).toEqual(Some(200)) +}) + +test('toResult() should work', async () => { + const result = new AsyncOption(None) + expect((await result.toResult('Blah').promise).unwrapErr()).toEqual('Blah') +}) diff --git a/test/asyncresult.test.ts b/test/asyncresult.test.ts index 420f04a..7838efd 100644 --- a/test/asyncresult.test.ts +++ b/test/asyncresult.test.ts @@ -2,6 +2,7 @@ import { AsyncResult, Err, Ok, + Some, } from '../src/index.js'; test('andThen() should work', async () => { @@ -62,3 +63,8 @@ test('orElse() should work', async () => { expect(await badResult.orElse(() => new AsyncResult(Ok(200))).promise).toEqual(Ok(200)) expect(await badResult.orElse(() => Promise.resolve(Ok(200))).promise).toEqual(Ok(200)) }) + +test('toOption() should work', async () => { + const result = new AsyncResult(Ok(1)) + expect(await result.toOption().promise).toEqual(Some(1)) +}) diff --git a/test/option.test.ts b/test/option.test.ts index d41a5ba..7713dbc 100644 --- a/test/option.test.ts +++ b/test/option.test.ts @@ -141,3 +141,8 @@ test('or / orElse', () => { expect(Some(1).or(Some(2))).toEqual(Some(1)) expect(Some(1).orElse(() => {throw new Error('Call unexpected')})).toEqual(Some(1)) }) + +test('toAsyncOption()', async () => { + expect(await Some(1).toAsyncOption().promise).toEqual(Some(1)) + expect(await None.toAsyncOption().promise).toEqual(None) +})