@efflore/flow-sure
Version:
FlowSure - a Result monad in TypeScript. Data types Ok, Nil, Err with maybe(), result(), asyncResult() and flow() functions.
702 lines (600 loc) • 24.2 kB
text/typescript
import { describe, test, expect } from "bun:test";
import { type MaybeResult, ok, isOk, isGone, nil, isNil, err, isErr, maybe, result, task, flow, log } from "./index";
/* === Types === */
type TreeNode = { id: number; parentId: number; name: string, children?: Set<TreeNode> }
type TreeStructure = { roots: Set<TreeNode>, orphans: Set<TreeNode>}
/* === Utility Functions === */
const identity = <T>(x: T): T => x;
const addOne = (x: number) => x + 1;
const addOneOk = (x: number) => ok(x + 1);
const double = (x: number) => x * 2;
const doubleOk = (x: number) => ok(x * 2);
const half = (x: number) => x / 2;
const isPositive = (x: number) => x > 0;
const isEven = (x: number) => x % 2 === 0;
const isString = (x: unknown): x is string => typeof x === 'string';
const isPositiveNumber = (x: unknown): x is number => typeof x === 'number' && x > 0;
const recoverWithOk = () => ok("recovered");
const recoverWithNil = () => nil();
const recoverWithErr = () => err("Recovery failed");
const handleOk = (value: number) => ok(value + 1);
const handleNil = () => ok("Recovered from Nil");
const handleErr = (error: Error) => ok(`Recovered from error: ${error.message}`);
const handleGone = () => ok("Recovered from Gone");
const successfulTask = () => Promise.resolve(10);
const nullTask = () => Promise.resolve(null);
const failingTask = () => Promise.reject(new Error("Task failed"));
/* === Tests === */
describe("Ok Use Case", () => {
test("isOk should return true for Ok instances", () => {
const o = ok(10);
expect(isOk(o)).toBe(true);
});
test("isOk should return false for Nil instances", () => {
const n = nil();
expect(isOk(n)).toBe(false);
});
test("isOk should return false for Err instances", () => {
const e = err("Error");
expect(isOk(e)).toBe(false);
});
test("map and filter should apply functions to the value of Ok instances", () => {
const result = ok(5).map(x => x * 2).filter(x => x > 5);
expect(isOk(result)).toBe(true);
expect(result.get()).toBe(10);
result.match({
Ok: value => expect(typeof value).toBe('number'),
Nil: () => expect().toBe(undefined),
Err: error => expect(error instanceof Error).toBe(true)
});
});
test("Ok clones mutable objects successfully", () => {
const obj = { a: 1 };
const instance = ok(obj);
obj.a = 2; // Modify original object
expect(instance.get()).toEqual({ a: 1 }); // Clone remains unchanged
});
test("Ok falls back for non-cloneable types", async () => {
const promise = new Promise(resolve => setTimeout(() => resolve(10), 1));
const instance = ok(promise);
const res = instance.get();
expect(res).toBe(promise);
expect(await res).toBe(await promise);
expect(() => instance.get()).toThrow("Mutable reference has already been consumed");
});
test("Ok treats unsupported types as immutable", () => {
expect(() => ok(Symbol("test"))).not.toThrow();
expect(() => ok(() => {})).not.toThrow();
expect(() => ok(BigInt(42))).not.toThrow();
});
test("Ok clones objects with circular references correctly", () => {
const circular: any = {};
circular.self = circular;
const instance = ok(circular);
const cloned = instance.get();
// Verify that the circular reference is preserved
expect(cloned.self).toBe(cloned);
expect(cloned).not.toBe(circular); // The clone should not be the same reference as the original
});
});
describe("Task Use Case", () => {
// Mock fetch function to simulate network request with 200ms delay and 30% failure rate
const mockFetch = (_: string): Promise<Response> => new Promise((resolve, reject) => {
const response: Response = result(() => Response.json({
value: [
{ id: 0, parentId: null, name: "Root" },
{ id: 1, parentId: 0, name: "Level 1, Item 1" },
{ id: 2, parentId: 0, name: "Level 1, Item 2" },
{ id: 3, parentId: 0, name: "Level 1, Item 3" },
{ id: 4, parentId: 1, name: "Level 2, Item 1.1" },
{ id: 5, parentId: 1, name: "Level 2, Item 1.2" },
{ id: 6, parentId: 2, name: "Level 2, Item 2.1" },
{ id: 7, parentId: 2, name: "Level 2, Item 2.2" },
{ id: 8, parentId: 2, name: "Level 2, Item 2.3" }
]
}))
// In case Response.json() throws an error, return a mock error response
.catch((error: Error) => Response.json(
{ error: error.message },
{ status: 500, statusText: "Internal Server Error" }
))
.get() as Response
setTimeout(() => Math.random() < 0.3
? reject(new Error("Mock network error: Request failed"))
: resolve(response)
, 200) // Simulate 200ms delay
})
// Step 1: Fetch data
const fetchData = async () => {
const response = await mockFetch('/api/data')
if (!response.ok) return err(`Failed to fetch data: ${response.statusText}`)
return response.json()
}
// Step 2: Validate data
const validateData = (data: any) =>
typeof data === "object"
&& "value" in data
&& Array.isArray(data.value)
// Step 3: Build tree structure
const buildTreeStructure = (items: TreeNode[]): TreeStructure => {
const idMap = new Map<number, TreeNode>()
const roots = new Set<TreeNode>()
const orphans = new Set<TreeNode>()
// Populate the idMap
for (const item of items) {
item.children = new Set<TreeNode>()
idMap.set(item.id, item)
}
// Attach each item to its parent's `children` array if possible
for (const item of items)
item.parentId == null
? roots.add(item)
: idMap.get(item.parentId)?.children!.add(item)
|| orphans.add(item)
return { roots, orphans }
}
const fetchTreeData = async () => {
const retry = <T>(
fn: () => Promise<MaybeResult<T>>,
retries: number,
delay: number
) => task(fn)
.catch((error: Error) => {
if (retries <= 0) return err(error)
return new Promise(resolve => setTimeout(resolve, delay))
.then(() => retry(fn, retries - 1, delay * 2))
})
// 3 attempts, exponential backoff with initial 1000ms delay
const data = await retry(fetchData, 3, 1000)
// Validate the data and build the tree structure
return data
.filter(validateData)
.map((x: { value: any[] }) => buildTreeStructure(x.value))
.match({
Nil: () => err("Data is invalid, missing 'value', or 'value' is not an array")
})
}
test("returns the correct result", async () => {
const result = await fetchTreeData()
if (isErr(result)) expect(result.error.message).toBeString()
else {
const obj = result.get() as TreeStructure
expect(obj.roots).toBeInstanceOf(Set)
expect(obj.orphans).toBeInstanceOf(Set)
expect(obj.roots.size).toBeGreaterThan(0)
expect(obj.orphans.size).toBe(0)
}
})
});
// Test Monad Laws for Ok
describe("Monad Laws for Ok", () => {
test("Left Identity: Ok(x).chain(f) === f(x)", () => {
const x = 5;
const f = addOneOk;
const res1 = ok(x).chain(f);
const res2 = f(x);
expect(res1.get()).toBe(res2.get());
});
test("Right Identity: Ok(x).chain(Ok) === Ok(x)", () => {
const x = 5;
const res1 = ok(x).chain(y => ok(y));
const res2 = ok(x);
expect(res1.get()).toBe(res2.get());
});
test("Associativity: (Ok(x).chain(f)).chain(g) === Ok(x).chain(x => f(x).chain(g))", () => {
const x = 5;
const f = addOneOk;
const g = doubleOk;
const res1 = ok(x).chain(f).chain(g);
const res2 = ok(x).chain(x => f(x).chain(g));
expect(res1.get()).toBe(res2.get());
});
});
describe("Async Monad Laws for Ok", () => {
test("Left Identity: ok(x).await(f) === f(x)", async () => {
const x = 5;
const f = async (y: number) => ok(y + 1);
const res1 = await ok(x).await(f);
const res2 = await f(x);
expect(res1.get()).toBe(res2.get());
});
test("Right Identity: ok(x).await(async y => ok(y)) === ok(x)", async () => {
const x = 5;
const res1 = await ok(x).await(async y => ok(y));
const res2 = ok(x);
expect(res1.get()).toBe(res2.get());
});
test("Associativity: await ok(x).await(f).then(res => res.await(g)) === await ok(x).await(async y => f(y).then(z => z.await(g))", async () => {
const x = 5;
const f = async (y: number) => ok(y + 1); // Async function
const g = async (y: number) => ok(y * 2); // Async function
// Sequential application of f and then g
const res1 = await ok(x).await(f).then(res => res.await(g));
// Composition of f and g into a single operation
const res2 = await ok(x).await(async y => f(y).then(z => z.await(g)));
expect(res1.get()).toBe(res2.get());
});
});
// Test Monad Laws for Err
describe("Monad Laws for Err", () => {
test("Left Identity: Err.chain(f) === Err", () => {
const e = err("Error");
const f = addOneOk;
const res = e.chain(f);
expect(res.error).toBe(e.error);
});
test("Right Identity: Err.chain(Ok) === Err", () => {
const e = err("Error");
const res = e.chain(ok);
expect(res.error).toBe(e.error);
});
test("Associativity: (Err.chain(f)).chain(g) === Err.chain(x => f(x).chain(g))", () => {
const e = err("Error");
const f = addOneOk;
const g = doubleOk;
const res1 = e.chain(f).chain(g);
const res2 = e.chain(x => f(x).chain(g));
expect(res1.error).toBe(e.error);
expect(res2.error).toBe(e.error);
});
});
// Test Monad Laws for Nil
describe("Monad Laws for Nil", () => {
test("Left Identity: Nil.chain(f) === Nil", () => {
const f = addOneOk;
const res = nil().chain(f);
expect(res.get()).toBe(nil().get());
});
test("Right Identity: Nil.chain(Ok) === Nil", () => {
const res = nil().chain(ok);
expect(res.get()).toBe(nil().get());
});
test("Associativity: (Nil.chain(f)).chain(g) === Nil.chain(x => f(x).chain(g))", () => {
const f = addOneOk;
const g = doubleOk;
const res1 = nil().chain(f).chain(g);
const res2 = nil().chain(x => f(x).chain(g));
expect(res1.get()).toBe(nil().get());
expect(res2.get()).toBe(nil().get());
});
});
// Test Functor Laws for Ok
describe("Functor Laws for Ok", () => {
test("Identity: Ok(x).map(x => x) === Ok(x)", () => {
const x = 5;
const res1 = ok(x).map(identity);
const res2 = ok(x);
expect(res1.get()).toBe(res2.get());
});
test("Composition: Ok(x).map(x => f(g(x))) === Ok(x).map(g).map(f)", () => {
const x = 5;
const res1 = ok(x).map(x => addOne(double(x))); // map with composition
const res2 = ok(x).map(double).map(addOne); // map separately
expect(res1.get()).toBe(res2.get());
});
});
// Test Functor Laws for Err
describe("Functor Laws for Err", () => {
test("Identity: Err.map(x => x) === Err", () => {
const e = err("Error");
const res = e.map(identity);
expect(res.error).toBe(e.error);
});
test("Composition: Err.map(x => f(g(x))) === Err.map(g).map(f)", () => {
const e = err("Error");
const res1 = e.map(x => addOne(double(x))); // map with composition
const res2 = e.map(double).map(addOne); // map separately
expect(res1.error).toBe(e.error); // No transformation happens on Err
expect(res2.error).toBe(e.error); // No transformation happens on Err
});
});
// Test Functor Laws for Nil
describe("Functor Laws for Nil", () => {
test("Identity: Nil.map(x => x) === Nil", () => {
const res = nil().map(identity);
expect(res.get()).toBe(nil().get());
});
test("Composition: Nil.map(x => f(g(x))) === Nil.map(g).map(f)", () => {
const res1 = nil().map(x => addOne(double(x))); // map with composition
const res2 = nil().map(double).map(addOne); // map separately
expect(res1.get()).toBe(nil().get()); // No transformation happens on Nil
expect(res2.get()).toBe(nil().get()); // No transformation happens on Nil
});
});
// Ok Monad
describe("Filterable Trait for Ok", () => {
test("Filter Ok value, predicate true", () => {
const res = ok(5).filter(isPositive);
expect(res.get()).toBe(5);
});
test("Filter Ok value, predicate false", () => {
const res = ok(-5).filter(isPositive);
expect(isNil(res)).toBe(true); // Ok(-5) should be filtered out, resulting in Nil
});
test("Filter Ok value with even predicate, true case", () => {
const res = ok(4).filter(isEven);
expect(res.get()).toBe(4); // 4 is even, so Ok(4) remains
});
test("Filter Ok value with even predicate, false case", () => {
const res = ok(5).filter(isEven);
expect(isNil(res)).toBe(true); // 5 is not even, so Nil
});
});
// Nil Monad
describe("Filterable Trait for Nil", () => {
test("Filter Nil, always returns Nil", () => {
const res = nil().filter(isPositive);
expect(isNil(res)).toBe(true); // Nil remains Nil regardless of predicate
});
});
// Err Monad
describe("Filterable Trait for Err", () => {
test("Filter Err, always returns Nil", () => {
const e = err("Something went wrong");
const res = e.filter(isPositive);
expect(isNil(res)).toBe(true); // Err should always result in Nil when filtered
});
});
// Ok Monad
describe("Guard Trait for Ok", () => {
test("Guard Ok value, type guard passes", () => {
const res = ok("hello").guard(isString);
expect(res.get()).toBe("hello"); // The value is a string, so Ok("hello") remains
});
test("Guard Ok value, type guard fails", () => {
// @ts-expect-error
const res = ok(5).guard(isString);
expect(isNil(res)).toBe(true); // 5 is not a string, so Nil
});
test("Guard Ok value with positive number check, passes", () => {
const res = ok(10).guard(isPositiveNumber);
expect(res.get()).toBe(10); // 10 is a positive number, so Ok(10) remains
});
test("Guard Ok value with positive number check, fails", () => {
const res = ok(-5).guard(isPositiveNumber);
expect(isNil(res)).toBe(true); // -5 is not a positive number, so Nil
});
});
// Nil Monad
describe("Guard Trait for Nil", () => {
test("Guard Nil, always returns Nil", () => {
const res = nil().guard(isString);
expect(isNil(res)).toBe(true); // Nil remains Nil regardless of guard
});
});
// Err Monad
describe("Guard Trait for Err", () => {
test("Guard Err, always returns Nil", () => {
const e = err("Something went wrong");
const res = e.guard(isString);
expect(isNil(res)).toBe(true); // Err should always result in Nil when guarded
});
});
// Ok Monad
describe("Or Trait for Ok", () => {
test("Ok.or() has no effect, keeps original value", () => {
const res = ok(5).or(() => 10);
expect(res.get()).toBe(5); // Ok(5) remains unchanged, or() has no effect
});
test("Ok.or() with nullish fallback, keeps original value", () => {
const res = ok(5).or(() => null);
expect(res.get()).toBe(5); // Ok(5) remains unchanged, or() has no effect
});
});
// Nil Monad
describe("Or Trait for Nil", () => {
test("Nil.or() provides fallback value as Ok", () => {
const res = nil().or(() => 10);
expect(res.get()).toBe(10); // Nil becomes Ok(10) with the fallback value
});
test("Nil.or() with nullish fallback, remains Nil", () => {
const res = nil().or(() => null);
expect(isNil(res)).toBe(true); // Nil remains Nil as fallback is nullish
});
});
// Err Monad
describe("Or Trait for Err", () => {
test("Err.or() provides fallback value as Ok", () => {
const e = err("Something went wrong");
const res = e.or(() => 10);
expect(res.get()).toBe(10); // Err becomes Ok(10) with the fallback value
});
test("Err.or() with nullish fallback, becomes Nil", () => {
const e = err("Something went wrong");
const res = e.or(() => null);
expect(isNil(res)).toBe(true); // Err becomes Nil as fallback is nullish
});
});
// Ok Monad
describe("Catch Trait for Ok", () => {
test("Ok.catch() has no effect, remains Ok", () => {
const res = ok(5).catch(recoverWithOk);
expect(res.get()).toBe(5); // Ok(5) remains unchanged, catch() has no effect
});
});
// Nil Monad
describe("Catch Trait for Nil", () => {
test("Nil.catch() has no effect, remains Nil", () => {
const res = nil().catch(recoverWithOk);
expect(isNil(res)).toBe(true); // Nil remains Nil, catch() has no effect
});
});
// Err Monad
describe("Catch Trait for Err", () => {
test("Err.catch() recovers with Ok", () => {
const e = err("Something went wrong");
const res = e.catch(recoverWithOk);
expect(isOk(res)).toBe(true); // Err becomes Ok after recovery
expect(res.get()).toBe("recovered");
});
test("Err.catch() recovers with Nil", () => {
const e = err("Something went wrong");
const res = e.catch(recoverWithNil);
expect(isNil(res)).toBe(true); // Err becomes Nil after recovery
});
test("Err.catch() fails with another Err", () => {
const e = err("Something went wrong");
const res = e.catch(recoverWithErr);
expect(isErr(res)).toBe(true); // Err remains Err after failed recovery
expect(() => res.get()).toThrow("Recovery failed"); // The error message should now reflect the recovery error
});
});
// Ok Monad
describe("Match Trait for Ok", () => {
test("Match with Ok handler", () => {
const res = ok(5).match({ Ok: handleOk });
expect(isOk(res)).toBe(true); // Ok handler should be applied
expect(res.get()).toBe(6); // 5 + 1 = 6
});
test("Match without Ok handler, passes through", () => {
const res = ok(5).match({});
expect(isOk(res)).toBe(true); // Ok remains unchanged
expect(res.get()).toBe(5);
});
test("Match with Gone handler", () => {
const res = ok({ name: "John" });
expect(res.get().name).toBe("John");
expect(isGone(res)).toBe(true); // Gone is true, indicating the value has been consumed already
const recovered = res.match({ Gone: handleGone });
expect(isOk(recovered)).toBe(true); // Gone handler should be applied
expect(recovered.get()).toBe("Recovered from Gone");
});
test("Match without Gone handler, returns Err<ReferenceError>", () => {
const res = ok({ name: "John" });
expect(res.get().name).toBe("John");
expect(isGone(res)).toBe(true); // Gone is true, indicating the value has been consumed already
const error = res.match({});
expect(isErr(error)).toBe(true); // Refeerence Error is returned, as the value has been consumed already
expect(() => error.get()).toThrow("Mutable reference has already been consumed");
});
});
// Nil Monad
describe("Match Trait for Nil", () => {
test("Match with Nil handler", () => {
const res = nil().match({ Nil: handleNil });
expect(isOk(res)).toBe(true); // Nil handler should be applied, resulting in Ok
expect(res.get()).toBe("Recovered from Nil");
});
test("Match without Nil handler, passes through", () => {
const res = nil().match({});
expect(isNil(res)).toBe(true); // Nil remains unchanged
});
});
// Err Monad
describe("Match Trait for Err", () => {
test("Match with Err handler", () => {
const e = err("Something went wrong");
const res = e.match({ Err: handleErr });
expect(isOk(res)).toBe(true); // Err handler should be applied, recovering to Ok
expect(res.get()).toBe("Recovered from error: Something went wrong");
});
test("Match without Err handler, passes through", () => {
const e = err("Something went wrong");
const res = e.match({});
expect(isErr(res)).toBe(true); // Err remains unchanged
expect(() => res.get()).toThrow("Something went wrong"); // Error is passed through
});
});
// Ok Monad
describe("Get Trait for Ok", () => {
test("Ok.get() returns the contained value", () => {
const res = ok(5);
expect(res.get()).toBe(5); // Ok(5) should return 5
});
test("Ok.get() returns the contained structured value only once", () => {
const res = ok({ name: "John" });
expect(res.get().name).toBe("John"); // Ok({ name: "John" }) should return { name: "John" }
expect(() => res.get()).toThrow("Mutable reference has already been consumed");
});
});
// Nil Monad
describe("Get Trait for Nil", () => {
test("Nil.get() returns undefined", () => {
const res = nil();
expect(res.get()).toBe(undefined); // Nil should return undefined
});
});
// Err Monad
describe("Get Trait for Err", () => {
test("Err.get() rethrows the contained error", () => {
const e = err("Something went wrong");
expect(() => e.get()).toThrow("Something went wrong"); // Err should rethrow the contained error
});
});
// Tests for maybe()
describe("Maybe Function", () => {
test("maybe() with non-nullish value returns Ok", () => {
const res = maybe(5);
expect(isOk(res)).toBe(true); // maybe(5) should return Ok(5)
expect(res.get()).toBe(5);
});
test("maybe() with null value returns Nil", () => {
const res = maybe(null);
expect(isNil(res)).toBe(true); // maybe(null) should return Nil
});
test("maybe() with undefined value returns Nil", () => {
const res = maybe();
expect(isNil(res)).toBe(true); // maybe() should return Nil
});
});
// Tests for result()
describe("Result Function", () => {
test("result() with successful function returns Ok", () => {
const res = result(() => 10);
expect(isOk(res)).toBe(true); // result of function should return Ok(10)
expect(res.get()).toBe(10);
});
test("result() with function returning null returns Nil", () => {
const res = result(() => null);
expect(isNil(res)).toBe(true); // result of function returning null should return Nil
});
test("result() with function throwing error returns Err", () => {
const res = result(() => {
throw new Error("Something went wrong");
});
expect(isErr(res)).toBe(true); // result of function throwing error should return Err
expect(() => res.get()).toThrow("Something went wrong"); // Ensure the error is properly thrown
});
});
// Tests for task()
describe("Task Function", () => {
test("task() with a successful Promise resolves to Ok", async () => {
const res = await task(successfulTask);
expect(isOk(res)).toBe(true); // Task resolves to Ok
expect(res.get()).toBe(10);
});
test("task() with a Promise resolving to null resolves to Nil", async () => {
const res = await task(nullTask);
expect(isNil(res)).toBe(true); // Task resolves to Nil
});
test("task() with a failing Promise rejects with Err", async () => {
const res = await task(failingTask);
expect(isErr(res)).toBe(true); // Task rejects with Err
expect(() => res.get()).toThrow("Task failed"); // Ensure the error is properly thrown
});
});
// Tests for flow()
describe("Flow Function", () => {
test("flow() with a successful Promise resolves to Ok", async () => {
const res = await flow(
5,
double,
async x => x
? Promise.resolve(x + 10)
: Promise.reject("Error in first stage"),
half
);
expect(isOk(res)).toBe(true); // Flow resolves to Ok
expect(res.get()).toBe(10); // (5 * 2 + 10) / 2 = 10
});
test("flow() with a Promise rejecting in the middle rejects with Err", async () => {
const res = await flow(
5,
double,
async _ => Promise.reject(new Error("Error in second stage")),
half
);
expect(isErr(res)).toBe(true); // Flow rejects with Err
expect(() => res.get()).toThrow("Error in second stage"); // Ensure the error is properly thrown
});
});