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
82 changes: 82 additions & 0 deletions src/Monoid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"

import { Product, Str, Sum } from "./Semigroup.ts"
import {
ProductMonoid,
StrMonoid,
SumMonoid,
concatAll,
} from "./Monoid.ts"

describe("Monoid", () => {
describe("SumMonoid", () => {
test("empty() returns Sum(0)", () => {
expect(SumMonoid.empty().value).toBe(0)
})

test("satisfies right identity: a.concat(empty) === a", () => {
const a = new Sum(42)
expect(a.concat(SumMonoid.empty()).value).toBe(a.value)
})

test("satisfies left identity: empty.concat(a) === a", () => {
const a = new Sum(42)
expect(SumMonoid.empty().concat(a).value).toBe(a.value)
})
})

describe("ProductMonoid", () => {
test("empty() returns Product(1)", () => {
expect(ProductMonoid.empty().value).toBe(1)
})

test("satisfies right identity: a.concat(empty) === a", () => {
const a = new Product(7)
expect(a.concat(ProductMonoid.empty()).value).toBe(a.value)
})

test("satisfies left identity: empty.concat(a) === a", () => {
const a = new Product(7)
expect(ProductMonoid.empty().concat(a).value).toBe(a.value)
})
})

describe("StrMonoid", () => {
test("empty() returns Str('')", () => {
expect(StrMonoid.empty().value).toBe("")
})

test("satisfies right identity: a.concat(empty) === a", () => {
const a = new Str("hello")
expect(a.concat(StrMonoid.empty()).value).toBe(a.value)
})

test("satisfies left identity: empty.concat(a) === a", () => {
const a = new Str("hello")
expect(StrMonoid.empty().concat(a).value).toBe(a.value)
})
})

describe("concatAll", () => {
test("folds an array of Sum values", () => {
const values = [new Sum(1), new Sum(2), new Sum(3)]
expect(concatAll(SumMonoid, values).value).toBe(6)
})

test("folds an array of Product values", () => {
const values = [new Product(2), new Product(3), new Product(4)]
expect(concatAll(ProductMonoid, values).value).toBe(24)
})

test("folds an array of Str values", () => {
const values = [new Str("a"), new Str("b"), new Str("c")]
expect(concatAll(StrMonoid, values).value).toBe("abc")
})

test("returns identity for an empty array", () => {
expect(concatAll(SumMonoid, []).value).toBe(0)
expect(concatAll(ProductMonoid, []).value).toBe(1)
expect(concatAll(StrMonoid, []).value).toBe("")
})
})
})
59 changes: 59 additions & 0 deletions src/Monoid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Semigroup } from "./Semigroup.ts"
import { Product, Str, Sum } from "./Semigroup.ts"

/**
* A Monoid is a Semigroup with an identity element (`empty`).
* For any value a:
* a.concat(empty) === a (right identity)
* empty.concat(a) === a (left identity)
*/
export interface Monoid<T> extends Semigroup<T> {}

/**
* A constructor-like type that provides the identity element for a Monoid.
* Since TypeScript interfaces cannot enforce static methods, we use a
* separate type to represent the "companion object" of a Monoid.
*/
export type MonoidConstructor<T> = { empty(): T }

/**
* MonoidConstructor for Sum — identity element is 0.
*/
export const SumMonoid: MonoidConstructor<Sum> = {
empty(): Sum {
return new Sum(0)
},
}

/**
* MonoidConstructor for Product — identity element is 1.
*/
export const ProductMonoid: MonoidConstructor<Product> = {
empty(): Product {
return new Product(1)
},
}

/**
* MonoidConstructor for Str — identity element is the empty string.
*/
export const StrMonoid: MonoidConstructor<Str> = {
empty(): Str {
return new Str("")
},
}

/**
* Folds an array of monoidal values into a single value using the
* monoid's identity element as the starting accumulator.
* Returns `monoid.empty()` for an empty array.
*/
export function concatAll<T extends Semigroup<T>>(
monoid: MonoidConstructor<T>,
values: T[],
): T {
return values.reduce(
(acc, value) => acc.concat(value),
monoid.empty(),
)
}
65 changes: 65 additions & 0 deletions src/Semigroup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"

import { Product, Str, Sum } from "./Semigroup.ts"

describe("Semigroup", () => {
describe("Sum", () => {
test("should add values via concat", () => {
const a = new Sum(3)
const b = new Sum(5)
expect(a.concat(b).value).toBe(8)
})

test("should satisfy associativity law", () => {
const a = new Sum(1)
const b = new Sum(2)
const c = new Sum(3)
expect(a.concat(b).concat(c).value).toBe(a.concat(b.concat(c)).value)
})

test("should correctly render when apply toString()", () => {
const a = new Sum(42)
expect(a.toString()).toBe("Sum(42)")
})
})

describe("Product", () => {
test("should multiply values via concat", () => {
const a = new Product(3)
const b = new Product(5)
expect(a.concat(b).value).toBe(15)
})

test("should satisfy associativity law", () => {
const a = new Product(2)
const b = new Product(3)
const c = new Product(4)
expect(a.concat(b).concat(c).value).toBe(a.concat(b.concat(c)).value)
})

test("should correctly render when apply toString()", () => {
const a = new Product(7)
expect(a.toString()).toBe("Product(7)")
})
})

describe("Str", () => {
test("should concatenate strings via concat", () => {
const a = new Str("hello")
const b = new Str(" world")
expect(a.concat(b).value).toBe("hello world")
})

test("should satisfy associativity law", () => {
const a = new Str("foo")
const b = new Str("bar")
const c = new Str("baz")
expect(a.concat(b).concat(c).value).toBe(a.concat(b.concat(c)).value)
})

test("should correctly render when apply toString()", () => {
const a = new Str("hello")
expect(a.toString()).toBe("Str(hello)")
})
})
})
79 changes: 79 additions & 0 deletions src/Semigroup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* A Semigroup is a type with an associative binary operation (`concat`).
* For any values a, b, c: a.concat(b).concat(c) === a.concat(b.concat(c))
*/
export interface Semigroup<T> {
concat(other: T): T
}

/**
* A Semigroup under addition.
* Wraps a number and combines by adding values together.
*/
export class Sum implements Semigroup<Sum> {
readonly #value: number

constructor(value: number) {
this.#value = value
}

get value(): number {
return this.#value
}

concat(other: Sum): Sum {
return new Sum(this.#value + other.value)
}

toString(): string {
return `Sum(${this.#value})`
}
}

/**
* A Semigroup under multiplication.
* Wraps a number and combines by multiplying values together.
*/
export class Product implements Semigroup<Product> {
readonly #value: number

constructor(value: number) {
this.#value = value
}

get value(): number {
return this.#value
}

concat(other: Product): Product {
return new Product(this.#value * other.value)
}

toString(): string {
return `Product(${this.#value})`
}
}

/**
* A Semigroup under string concatenation.
* Wraps a string and combines by concatenating values together.
*/
export class Str implements Semigroup<Str> {
readonly #value: string

constructor(value: string) {
this.#value = value
}

get value(): string {
return this.#value
}

concat(other: Str): Str {
return new Str(this.#value + other.value)
}

toString(): string {
return `Str(${this.#value})`
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@ export * from "./Mapper.ts"
export * from "./Monad.ts"
export * from "./Option.ts"
export * from "./Result.ts"
export * from "./Semigroup.ts"
export * from "./Monoid.ts"