diff --git a/src/core/async/modules/base/index.ts b/src/core/async/modules/base/index.ts index 62622f2ef..b8aac7887 100644 --- a/src/core/async/modules/base/index.ts +++ b/src/core/async/modules/base/index.ts @@ -31,10 +31,13 @@ import type { } from 'core/async/interface'; +import flatAsync from 'core/async/modules/flat'; + export * from 'core/async/modules/base/const'; export * from 'core/async/modules/base/helpers'; export * from 'core/async/interface'; +export * from 'core/async/modules/flat/interface'; export default class Async> { /** @@ -48,6 +51,16 @@ export default class Async> { */ static linkNames: NamespacesDictionary = namespaces; + /** + * {@link flatAsync} + */ + static flat: typeof flatAsync = flatAsync; + + /** + * {@link flatAsync} + */ + flat: typeof flatAsync = flatAsync; + /** * The lock status. * If true, then all new tasks won't be registered. diff --git a/src/core/async/modules/flat/CHANGELOG.md b/src/core/async/modules/flat/CHANGELOG.md new file mode 100644 index 000000000..7b7e5662f --- /dev/null +++ b/src/core/async/modules/flat/CHANGELOG.md @@ -0,0 +1,16 @@ +Changelog +========= + +> **Tags:** +> - :boom: [Breaking Change] +> - :rocket: [New Feature] +> - :bug: [Bug Fix] +> - :memo: [Documentation] +> - :house: [Internal] +> - :nail_care: [Polish] + +## v3.??.?? (2023-??-??) + +#### :rocket: New Feature + +* Creating the module diff --git a/src/core/async/modules/flat/README.md b/src/core/async/modules/flat/README.md new file mode 100644 index 000000000..824239c41 --- /dev/null +++ b/src/core/async/modules/flat/README.md @@ -0,0 +1,45 @@ +# core/async/modules/flat + +This module provides a function for flatly working with a sequence of promises. +The function uses the `Proxy` object for creating a chain of promises where each next promise gets a value from the previous one. + +You can either specify a value that will be then wrapped in a `Promise` or a function which return value also will be wrapped in a `Promise`: + +```typescript +import flatAsync from 'core/async/modules/flat'; + +function getData(): Promise>> { + return Promise.resolve([Promise.resolve(21)]); +} + +// "21" +const str1 = await flatAsync(getData)().at(0)?.toFixed(); + +// "21" +const str2 = await flatAsync(getData())[0].toFixed(); +``` + +The function is also available as a static method of the `Async` class or as a method of its instance: + +```typescript +import Async from 'core/async'; + +Async.flat(promise); + +new Async().flat(promise); +``` + +The module also provides the `Promisify` type for patching your value: + +```typescript +import type { Promisify } from 'core/async/modules/flat'; + +const val: Promisify = Promise.resolve(21); + +val + .toString() + .split('') + .then() + .catch() + .finally(); +``` diff --git a/src/core/async/modules/flat/helpers.ts b/src/core/async/modules/flat/helpers.ts new file mode 100644 index 000000000..e7722d8cd --- /dev/null +++ b/src/core/async/modules/flat/helpers.ts @@ -0,0 +1,69 @@ +/*! + * V4Fire Core + * https://github.com/V4Fire/Core + * + * Released under the MIT license + * https://github.com/V4Fire/Core/blob/master/LICENSE + */ + +/** + * The function implements the logic of chaining promises flatly using the `Proxy` object. + * It creates a chain of promises where each next promise takes a value from the previous one. + * + * @param getPrevPromiseLike - the function that returns the previous `PromiseLike` in chain + */ +export function proxymify(getPrevPromiseLike: (...args: unknown[]) => PromiseLike): unknown { + return new Proxy(getPrevPromiseLike, { + get(_: unknown, nextProp: string): unknown { + const prevPromiseLike = getPrevPromiseLike(); + return handleNativePromise(prevPromiseLike, nextProp) ?? proxymifyNextValue(prevPromiseLike, nextProp); + }, + + apply(target: (...args: unknown[]) => PromiseLike, _: unknown, args: unknown[]): unknown { + return proxymifyNextValueFromMethodCall(target, args); + } + }); +} + +/** + * Checks if the passed prop is in the `Promise.prototype` and tries to get value by this prop. + * + * @param prevPromiseLike - previous `PromiseLike` + * @param nextProp - possible key from `Promise.prototype` + */ +function handleNativePromise(prevPromiseLike: PromiseLike, nextProp: string | symbol): unknown { + if (!Object.hasOwnProperty(Promise.prototype, nextProp)) { + return; + } + + const value = prevPromiseLike[nextProp]; + return Object.isFunction(value) ? value.bind(prevPromiseLike) : value; +} + +/** + * Creates next promise in chain that gets a value from the previous one by accessing it using the specified prop. + * + * @param prevPromiseLike - previous `PromiseLike` + * @param nextProp - key to get next value + */ +function proxymifyNextValue(prevPromiseLike: PromiseLike, nextProp: string | symbol): unknown { + return proxymify(async () => { + const data = await prevPromiseLike; + const value = data[nextProp]; + return Object.isFunction(value) ? value.bind(data) : value; + }); +} + +/** + * Creates next promise in chain that gets value from the previous one by calling the function. + * This function is called when we try to call a method on the previous proxied object. + * + * @param getMethod - the function that returns `PromiseLike` with currently calling method + * @param args - arguments for the method + */ +function proxymifyNextValueFromMethodCall(getMethod: () => PromiseLike, args: unknown[]): unknown { + return proxymify(async () => { + const method = await getMethod(); + return method(...args); + }); +} diff --git a/src/core/async/modules/flat/index.ts b/src/core/async/modules/flat/index.ts new file mode 100644 index 000000000..430147e80 --- /dev/null +++ b/src/core/async/modules/flat/index.ts @@ -0,0 +1,52 @@ +/*! + * V4Fire Core + * https://github.com/V4Fire/Core + * + * Released under the MIT license + * https://github.com/V4Fire/Core/blob/master/LICENSE + */ + +import type { Promisify } from 'core/async/modules/flat/interface/promisify'; +import { proxymify } from 'core/async/modules/flat/helpers'; + +export * from 'core/async/modules/flat/interface'; + +export function flatAsync(fn: T): Promisify; + +export function flatAsync(value: CanPromiseLike): Promisify; + +/** + * The function allows you to work flatly with promises using the `Proxy` object. + * + * The value you pass will be patched using the `Promisify` type in such a way that + * each of its members will be wrapped in a promise. However, you can still work with this value + * without worrying about the nested promises. + * + * @param value + * Can be any value or a function that returns any value. + * The final value will be wrapped in the `Promise`. + * + * @example + * ```typescript + * function getData(): Promise[]> { + * return Promise.resolve([Promise.resolve(21)]); + * } + * + * // "21" + * const str1 = await flatAsync(getData)()[0].toFixed(1); + + * // "21" + * const str2 = await flatAsync(getData())[0].toFixed(1); + * ``` + */ +export default function flatAsync( + value: CanPromiseLike | AnyFunction +): Promisify { + if (Object.isFunction(value)) { + return Object.cast((...args: unknown[]) => proxymify( + () => Promise.resolve(value(...args)) + )); + } + + return Object.cast(proxymify(() => Promise.resolve(value))); +} diff --git a/src/core/async/modules/flat/interface/expect-type.ts b/src/core/async/modules/flat/interface/expect-type.ts new file mode 100644 index 000000000..b2493c974 --- /dev/null +++ b/src/core/async/modules/flat/interface/expect-type.ts @@ -0,0 +1,17 @@ +/*! + * V4Fire Core + * https://github.com/V4Fire/Core + * + * Released under the MIT license + * https://github.com/V4Fire/Core/blob/master/LICENSE + */ + +type Fn = (() => G extends T ? 1 : 0); + +type AreEquals = Fn extends Fn ? unknown : never; + +/** + * Util for checking two types equality + */ +// eslint-disable-next-line @typescript-eslint/no-empty-function +export function expectType>(): void {} diff --git a/src/core/async/modules/flat/interface/index.ts b/src/core/async/modules/flat/interface/index.ts new file mode 100644 index 000000000..2fccaeeec --- /dev/null +++ b/src/core/async/modules/flat/interface/index.ts @@ -0,0 +1 @@ +export * from 'core/async/modules/flat/interface/promisify'; diff --git a/src/core/async/modules/flat/interface/promisify.ts b/src/core/async/modules/flat/interface/promisify.ts new file mode 100644 index 000000000..f23eac91c --- /dev/null +++ b/src/core/async/modules/flat/interface/promisify.ts @@ -0,0 +1,217 @@ +/*! + * V4Fire Core + * https://github.com/V4Fire/Core + * + * Released under the MIT license + * https://github.com/V4Fire/Core/blob/master/LICENSE + */ + +/* eslint-disable @typescript-eslint/ban-types */ + +/** + * Promisifies each function overload return type + */ +type Overloads = T extends () => infer R + ? T extends (...args: infer A) => any + ? (...args: A) => Promisify + : () => Promisify + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + (...args: infer A6): infer R6; + (...args: infer A7): infer R7; + (...args: infer A8): infer R8; + (...args: infer A9): infer R9; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + (...args: A4): Promisify; + (...args: A5): Promisify; + (...args: A6): Promisify; + (...args: A7): Promisify; + (...args: A8): Promisify; + (...args: A9): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + (...args: infer A6): infer R6; + (...args: infer A7): infer R7; + (...args: infer A8): infer R8; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + (...args: A4): Promisify; + (...args: A5): Promisify; + (...args: A6): Promisify; + (...args: A7): Promisify; + (...args: A8): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + (...args: infer A6): infer R6; + (...args: infer A7): infer R7; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + (...args: A4): Promisify; + (...args: A5): Promisify; + (...args: A6): Promisify; + (...args: A7): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + (...args: infer A6): infer R6; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + (...args: A4): Promisify; + (...args: A5): Promisify; + (...args: A6): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + (...args: A4): Promisify; + (...args: A5): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + (...args: A4): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + (...args: A3): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + (...args: A2): Promisify; + } + + : T extends { + (...args: infer A0): infer R0; + (...args: infer A1): infer R1; + } + ? { + (...args: A0): Promisify; + (...args: A1): Promisify; + } + + : T extends (...args: infer A) => infer R + ? (...args: A) => Promisify + + : never; + +/** + * Adds `Promise` properties to the specified value + */ +type WithPromise = Wrapped & Promise; + +/** + * Maps primitive values to their object representation and "unwraps" `PromiseLike` objects + */ +type GetSchema = Value extends string + ? String + : Value extends number + ? Number + : Value extends boolean + ? Boolean + : Value extends bigint + ? BigInt + : Value extends symbol + ? Symbol + : Value extends PromiseLike + ? GetSchema + : Value; + +/** + * Promisifies members of the specified schema by creating an object with the promisified properties + * or promisifying return type of each function overload + */ +type PromisifySchema = Schema extends AnyFunction + ? Overloads + + : WithPromise< + { + [Key in keyof Schema]: Promisify; + }, + Origin + >; + +/** + * Patches all members of the specified value in such a way that + * each of them will be wrapped in a promise but at the same time preserving its own properties + */ +export type Promisify = PromisifySchema, Value>; diff --git a/src/core/async/modules/flat/spec.ts b/src/core/async/modules/flat/spec.ts new file mode 100644 index 000000000..22f1dc440 --- /dev/null +++ b/src/core/async/modules/flat/spec.ts @@ -0,0 +1,84 @@ +/*! + * V4Fire Core + * https://github.com/V4Fire/Core + * + * Released under the MIT license + * https://github.com/V4Fire/Core/blob/master/LICENSE + */ + +import Async from 'core/async'; +import flat from 'core/async/modules/flat'; +import { expectType } from 'core/async/modules/flat/interface/expect-type'; + +describe('core/async/modules/flat', () => { + it('returns the last value from chain and infers its type', async () => { + const data = Promise.resolve({ + foo: { + bar: [(arg: number) => arg * 2] + } + }); + + const val = await flat(data) + .foo + .bar + .at(0)?.(21) + .toString() + .split(''); + + expect(val).toEqual(['4', '2']); + expectType, typeof val>(); + }); + + it('infers the type of the overloaded function', async () => { + function fn(arg: number): string; + function fn(arg: string): Promise; + function fn(arg: string | number): Promise | string { + return Object.isString(arg) ? Promise.resolve(Number(arg)) : String(arg); + } + + const + f = flat(fn), + s1 = await f(1).toUpperCase(), + s2 = await f('1').toFixed(1); + + expectType(); + expectType(); + + expect(s1).toBe('1'); + expect(s2).toBe('1.0'); + }); + + it('throws an expection or rejects a promise if trying to access an undefined property', async () => { + const thenHandler = jest.fn(); + const promise = flat([1])[10] + .toString() + .then(thenHandler) + .catch((e) => Promise.reject(e.message)); + + await expect(promise).rejects.toBe("Cannot read properties of undefined (reading 'toString')"); + expect(thenHandler).not.toBeCalled(); + }); + + it('works with `Promise.all`', async () => { + const $a = new Async(); + const flatten = flat(async () => { + await $a.sleep(50); + + return { + foo: {bar: 'bar'}, + baz: async () => { + await $a.sleep(50); + return ['baz']; + } + }; + })(); + + const + {bar} = flatten.foo, + baz = flatten.baz()[0], + values = await Promise.all([bar, baz]); + + expect(values).toEqual(['bar', 'baz']); + expectType<[string, string], typeof values>(); + }); +});