diff --git a/src/Either.test.ts b/src/Either.test.ts new file mode 100644 index 0000000..a5a1a83 --- /dev/null +++ b/src/Either.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test" + +import { Either } from "./Either.ts" + +describe("Either", () => { + test("Left creates a Left variant", () => { + const either = Either.Left("hello") + expect(either.isLeft()).toBe(true) + expect(either.isRight()).toBe(false) + }) + + test("Right creates a Right variant", () => { + const either = Either.Right(42) + expect(either.isRight()).toBe(true) + expect(either.isLeft()).toBe(false) + }) + + test("isLeft returns true for Left", () => { + expect(Either.Left("x").isLeft()).toBe(true) + }) + + test("isRight returns true for Right", () => { + expect(Either.Right("x").isRight()).toBe(true) + }) + + test("map transforms the Right value", () => { + const either = Either.Right(10) + const mapped = either.map((x) => x * 2) + expect(mapped.getOrElse(0)).toBe(20) + }) + + test("map passes through Left unchanged", () => { + const either = Either.Left("error") + const mapped = either.map((x) => x * 2) + expect(mapped.isLeft()).toBe(true) + expect(mapped.toString()).toBe("Left(error)") + }) + + test("mapLeft transforms the Left value", () => { + const either = Either.Left("hello") + const mapped = either.mapLeft((s) => s.toUpperCase()) + expect(mapped.toString()).toBe("Left(HELLO)") + }) + + test("mapLeft passes through Right unchanged", () => { + const either = Either.Right(42) + const mapped = either.mapLeft((s) => s.toUpperCase()) + expect(mapped.isRight()).toBe(true) + expect(mapped.getOrElse(0)).toBe(42) + }) + + test("flatMap chains on Right", () => { + const either = Either.Right(5) + const result = either.flatMap((x) => Either.Right(x + 1)) + expect(result.getOrElse(0)).toBe(6) + }) + + test("flatMap passes through Left", () => { + const either = Either.Left("fail") + const result = either.flatMap((x) => Either.Right(x + 1)) + expect(result.isLeft()).toBe(true) + }) + + test("match dispatches to onLeft for Left", () => { + const either = Either.Left("left") + const output = either.match( + (l) => `Left: ${l}`, + (r) => `Right: ${r}`, + ) + expect(output).toBe("Left: left") + }) + + test("match dispatches to onRight for Right", () => { + const either = Either.Right(99) + const output = either.match( + (l) => `Left: ${l}`, + (r) => `Right: ${r}`, + ) + expect(output).toBe("Right: 99") + }) + + test("getOrElse returns Right value when Right", () => { + const either = Either.Right(7) + expect(either.getOrElse(0)).toBe(7) + }) + + test("getOrElse returns fallback when Left", () => { + const either = Either.Left("nope") + expect(either.getOrElse(0)).toBe(0) + }) + + test("functor identity law: map(x => x) equals original", () => { + const either = Either.Right(42) + const mapped = either.map((x) => x) + expect(mapped.getOrElse(0)).toBe(either.getOrElse(0)) + }) + + test("toString returns Left() for Left", () => { + expect(Either.Left("abc").toString()).toBe("Left(abc)") + }) + + test("toString returns Right() for Right", () => { + expect(Either.Right(123).toString()).toBe("Right(123)") + }) +}) diff --git a/src/Either.ts b/src/Either.ts new file mode 100644 index 0000000..813f2c9 --- /dev/null +++ b/src/Either.ts @@ -0,0 +1,98 @@ +import { Container } from "./Container.ts" +import type { Mapper } from "./Mapper.ts" + +interface LeftVariant { + left: Left +} + +interface RightVariant { + right: Right +} + +type $Either = LeftVariant | RightVariant + +/** + * Represents a disjunction: a value that is one of two possible types. + * + * Unlike Result, Either does not imply error semantics (success/failure). + * It is useful for any branching logic where both sides are equally valid + * (e.g., Left=string, Right=number). + * + * Either is Right-biased: map and flatMap operate on the Right value, + * leaving Left values unchanged. + * + * @extends Container + */ +export class Either extends Container< + $Either +> { + static Left(value: Left): Either { + return new Either({ left: value }) + } + static Right(value: Right): Either { + return new Either({ right: value }) + } + isLeft(): boolean { + return "left" in this.value + } + isRight(): boolean { + return "right" in this.value + } + override toString(): string { + if (this.isLeft()) { + return `Left(${String((this.value as LeftVariant).left)})` + } + return `Right(${String((this.value as RightVariant).right)})` + } + /** + * Maps over the Right value. Left values pass through unchanged. + * Either is Right-biased, so map targets the Right side. + */ + map(mapper: Mapper): Either { + if (this.isLeft()) { + return Either.Left((this.value as LeftVariant).left) + } + return Either.Right(mapper((this.value as RightVariant).right)) + } + /** + * Maps over the Left value. Right values pass through unchanged. + */ + mapLeft(mapper: Mapper): Either { + if (this.isRight()) { + return Either.Right((this.value as RightVariant).right) + } + return Either.Left(mapper((this.value as LeftVariant).left)) + } + /** + * Chains a computation on the Right value without nesting Either. + */ + flatMap( + mapper: Mapper>, + ): Either { + if (this.isLeft()) { + return Either.Left((this.value as LeftVariant).left) + } + return mapper((this.value as RightVariant).right) + } + /** + * Pattern matches on both sides, dispatching to the appropriate handler. + */ + match( + onLeft: Mapper, + onRight: Mapper, + ): Output { + if (this.isLeft()) { + return onLeft((this.value as LeftVariant).left) + } + return onRight((this.value as RightVariant).right) + } + /** + * Extracts the Right value, or returns the provided fallback if this is Left. + */ + getOrElse(fallback: Right): Right { + if (this.isLeft()) { + return fallback + } + return (this.value as RightVariant).right + } +} diff --git a/src/index.ts b/src/index.ts index 94cf7bf..af552e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from "./Applicative.ts" export * from "./Container.ts" +export * from "./Either.ts" export * from "./Curry.ts" export * from "./Functor.ts" export * from "./Identity.ts"