Skip to content
Open
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
105 changes: 105 additions & 0 deletions src/Either.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>(10)
const mapped = either.map((x) => x * 2)
expect(mapped.getOrElse(0)).toBe(20)
})

test("map passes through Left unchanged", () => {
const either = Either.Left<string, number>("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<string, number>("hello")
const mapped = either.mapLeft((s) => s.toUpperCase())
expect(mapped.toString()).toBe("Left(HELLO)")
})

test("mapLeft passes through Right unchanged", () => {
const either = Either.Right<string, number>(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<string, number>(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<string, number>("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<string, number>("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<string, number>(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<string, number>(7)
expect(either.getOrElse(0)).toBe(7)
})

test("getOrElse returns fallback when Left", () => {
const either = Either.Left<string, number>("nope")
expect(either.getOrElse(0)).toBe(0)
})

test("functor identity law: map(x => x) equals original", () => {
const either = Either.Right<string, number>(42)
const mapped = either.map((x) => x)
expect(mapped.getOrElse(0)).toBe(either.getOrElse(0))
})

test("toString returns Left(<value>) for Left", () => {
expect(Either.Left("abc").toString()).toBe("Left(abc)")
})

test("toString returns Right(<value>) for Right", () => {
expect(Either.Right(123).toString()).toBe("Right(123)")
})
})
98 changes: 98 additions & 0 deletions src/Either.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Container } from "./Container.ts"
import type { Mapper } from "./Mapper.ts"

interface LeftVariant<Left> {
left: Left
}

interface RightVariant<Right> {
right: Right
}

type $Either<Left, Right> = LeftVariant<Left> | RightVariant<Right>

/**
* 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<const Left, const Right> extends Container<
$Either<Left, Right>
> {
static Left<Left, Right>(value: Left): Either<Left, Right> {
return new Either({ left: value })
}
static Right<Left, Right>(value: Right): Either<Left, Right> {
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>).left)})`
}
return `Right(${String((this.value as RightVariant<Right>).right)})`
}
/**
* Maps over the Right value. Left values pass through unchanged.
* Either is Right-biased, so map targets the Right side.
*/
map<NewRight>(mapper: Mapper<Right, NewRight>): Either<Left, NewRight> {
if (this.isLeft()) {
return Either.Left((this.value as LeftVariant<Left>).left)
}
return Either.Right(mapper((this.value as RightVariant<Right>).right))
}
/**
* Maps over the Left value. Right values pass through unchanged.
*/
mapLeft<NewLeft>(mapper: Mapper<Left, NewLeft>): Either<NewLeft, Right> {
if (this.isRight()) {
return Either.Right((this.value as RightVariant<Right>).right)
}
return Either.Left(mapper((this.value as LeftVariant<Left>).left))
}
/**
* Chains a computation on the Right value without nesting Either.
*/
flatMap<NewRight>(
mapper: Mapper<Right, Either<Left, NewRight>>,
): Either<Left, NewRight> {
if (this.isLeft()) {
return Either.Left((this.value as LeftVariant<Left>).left)
}
return mapper((this.value as RightVariant<Right>).right)
}
/**
* Pattern matches on both sides, dispatching to the appropriate handler.
*/
match<Output>(
onLeft: Mapper<Left, Output>,
onRight: Mapper<Right, Output>,
): Output {
if (this.isLeft()) {
return onLeft((this.value as LeftVariant<Left>).left)
}
return onRight((this.value as RightVariant<Right>).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>).right
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading