import { describe, expect, it } from "bun:test"; import { Option, Some, None } from "./option"; import { Panic } from "./error"; describe("Option", () => { describe("some", () => { it("creates Some with value", () => { const option = Option.some(42); expect(option).toBeInstanceOf(Some); expect(option.status).toBe("some"); expect(option.value).toBe(42); }); it("creates Some with null", () => { const option = Option.some(null); expect(option.value).toBe(null); }); it("creates Some with undefined", () => { const option = Option.some(undefined); expect(option.value).toBe(undefined); }); it("creates Some when called without arguments", () => { const option = Option.some(); expect(option).toBeInstanceOf(Some); expect(option.status).toBe("some"); expect(option.value).toBe(undefined); }); }); describe("none", () => { it("creates None singleton", () => { const option = Option.none(); expect(option).toBe(None.instance); expect(option.status).toBe("none"); }); it("returns same instance", () => { expect(Option.none()).toBe(Option.none()); }); }); describe("isSome", () => { it("returns true for Some", () => { expect(Option.isSome(Option.some(1))).toBe(true); }); it("returns false for None", () => { expect(Option.isSome(Option.none())).toBe(false); }); }); describe("isNone", () => { it("returns true for None", () => { expect(Option.isNone(Option.none())).toBe(true); }); it("returns false for Some", () => { expect(Option.isNone(Option.some(1))).toBe(false); }); }); describe("Some.isSome / Some.isNone methods", () => { it("Some.isSome() returns true", () => { const some = Option.some(42); expect(some.isSome()).toBe(true); }); it("Some.isNone() returns false", () => { const some = Option.some(42); expect(some.isNone()).toBe(false); }); it("None.isSome() returns false", () => { const none = Option.none(); expect(none.isSome()).toBe(false); }); it("None.isNone() returns true", () => { const none = Option.none(); expect(none.isNone()).toBe(true); }); it("narrows Option to Some when isSome() returns true", () => { const option: Option = Option.some(42); if (option.isSome()) { const value: number = option.value; expect(value).toBe(42); } else { expect.unreachable("should be Some"); } }); it("narrows Option to None when isNone() returns true", () => { const option: Option = Option.none(); if (option.isNone()) { expect(option.status).toBe("none"); } else { expect.unreachable("should be None"); } }); }); describe("map", () => { it("transforms Some value", () => { const option = Option.some(2).map((x) => x * 3); expect(option.unwrap()).toBe(6); }); it("passes through None", () => { const option = Option.none().map((x: number) => x * 3); expect(Option.isNone(option)).toBe(true); }); it("works as standalone function (data-first)", () => { const option = Option.map(Option.some(2), (x) => x * 3); expect(option.unwrap()).toBe(6); }); it("works as standalone function (data-last)", () => { const double = Option.map((x: number) => x * 2); const option = double(Option.some(5)); expect(option.unwrap()).toBe(10); }); it("throws Panic when callback throws", () => { expect(() => Option.some(1).map(() => { throw new Error("map callback failed"); }), ).toThrow(Panic); }); }); describe("andThen", () => { it("chains Some to Some", () => { const option = Option.some(2).andThen((x) => Option.some(x * 3)); expect(option.unwrap()).toBe(6); }); it("chains Some to None", () => { const option = Option.some(2).andThen(() => Option.none()); expect(Option.isNone(option)).toBe(true); }); it("short-circuits on None", () => { let called = false; const option = Option.none().andThen((x: number) => { called = true; return Option.some(x * 2); }); expect(called).toBe(false); expect(Option.isNone(option)).toBe(true); }); it("throws Panic when callback throws", () => { expect(() => Option.some(1).andThen(() => { throw new Error("andThen callback failed"); }), ).toThrow(Panic); }); }); describe("andThenAsync", () => { it("chains async operations", async () => { const option = await Option.some(2).andThenAsync(async (x) => Option.some(x * 3)); expect(option.unwrap()).toBe(6); }); it("short-circuits on None", async () => { let called = false; const option = await Option.none().andThenAsync(async (x: number) => { called = true; return Option.some(x * 2); }); expect(called).toBe(false); expect(Option.isNone(option)).toBe(true); }); it("throws Panic when callback rejects", async () => { await expect( Option.some(1).andThenAsync(async () => { throw new Error("async callback failed"); }), ).rejects.toBeInstanceOf(Panic); }); }); describe("match", () => { it("calls some handler for Some", () => { const result = Option.some(2).match({ some: (x) => `value: ${x}`, none: () => "empty", }); expect(result).toBe("value: 2"); }); it("calls none handler for None", () => { const result = Option.none().match({ some: (x) => `value: ${x}`, none: () => "empty", }); expect(result).toBe("empty"); }); it("throws Panic when some handler throws", () => { expect(() => Option.some(1).match({ some: () => { throw new Error("some handler failed"); }, none: () => "none", }), ).toThrow(Panic); }); it("throws Panic when none handler throws", () => { expect(() => Option.none().match({ some: () => "some", none: () => { throw new Error("none handler failed"); }, }), ).toThrow(Panic); }); }); describe("unwrap", () => { it("returns value for Some", () => { expect(Option.some(42).unwrap()).toBe(42); }); it("throws for None", () => { expect(() => Option.none().unwrap()).toThrow(); }); it("throws with custom message", () => { expect(() => Option.none().unwrap("custom")).toThrow("custom"); }); }); describe("unwrapOr", () => { it("returns value for Some", () => { expect(Option.some(42).unwrapOr(0)).toBe(42); }); it("returns fallback for None", () => { expect(Option.none().unwrapOr(0)).toBe(0); }); }); describe("unwrapOrElse (instance)", () => { it("returns value for Some (fallback not called)", () => { let called = false; const value = Option.some(42).unwrapOrElse(() => { called = true; return 0; }); expect(value).toBe(42); expect(called).toBe(false); }); it("computes fallback for None", () => { const value = Option.none().unwrapOrElse(() => "default"); expect(value).toBe("default"); }); it("throws Panic when fallback throws", () => { expect(() => Option.none().unwrapOrElse(() => { throw new Error("fallback failed"); }), ).toThrow(Panic); }); }); describe("tap", () => { it("runs side effect on Some", () => { let captured = 0; const option = Option.some(42).tap((x) => { captured = x; }); expect(captured).toBe(42); expect(option.unwrap()).toBe(42); }); it("skips side effect on None", () => { let called = false; const option = Option.none().tap(() => { called = true; }); expect(called).toBe(false); expect(Option.isNone(option)).toBe(true); }); it("throws Panic when callback throws", () => { expect(() => Option.some(1).tap(() => { throw new Error("tap callback failed"); }), ).toThrow(Panic); }); }); describe("tapAsync", () => { it("runs async side effect on Some", async () => { let captured = 0; const option = await Option.some(42).tapAsync(async (x) => { captured = x; }); expect(captured).toBe(42); expect(option.unwrap()).toBe(42); }); it("throws Panic when callback rejects", async () => { await expect( Option.some(1).tapAsync(async () => { throw new Error("tapAsync callback failed"); }), ).rejects.toBeInstanceOf(Panic); }); }); describe("filter", () => { it("returns Some when predicate passes", () => { const option = Option.some(5).filter((x) => x > 3); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toBe(5); }); it("returns None when predicate fails", () => { const option = Option.some(2).filter((x) => x > 3); expect(Option.isNone(option)).toBe(true); }); it("passes through None", () => { const option = Option.none().filter((x: number) => x > 3); expect(Option.isNone(option)).toBe(true); }); it("works as standalone function (data-first)", () => { const option = Option.filter(Option.some(5), (x) => x > 3); expect(option.unwrap()).toBe(5); }); it("works as standalone function (data-last)", () => { const filterPositive = Option.filter((x: number) => x > 0); const option = filterPositive(Option.some(5)); expect(option.unwrap()).toBe(5); }); it("throws Panic when predicate throws", () => { expect(() => Option.some(1).filter(() => { throw new Error("predicate failed"); }), ).toThrow(Panic); }); }); describe("gen (sync)", () => { it("composes multiple Options", () => { const getA = () => Option.some(1); const getB = (a: number) => Option.some(a + 1); const getC = (b: number) => Option.some(b + 1); const option = Option.gen(function* () { const a = yield* getA(); const b = yield* getB(a); const c = yield* getC(b); return Option.some(c); }); expect(option.unwrap()).toBe(3); }); it("short-circuits on first None", () => { let bCalled = false; const getA = (): Option => Option.none(); const getB = () => { bCalled = true; return Option.some(2); }; const option = Option.gen(function* () { const a = yield* getA(); const b = yield* getB(); return Option.some(a + b); }); expect(Option.isNone(option)).toBe(true); expect(bCalled).toBe(false); }); it("runs finally blocks when short-circuiting", () => { let finallyCalled = false; const getA = (): Option => Option.none(); const option = Option.gen(function* () { try { yield* getA(); return Option.some(1); } finally { finallyCalled = true; } }); expect(Option.isNone(option)).toBe(true); expect(finallyCalled).toBe(true); }); it("supports this binding", () => { const ctx = { multiplier: 10 }; const option = Option.gen(function* (this: typeof ctx) { const a = yield* Option.some(5); return Option.some(a * this.multiplier); }, ctx); expect(option.unwrap()).toBe(50); }); it("throws Panic when generator body throws", () => { expect(() => // oxlint-disable-next-line require-yield Option.gen(function* () { throw new Error("unexpected throw"); }), ).toThrow(Panic); }); it("throws Panic when finally block throws", () => { expect(() => Option.gen(function* () { try { yield* Option.none(); return Option.some(1); } finally { // oxlint-disable-next-line no-unsafe-finally throw new Error("cleanup failed"); } }), ).toThrow(Panic); }); }); describe("gen (async)", () => { it("composes async Options", async () => { const fetchA = () => Promise.resolve(Option.some(1)); const fetchB = (a: number) => Promise.resolve(Option.some(a + 1)); const option = await Option.gen(async function* () { const a = yield* Option.await(fetchA()); const b = yield* Option.await(fetchB(a)); return Option.some(b); }); expect(option.unwrap()).toBe(2); }); it("short-circuits on async None", async () => { let bCalled = false; const fetchA = (): Promise> => Promise.resolve(Option.none()); const fetchB = () => { bCalled = true; return Promise.resolve(Option.some(2)); }; const option = await Option.gen(async function* () { const a = yield* Option.await(fetchA()); const b = yield* Option.await(fetchB()); return Option.some(a + b); }); expect(Option.isNone(option)).toBe(true); expect(bCalled).toBe(false); }); it("throws Panic when async generator body throws", async () => { expect( // oxlint-disable-next-line require-yield Option.gen(async function* () { throw new Error("unexpected throw"); }), ).rejects.toBeInstanceOf(Panic); }); }); describe("serialize", () => { it("serializes Some to plain object", () => { const option = Option.some(42); const serialized = Option.serialize(option); expect(serialized).toEqual({ status: "some", value: 42 }); }); it("serializes None to plain object", () => { const option = Option.none(); const serialized = Option.serialize(option); expect(serialized).toEqual({ status: "none" }); }); it("serializes complex values", () => { const option = Option.some({ id: 1, name: "test", nested: { a: [1, 2, 3] } }); const serialized = Option.serialize(option); expect(serialized).toEqual({ status: "some", value: { id: 1, name: "test", nested: { a: [1, 2, 3] } }, }); }); }); describe("deserialize", () => { it("deserializes Some object to Some instance", () => { const serialized = { status: "some" as const, value: 42 }; const option = Option.deserialize(serialized); expect(option).not.toBe(null); expect(option).toBeInstanceOf(Some); expect(option?.unwrap()).toBe(42); }); it("deserializes None object to None instance", () => { const serialized = { status: "none" as const }; const option = Option.deserialize(serialized); expect(option).not.toBe(null); expect(option).toBe(None.instance); }); it("returns null for non-Option objects", () => { expect(Option.deserialize({ foo: "bar" })).toBe(null); expect(Option.deserialize(null)).toBe(null); expect(Option.deserialize(42)).toBe(null); expect(Option.deserialize({ status: "some" })).toBe(null); // missing value }); }); describe("serialize/deserialize roundtrip", () => { it("roundtrips Some", () => { const original = Option.some({ id: 42, name: "test" }); const serialized = Option.serialize(original); const deserialized = Option.deserialize<{ id: number; name: string }>(serialized); expect(deserialized).toBeInstanceOf(Some); expect(deserialized?.unwrap()).toEqual({ id: 42, name: "test" }); }); it("roundtrips None", () => { const original = Option.none(); const serialized = Option.serialize(original); const deserialized = Option.deserialize(serialized); expect(deserialized).toBe(None.instance); }); it("roundtrips through JSON.stringify/parse", () => { const original = Option.some({ id: 1, data: [1, 2, 3] }); const json = JSON.stringify(Option.serialize(original)); const parsed = JSON.parse(json); const deserialized = Option.deserialize<{ id: number; data: number[] }>(parsed); expect(deserialized?.unwrap()).toEqual({ id: 1, data: [1, 2, 3] }); }); }); describe("partition", () => { it("returns empty arrays for empty input", () => { expect(Option.partition([])).toEqual([[], 0]); }); it("collects all Some values when no None", () => { const options = [Option.some(1), Option.some(2), Option.some(3)]; expect(Option.partition(options)).toEqual([[1, 2, 3], 0]); }); it("counts all None when no Some", () => { const options = [Option.none(), Option.none()]; expect(Option.partition(options)).toEqual([[], 2]); }); it("splits mixed options preserving order", () => { const options = [Option.some(1), Option.none(), Option.some(2), Option.none()]; expect(Option.partition(options)).toEqual([[1, 2], 2]); }); }); describe("flatten", () => { it("flattens Some(Some(value)) to Some(value)", () => { const nested = Option.some(Option.some(42)); const flat = Option.flatten(nested); expect(Option.isSome(flat)).toBe(true); expect(flat.unwrap()).toBe(42); }); it("flattens Some(None) to None", () => { const nested = Option.some(Option.none()); const flat = Option.flatten(nested); expect(Option.isNone(flat)).toBe(true); }); it("flattens None to None", () => { const nested: Option> = Option.none(); const flat = Option.flatten(nested); expect(Option.isNone(flat)).toBe(true); }); }); describe("from", () => { it("converts non-null value to Some", () => { const option = Option.from(42); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toBe(42); }); it("converts null to None", () => { const option = Option.from(null); expect(Option.isNone(option)).toBe(true); }); it("converts undefined to None", () => { const option = Option.from(undefined); expect(Option.isNone(option)).toBe(true); }); it("converts 0 to Some (not falsy)", () => { const option = Option.from(0); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toBe(0); }); it("converts empty string to Some (not falsy)", () => { const option = Option.from(""); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toBe(""); }); it("converts false to Some (not falsy)", () => { const option = Option.from(false); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toBe(false); }); }); describe("fromPredicate", () => { it("returns Some when predicate passes", () => { const option = Option.fromPredicate(5, (x) => x > 3); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toBe(5); }); it("returns None when predicate fails", () => { const option = Option.fromPredicate(2, (x) => x > 3); expect(Option.isNone(option)).toBe(true); }); it("works as standalone function (data-last)", () => { const isPositive = Option.fromPredicate((x: number) => x > 0); expect(Option.isSome(isPositive(5))).toBe(true); expect(Option.isNone(isPositive(-1))).toBe(true); }); it("supports type guards for narrowing", () => { const isString = (x: unknown): x is string => typeof x === "string"; const option = Option.fromPredicate("hello" as unknown, isString); if (Option.isSome(option)) { // Type should be narrowed to string const value: string = option.value; expect(value).toBe("hello"); } }); it("throws Panic when predicate throws", () => { expect(() => Option.fromPredicate(1, () => { throw new Error("predicate failed"); }), ).toThrow(Panic); }); }); describe("zip", () => { it("returns Some([a, b]) when both are Some", () => { const option = Option.zip(Option.some(1), Option.some("a")); expect(Option.isSome(option)).toBe(true); expect(option.unwrap()).toEqual([1, "a"]); }); it("returns None when first is None", () => { const option = Option.zip(Option.none(), Option.some("a")); expect(Option.isNone(option)).toBe(true); }); it("returns None when second is None", () => { const option = Option.zip(Option.some(1), Option.none()); expect(Option.isNone(option)).toBe(true); }); it("returns None when both are None", () => { const option = Option.zip(Option.none(), Option.none()); expect(Option.isNone(option)).toBe(true); }); }); describe("unwrapOrElse", () => { it("returns value for Some (fallback not called)", () => { let called = false; const value = Option.unwrapOrElse(Option.some(42), () => { called = true; return 0; }); expect(value).toBe(42); expect(called).toBe(false); }); it("computes fallback for None", () => { const value = Option.unwrapOrElse(Option.none(), () => "default"); expect(value).toBe("default"); }); it("works as standalone function (data-last)", () => { const withDefault = Option.unwrapOrElse(() => 0); expect(withDefault(Option.some(42))).toBe(42); expect(withDefault(Option.none())).toBe(0); }); it("throws Panic when fallback throws", () => { expect(() => Option.unwrapOrElse(Option.none(), () => { throw new Error("fallback failed"); }), ).toThrow(Panic); }); }); }); describe("Functor Laws", () => { describe("Identity", () => { it("holds for Some", () => { const m = Option.some(42); const result = m.map((x) => x); expect(result.unwrap()).toBe(m.unwrap()); }); it("holds for None", () => { const m = Option.none(); const result = m.map((x: number) => x); expect(Option.isNone(result)).toBe(true); }); }); describe("Composition", () => { const f = (x: number) => x * 2; const g = (x: number) => x + 10; it("holds for Some", () => { const m = Option.some(5); const left = m.map((x) => g(f(x))); const right = m.map(f).map(g); expect(left.unwrap()).toBe(right.unwrap()); expect(left.unwrap()).toBe(20); // (5 * 2) + 10 }); it("holds for None", () => { const m: Option = Option.none(); const left = m.map((x) => g(f(x))); const right = m.map(f).map(g); expect(Option.isNone(left)).toBe(true); expect(Option.isNone(right)).toBe(true); }); }); }); describe("Monad Laws", () => { const f = (x: number): Option => Option.some(x * 2); const g = (x: number): Option => Option.some(x + 10); describe("Left Identity", () => { it("holds for Some", () => { const a = 5; const left = Option.some(a).andThen(f); const right = f(a); expect(left.unwrap()).toBe(right.unwrap()); }); }); describe("Right Identity", () => { it("holds for Some", () => { const m = Option.some(42); const result = m.andThen(Option.some); expect(result.unwrap()).toBe(m.unwrap()); }); it("holds for None", () => { const m: Option = Option.none(); const result = m.andThen(Option.some); expect(Option.isNone(result)).toBe(true); }); }); describe("Associativity", () => { it("holds for Some", () => { const m = Option.some(5); const left = m.andThen(f).andThen(g); const right = m.andThen((x) => f(x).andThen(g)); expect(left.unwrap()).toBe(right.unwrap()); expect(left.unwrap()).toBe(20); // (5 * 2) + 10 }); it("holds for None (short-circuits consistently)", () => { const m: Option = Option.none(); const left = m.andThen(f).andThen(g); const right = m.andThen((x) => f(x).andThen(g)); expect(Option.isNone(left)).toBe(true); expect(Option.isNone(right)).toBe(true); }); it("holds when f returns None", () => { const fNone = (): Option => Option.none(); const m = Option.some(5); const left = m.andThen(fNone).andThen(g); const right = m.andThen(() => fNone().andThen(g)); expect(Option.isNone(left)).toBe(true); expect(Option.isNone(right)).toBe(true); }); }); }); describe("Type Inference", () => { describe("unwrapOr type widening", () => { it("unwrapOr with different fallback type widens to union", () => { const o: Option = Option.none(); const value: number | string = o.unwrapOr("fallback"); expect(value).toBe("fallback"); }); it("unwrapOr with same type returns that type", () => { const o: Option = Option.none(); const value: number = o.unwrapOr(0); expect(value).toBe(0); }); }); describe("generic Option preservation", () => { it("generic function preserves type parameter through gen", () => { function identity(value: T): Option { return Option.gen(function* () { const x = yield* Option.some(value); return Option.some(x); }); } const strOption: Option = identity("hello"); const numOption: Option = identity(42); expect(strOption.unwrap()).toBe("hello"); expect(numOption.unwrap()).toBe(42); }); }); });