Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
coverage
node_modules
yarn-error.log
Future.js
Future.js
.envrc
87 changes: 82 additions & 5 deletions Future.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,17 @@ describe("Future", () => {
expect(resolveSpy).not.toHaveBeenCalled();
});

test("invokes reject when action throws exception", () => {
const actionSpy = jasmine.createSpy("futureAction").and.throwError("forced error");
test("does not invoke reject when action throws exception", () => {
const actionSpy = jasmine.createSpy("futureAction").and.callFake(() => {
throw new Error("forced error");
});
const rejectSpy = jasmine.createSpy("rejectSpy");
const resolveSpy = jasmine.createSpy("resolveSpy");
new Future(actionSpy).engage(rejectSpy, resolveSpy);

expect(() => {
new Future(actionSpy).engage(rejectSpy, resolveSpy);
}).toThrowError("forced error");
expect(actionSpy).toHaveBeenCalledWith(rejectSpy, resolveSpy);
expect(rejectSpy).toHaveBeenCalledWith(new Error("forced error"));
expect(rejectSpy).not.toHaveBeenCalled();
expect(resolveSpy).not.toHaveBeenCalled();
});

Expand All @@ -37,6 +40,69 @@ describe("Future", () => {

expect(resolveSpy).toHaveBeenCalledWith("my value");
});
test("handleWith for current Future does not get run if errors above its scope throw", (done) => {
let mapCalledTimes = 0;
let handleWithCalledTimes = 0;
const action = Future.of(33)
.handleWith((e: Error) => {
// eslint-disable-next-line no-console
console.log(`failed an infallible future: ${e.message}`);
handleWithCalledTimes++;
return Future.of(-1);
})
.map((r) => {
mapCalledTimes++;
return r;
});

try {
action.engage(
(e) => {
throw e;
},
(r) => {
throw new Error(`oh no, something went wrong after the future has run to completion on ${r}`);
}
);
} catch (e) {
expect(handleWithCalledTimes).toBe(0);
expect(mapCalledTimes).toBe(1);
done();
}
});

test("does not get run if engages above this scope throw in the rejection", (done) => {
let mapCalledTimes = 0;
let handleWithCalledTimes = 0;
const expectedError = new Error("error message");
let errorInHandleWith = null;
const action: Future<Error, number> = Future.reject(expectedError)
.handleWith((e): any => {
handleWithCalledTimes++;
errorInHandleWith = e;
return Future.of(-1);
})
.map((r) => {
mapCalledTimes++;
return r;
});

try {
action.engage(
(e) => {
throw e;
},
(r) => {
throw new Error(`oh no, something went wrong after the future has run to completion on ${r}`);
}
);
} catch (e) {
expect(handleWithCalledTimes).toBe(1);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand based on the description, where does this test show that it the handleWith wasn't run? It looks like it shows that it does, and it's not clear if it's from its own rejection or the rejection thrown in the engage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the test to show that it's the error that came from the Future.reject

expect(errorInHandleWith).toBe(expectedError);
expect(mapCalledTimes).toBe(1);
done();
}
});
});

describe("toPromise", () => {
Expand Down Expand Up @@ -909,5 +975,16 @@ describe("Future", () => {

await expect(chainedPromiseFuture).rejects.toThrow("bad stuff");
});

test("catches exceptions if thrown in the map of the future", async () => {
const p = Promise.resolve(1);
const chainedPromiseFuture = p.then(() =>
Future.of(1).map(() => {
throw new Error("bad stuff");
})
);

await expect(chainedPromiseFuture).rejects.toThrow("bad stuff");
});
});
});
24 changes: 15 additions & 9 deletions Future.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ export default class Future<L, R> {
* @param {Function} resolve Handler if Future fully executed successfully
*/
engage(reject: Reject<L>, resolve: Resolve<R>): void {
this.action(reject, resolve);
}

private engageAndCatch(reject: Reject<L>, resolve: Resolve<R>): void {
try {
//In the case where the action call just completely blows up, prevent against that and invoke reject.
this.action(reject, resolve);
} catch (error: any) {
reject(error);
this.engage(reject, resolve);
} catch (e) {
reject(e as L);
}
}

Expand Down Expand Up @@ -60,7 +63,7 @@ export default class Future<L, R> {
*/
flatMap<NewResultType>(next: (data: R) => Future<L, NewResultType>): Future<L, NewResultType> {
return new Future<L, NewResultType>((reject: Reject<L>, resolve: Resolve<NewResultType>) => {
this.engage(reject, (data: R) => next(data).engage(reject, resolve));
this.engageAndCatch(reject, (data: R) => next(data).engageAndCatch(reject, resolve));
});
}

Expand All @@ -83,7 +86,7 @@ export default class Future<L, R> {
*/
errorMap<LB>(mapper: (error: L) => LB): Future<LB, R> {
return new Future<LB, R>((reject: Reject<LB>, resolve: Resolve<R>) => {
this.engage((error) => reject(mapper(error)), resolve);
this.engageAndCatch((error) => reject(mapper(error)), resolve);
});
}

Expand All @@ -97,6 +100,7 @@ export default class Future<L, R> {
try {
result = fn();
} catch (e: any) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return reject(e);
}
resolve(result);
Expand All @@ -114,6 +118,7 @@ export default class Future<L, R> {
try {
promiseResult = fn();
} catch (e: any) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return reject(e);
}
//We have to support both Promise and PromiseLike methods as input here, but treat them all as normal Promises when executing them
Expand Down Expand Up @@ -151,6 +156,7 @@ export default class Future<L, R> {
try {
result = fn(a);
} catch (e: any) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
return reject(e);
}
resolve(result);
Expand All @@ -169,7 +175,7 @@ export default class Future<L, R> {
let count = 0;
let done = false;

future1.engage(
future1.engageAndCatch(
(error) => {
if (!done) {
done = true;
Expand All @@ -184,7 +190,7 @@ export default class Future<L, R> {
}
);

future2.engage(
future2.engageAndCatch(
(error) => {
if (!done) {
done = true;
Expand Down Expand Up @@ -249,7 +255,7 @@ export default class Future<L, R> {
}

futures.forEach((futureInstance, index) => {
futureInstance.engage(
futureInstance.engageAndCatch(
(error) => {
reject(error);
},
Expand Down
57 changes: 57 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
description = "FutureJS";
inputs.flake-utils.url = "github:numtide/flake-utils";

outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem
(system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShell = pkgs.mkShell
{
buildInputs =
[
pkgs.nodejs_20
(pkgs.yarn.override {
nodejs = pkgs.nodejs_20;
})
];
};
});
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
},
"devDependencies": {
"@types/jest": "^26.0.22",
"@typescript-eslint/eslint-plugin": "^4.21.0",
"@typescript-eslint/parser": "^4.21.0",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"eslint": "^7.23.0",
"eslint-plugin-import": "^2.22.1",
"eslint-plugin-jsdoc": "^32.3.0",
Expand Down
Loading