Skip to content

Commit

Permalink
Refactor PromiseChain class API (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
myty authored Sep 24, 2022
1 parent a2cf595 commit 9b83da6
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 46 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ console.log(`Result: propertyOne=${propertyOne}, propertyTwo=${propertyTwo}`);
With PromiseChain, it is simplified and easier to read.
```typescript
const { propertyOne, propertyTwo } = await Composable.create(testClass)
const { propertyOne, propertyTwo } = await PromiseChain(testClass)
.asyncIncrement("propertyOne", 3)
.asyncIncrement("propertyTwo", 5)
.increment("propertyTwo", 5);
Expand Down
4 changes: 3 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { PromiseChain } from "./promise-chain.ts";
import PromiseChain from "./promise-chain.ts";

export default PromiseChain;
8 changes: 4 additions & 4 deletions promise-chain.bench.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PromiseChain } from "./promise-chain.ts";
import PromiseChain from "./promise-chain.ts";
import { TestClass } from "./stubs/test-class.ts";

const iterate = (
Expand All @@ -18,7 +18,7 @@ Deno.bench(
Deno.bench(
"Composable Async Chain (1 Step)",
{ group: "1 step" },
iterate((t) => PromiseChain.create(t).asyncIncrement("propertyOne", 3)),
iterate((t) => PromiseChain(t).asyncIncrement("propertyOne", 3)),
);

Deno.bench(
Expand All @@ -35,7 +35,7 @@ Deno.bench(
"Composable Async Chain (2 Steps)",
{ group: "2 steps" },
iterate((t) =>
PromiseChain.create(t).asyncIncrement("propertyOne", 3).increment(
PromiseChain(t).asyncIncrement("propertyOne", 3).increment(
"propertyTwo",
5,
)
Expand All @@ -60,7 +60,7 @@ Deno.bench(
"Composable Async Chain (6 Steps)",
{ group: "6 steps" },
iterate((t) =>
PromiseChain.create(t)
PromiseChain(t)
.asyncIncrement("propertyOne", 3)
.asyncIncrementTwo()
.asyncIncrementOne()
Expand Down
57 changes: 50 additions & 7 deletions promise-chain.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import {
assert,
assertEquals,
assertRejects,
} from "https://deno.land/std@0.154.0/testing/asserts.ts";
import { PromiseChain } from "./promise-chain.ts";
import {
assertSpyCalls,
spy,
} from "https://deno.land/std@0.157.0/testing/mock.ts";
import PromiseChain from "./promise-chain.ts";
import { TestClassWithException } from "./stubs/test-class-with-exceptions.ts";
import { TestClass } from "./stubs/test-class.ts";

Deno.test(async function whenTraditionalAsyncChainingItReturnsResult() {
Expand All @@ -28,7 +34,7 @@ Deno.test(async function whenAsyncChainingItReturnsResult() {
const testClass = new TestClass();

// Act
const result = await PromiseChain.create(testClass)
const result = await PromiseChain(testClass)
.asyncIncrement("propertyOne", 3)
.asyncIncrementTwo()
.asyncIncrementOne()
Expand All @@ -41,23 +47,24 @@ Deno.test(async function whenAsyncChainingItReturnsResult() {
assertEquals(result.propertyTwo, 6);
});

Deno.test(function whenComposableAsyncItIsPromise() {
Deno.test(function whenComposableAsyncItIsPromiseLike() {
// Arrange
const testClass = new TestClass();

// Act
const result = PromiseChain.create(testClass);
const result = PromiseChain(testClass);

// Assert
assert(result instanceof PromiseChain);
assert(result instanceof Promise);
assert("then" in result && typeof result.then === "function");
assert("catch" in result && typeof result.catch === "function");
assert("finally" in result && typeof result.finally === "function");
});

Deno.test(async function whenChainedPromiseIsReusedItReturnsCachedResult() {
// Arrange
const testClass = new TestClass();
const durationExpectedMs = 250;
const resultTask = PromiseChain.create(testClass)
const resultTask = PromiseChain(testClass)
.asyncIncrement("propertyTwo", 3)
.asyncIncrementOneLongRunningTask(durationExpectedMs);
await resultTask;
Expand All @@ -76,3 +83,39 @@ Deno.test(async function whenChainedPromiseIsReusedItReturnsCachedResult() {
assertEquals(resultTwo.propertyOne, 1);
assertEquals(resultTwo.propertyTwo, 6);
});

Deno.test(function whenPromiseChainHasExceptionItIsRejected() {
// Arrange
const testClassWithException = new TestClassWithException();

// Act, Assert
assertRejects(() => PromiseChain(testClassWithException).throwException());
});

Deno.test(async function whenPromiseChainHasExceptionItIsCaught() {
// Arrange
const catchSpy = spy();
const testClassWithException = new TestClassWithException();

// Act
await PromiseChain(testClassWithException)
.throwException()
.catch(catchSpy);

// Assert
assertSpyCalls(catchSpy, 1);
});

Deno.test(async function whenPromiseChainPromiseIsFinalized() {
// Arrange
const finallySpy = spy();
const testClass = new TestClass();

// Act
await PromiseChain(testClass)
.asyncIncrementOne()
.finally(finallySpy);

// Assert
assertSpyCalls(finallySpy, 1);
});
52 changes: 22 additions & 30 deletions promise-chain.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,31 @@
import { AsyncComposable } from "./types.ts";
import { PromiseChainable, PromiseChainableConstructor } from "./types.ts";

/**
* Utility class to wrap a composition class with the intended purpose of chaining methods, specifically useful for
* functions that return Promises. Note: Promise functions and non-promise functions can be mixed.
*/
export class PromiseChain<T> extends Promise<T> implements Promise<T> {
/**
* Create a chaninable class based off of the functions that return "this" or a Promise of "this".
*/
static create<T>(wrappedClass: T): AsyncComposable<T> {
return new PromiseChain(wrappedClass) as unknown as AsyncComposable<T>;
}

private _valuePromise: Promise<T>;
const PromiseChain = function <T>(this: PromiseChainable<T> | void, obj: T) {
if (!(this instanceof PromiseChain)) {
return new PromiseChain(obj);
} else {
const self = this as unknown as { _valuePromise: Promise<T> } & Promise<T>;

private constructor(_wrappedClass: T) {
super((_resolve, _reject) => {});
self._valuePromise = Promise.resolve(obj);

this._valuePromise = Promise.resolve(_wrappedClass);
this.then = (...args) => this._valuePromise.then(...args);
this.catch = (...args) => this._valuePromise.catch(...args);
this.finally = (...args) => this._valuePromise.finally(...args);
this.then = (...args) => self._valuePromise.then(...args);
this.catch = (...args) => self._valuePromise.catch(...args);
this.finally = (...args) => self._valuePromise.finally(...args);

PromiseChain.keysOfObject(_wrappedClass).forEach((key) => {
const callableFunc = _wrappedClass[key];
keysOfObject(obj).forEach((key) => {
const callableFunc = obj[key];

if (!(callableFunc instanceof Function)) {
return;
}

Object.defineProperty(this, key, {
value: (...args: unknown[]) => {
this._valuePromise = this._valuePromise.then((val: T) =>
self._valuePromise = self._valuePromise.then((val: T) =>
callableFunc.apply(val, args)
);

Expand All @@ -40,18 +34,16 @@ export class PromiseChain<T> extends Promise<T> implements Promise<T> {
});
});
}
} as PromiseChainableConstructor;

// Private static methods

private static keysOfObject<T>(obj: T): Array<keyof T> {
const proto = Object.getPrototypeOf(obj);
function keysOfObject<T>(obj: T): Array<keyof T> {
const proto = Object.getPrototypeOf(obj);

const keys = Object.keys(obj).concat(
Object.getOwnPropertyNames(proto).filter((name) =>
name !== "constructor"
),
);
const keys = Object.keys(obj).concat(
Object.getOwnPropertyNames(proto).filter((name) => name !== "constructor"),
);

return keys as Array<keyof T>;
}
return keys as Array<keyof T>;
}

export default PromiseChain;
5 changes: 5 additions & 0 deletions stubs/test-class-with-exceptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export class TestClassWithException {
throwException(): Promise<TestClassWithException> {
return Promise.reject();
}
}
10 changes: 7 additions & 3 deletions types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// deno-lint-ignore-file ban-types

// https://stackoverflow.com/a/57044690/203857
export type KeysMatching<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
Expand All @@ -10,10 +9,15 @@ export type PickMatching<T, V> = Pick<T, KeysMatching<T, V>>;
export type PickFunctionsThatReturnSelf<TType> = {
[TKey in keyof PickMatching<TType, Function>]: TType[TKey] extends (
...args: infer TParams
) => TType | Promise<TType> ? (...args: TParams) => AsyncComposable<TType>
) => TType | Promise<TType> ? (...args: TParams) => PromiseChainable<TType>
: never;
};

export type AsyncComposable<TType> =
export type PromiseChainable<TType> =
& PickFunctionsThatReturnSelf<TType>
& Promise<TType>;

export interface PromiseChainableConstructor {
new <T>(obj: T): PromiseChainable<T>;
<T>(obj: T): PromiseChainable<T>;
}

0 comments on commit 9b83da6

Please sign in to comment.