diff --git a/CHANGELOG.md b/CHANGELOG.md index b834226..186ba9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,101 +1,79 @@ # Changelog -All notable changes to the project are documented in this file. +All notable changes to this educational project are documented here. -## 0.3.0 — Typeclass Laws and Property-Based Testing +## 0.4.0 ### Added -- Reusable test-scope `EqLaws`, `OrdLaws`, `SemigroupLaws`, and `MonoidLaws`. -- jqwik 1.10.1 for property-based testing on the JUnit Platform. -- Property suites for Integer, String, LocalDate, List, and Person instances. -- Custom jqwik generators for dates, immutable lists, and `Person`. -- `UnlawfulInstancesTest`, demonstrating that integer subtraction implements the Java shape of a semigroup but violates associativity. -- `docs/LAWS.md` explaining executable algebraic contracts and property-based verification. -- Detailed milestone 0.3 implementation task list. +- sealed `Option` ADT with `Some` and `None`, +- sealed `Either` ADT with `Left` and `Right`, +- sealed `Validated` ADT with `Valid` and `Invalid`, +- minimal ADT operations (`map`, `fold` and branch helpers), +- safe factories for all ADTs, +- non-empty immutable error invariant for `Invalid`, +- `OptionInstances` with derived `Eq` and `Show`, +- `EitherInstances` with derived `Eq` and `Show`, +- `ValidatedInstances` with derived `Eq` and `Show`, +- unit tests for all ADTs and compositional instance factories, +- jqwik properties validating `Eq` laws for composed ADT instances, +- `AdtExample`, +- `docs/ADT.md`, +- extended roadmap covering HKT, higher-kinded typeclasses, effects and Tagless Final. ### Changed -- Project version advanced from `0.2.0` to `0.3.0`. -- `PersonInstances` names now include their typeclass family: `SHOW_COMPACT`, `SHOW_VERBOSE`, `EQ_ALL_FIELDS`, `EQ_NAME`, `ORD_AGE`, and `ORD_NAME`. -- Existing examples and unit tests were updated for the normalized instance names. -- README, architecture, algebra, typeclass documentation and roadmap now describe executable laws. -- `Semigroup` Javadoc no longer describes law verification as future work. +- project version updated from `0.3.0` to `0.4.0`, +- project description updated to include ADTs and compositional instances, +- README expanded with ADT examples and the post-0.4 roadmap, +- architecture documentation updated with the data layer and composed-instance flow, +- laws documentation extended to cover ADT equality properties, +- implementation task list rewritten for milestone 0.4. -### Removed +### Removed / intentionally omitted -- Ambiguous pre-0.3 `PersonInstances` constant names. No deprecated aliases were retained because the API is still pre-1.0. -- Stale milestone-0.2 documentation describing property-based law verification as future work. -- No generated build output, IDE state, obsolete scripts or unused compatibility shims are included. +- generated build output and temporary compilation files are not part of the milestone artifact, +- no obsolete helper scripts are retained, +- no compatibility aliases are added for pre-1.0 APIs, +- no Tagless Final code is introduced yet, +- no HKT encoding is introduced yet, +- no `Functor`, `Applicative` or `Monad` is introduced yet, +- no generic validation accumulation API is introduced before `Applicative` exists. ### Design notes -- Law helpers remain under `src/test` and therefore do not expand the production API. -- Laws compare algebraic results using explicit `Eq` instances rather than `Objects.equals`. -- Focused unit tests are retained alongside property tests; the two test styles serve complementary purposes. -- jqwik is test-only and does not leak into production dependencies. -- ADTs, HKT, `Functor`, `Applicative`, `Monad`, registries and automatic derivation remain outside milestone 0.3. +- ADTs are modeled with Java 21 sealed interfaces and records, +- variants reject invalid null state where appropriate, +- `Invalid` enforces at least one validation error, +- typeclass instances remain external to represented data types, +- composed instances receive their dependencies explicitly, +- law helpers remain test-only infrastructure, +- the project continues to follow SOLID, KISS, DRY and YAGNI. -## 0.2.0 — Basic Algebraic Typeclasses +## 0.3.0 ### Added -- `Eq` with derived inequality support. -- `Ord` extending `Eq` with comparison, ordering predicates, minimum and maximum. -- `Semigroup` representing associative combination. -- `Monoid` extending `Semigroup` with an identity element. -- Dedicated `ShowFunctions`, `EqFunctions`, `OrdFunctions`, `SemigroupFunctions`, and `MonoidFunctions` syntax helpers. -- Integer equality, ordering, addition monoid and multiplication monoid. -- String equality, case-sensitive and case-insensitive ordering, and concatenation monoid. -- `LocalDate` equality and chronological ordering. -- Immutable-style generic list concatenation monoid. -- Multiple `Eq` and `Ord` instances. -- Executable examples for equality, ordering, semigroup and monoid usage. -- Focused tests for all new typeclasses, syntax helpers and instance families. -- `docs/ALGEBRA.md`. -- Detailed milestone 0.2 implementation task list. +- executable laws for `Eq`, `Ord`, `Semigroup` and `Monoid`, +- jqwik property-based testing, +- lawful-instance property suites, +- intentionally unlawful semigroup example. -### Changed - -- Project version advanced from `0.1.1` to `0.2.0`. -- Generic helper architecture changed from one growing `TypeClassFunctions` class to one small syntax class per typeclass family. -- `ShowExample` now uses `ShowFunctions`. -- README now documents the complete basic algebra model and multiple-instance examples. -- Architecture, typeclass documentation and roadmap were updated for milestone 0.2. -- CI verification now runs `clean check javadoc` for Java 21, 25 and 27. - -### Removed - -- `TypeClassFunctions`, superseded by focused syntax helpers. -- The old aggregated tests from 0.1.1 that were replaced by smaller type-specific test classes. -- No obsolete pre-hardening scripts, generated build output or IDE files are included in the source distribution. - -### Design notes - -- Domain types remain unaware of their typeclass instances. -- Instance selection remains explicit; no registry, reflection or automatic resolution was introduced. -- `Ord` refines `Eq` and `Monoid` refines `Semigroup`. -- Algebraic laws were documented in 0.2 and are executable from milestone 0.3 onward. -- HKT, ADTs, `Functor`, `Applicative` and `Monad` remain out of scope for YAGNI reasons. - -## 0.1.1 — Foundation Hardening +## 0.2.0 ### Added -- Java 21 toolchain baseline. -- `Show` as the minimal typeclass example. -- Explicit dictionary-passing helper. -- Standard Show instances for Integer, String and LocalDate. -- Person example with compact and verbose rendering instances. -- Unit tests, JaCoCo reporting, Javadoc generation and GitHub Actions CI. -- Architecture, typeclass, roadmap and implementation documentation. -- Apache License 2.0. +- `Eq`, `Ord`, `Semigroup` and `Monoid`, +- standard typeclass instances, +- dedicated syntax helpers. + +## 0.1.1 ### Changed -- Reorganized the original prototype into coherent `core`, `instances` and `examples` packages. -- Replaced the placeholder GitLab README with project-specific documentation. +- hardened and documented the initial proof of concept, +- introduced the explicit `Show` model and dictionary passing. -## 0.1 — Original Prototype +## 0.1 -Initial repository state, subsequently treated as milestone 0.1. +Initial proof of concept. diff --git a/README.md b/README.md index 41b75d5..d31a885 100644 --- a/README.md +++ b/README.md @@ -1,246 +1,246 @@ # Java Typeclasses -Educational implementation of typeclass-oriented functional programming in modern Java. +An educational implementation of typeclass-oriented functional programming in modern Java. -Milestone **0.3.0** builds on the algebra introduced in 0.2 and adds **executable typeclass laws** verified with property-based tests. +Milestone **0.4.0** extends the algebra and executable laws introduced in earlier releases with algebraic data types (ADTs) and compositional typeclass instances. ## Goals -The project demonstrates how typeclass-style polymorphism can be expressed in Java while keeping domain models independent from behavior policies. It deliberately favors explicit mechanics over framework magic. +The project demonstrates how concepts commonly associated with Scala, Cats and Haskell can be represented explicitly in Java while keeping the implementation small and understandable. -Core design principles: +The project currently covers: -- **SOLID** — small abstractions with focused responsibilities; -- **KISS** — explicit instance passing instead of hidden resolution; -- **DRY** — reusable law helpers remove duplicated test logic; -- **YAGNI** — no HKT encoding, registry, reflection, annotations, `Functor` or `Monad` yet; -- **immutability by default** — examples and collection instances do not mutate caller-owned data. +- explicit typeclass instances, +- dictionary passing, +- multiple instances for the same domain type, +- `Eq`, `Ord`, `Show`, `Semigroup` and `Monoid`, +- executable algebraic laws, +- property-based testing with jqwik, +- sealed algebraic data types, +- composition of typeclass instances. -## Requirements +The project deliberately does **not** yet introduce higher-kinded type encoding, `Functor`, `Applicative`, `Monad`, effect types or Tagless Final. -- Java 21 or newer; -- Gradle 9.8.0 when regenerating/running the wrapper; -- JUnit 6.1.2; -- jqwik 1.10.1 for property-based testing. +## Requirements -## Typeclasses implemented +- Java 21 or newer, +- Gradle 9.8.0 through the project wrapper configuration. -```text -Show +CI is intended to verify the project on Java 21, 25 and 27. -Eq - ▲ - │ -Ord +## Build -Semigroup - ▲ - │ - Monoid +```bash +./gradlew clean check ``` -`Show` is independent. `Ord` refines `Eq`; `Monoid` refines `Semigroup`. +Generate Javadoc: -## Why laws matter +```bash +./gradlew javadoc +``` -Implementing an interface is not enough to create a valid algebraic instance. A `Semigroup` must be associative, and a `Monoid` must additionally have a left and right identity. Likewise, equality and ordering instances must satisfy their corresponding laws. +## Project structure -Milestone 0.3 verifies these properties systematically using reusable law functions and jqwik-generated values. +```text +src/main/java/io/codeswarm/typeclasses/ +├── algebra/ +│ ├── Monoid.java +│ └── Semigroup.java +├── core/ +│ ├── Eq.java +│ ├── Ord.java +│ └── Show.java +├── data/ +│ ├── Either.java +│ ├── Invalid.java +│ ├── Left.java +│ ├── None.java +│ ├── Option.java +│ ├── Right.java +│ ├── Some.java +│ ├── Valid.java +│ └── Validated.java +├── examples/ +├── instances/ +└── syntax/ +``` -Examples: +Reusable law helpers remain test infrastructure: ```text -Eq: - reflexivity - symmetry - transitivity +src/test/java/io/codeswarm/typeclasses/laws/ +``` -Ord: - reflexivity - sign antisymmetry - transitivity - equality consistency +This keeps the production API focused while the project is still pre-1.0. -Semigroup: - associativity +## Basic typeclass example -Monoid: - associativity - left identity - right identity +```java +Eq integerEq = IntegerInstances.EQ; +boolean same = integerEq.eqv(42, 42); ``` -See [`docs/LAWS.md`](docs/LAWS.md) for details. +Typeclass behavior remains separate from the represented type. A domain object does not have to implement the typeclass interface. -## Explicit instances +## Algebraic data types -Domain types do not implement their typeclasses: +### Option + +`Option` models presence or absence without exposing `null` as part of normal program flow. ```java -public record Person(String name, int age) { -} +Option result = + Option.some(21) + .map(value -> value * 2); ``` -Instead, behavior is external: +The ADT is sealed and has two variants: -```java -public static final Eq EQ_ALL_FIELDS = - (left, right) -> left.name().equals(right.name()) - && left.age() == right.age(); - -public static final Eq EQ_NAME = - (left, right) -> left.name().equalsIgnoreCase(right.name()); +```text +Option +├── Some +└── None ``` -This allows several valid interpretations for the same domain type. +### Either -## Multiple lawful monoids for the same Java type - -`Integer` has two useful monoids: +`Either` models two alternatives and is right-biased for `map`. ```java -IntegerInstances.ADDITION -IntegerInstances.MULTIPLICATION +Either result = + Either.right(21) + .map(value -> value * 2); ``` -The same algorithm can therefore produce different results solely through the supplied instance: +```text +Either +├── Left +└── Right +``` -```java -var numbers = List.of(1, 2, 3, 4); +### Validated -var sum = MonoidFunctions.combineAll(numbers, IntegerInstances.ADDITION); -var product = MonoidFunctions.combineAll(numbers, IntegerInstances.MULTIPLICATION); -``` +`Validated` models a successful value or one or more validation errors. -Results: +```java +Validated result = + Validated.invalid( + List.of("name is empty", "age is negative")); +``` ```text -10 -24 +Validated +├── Valid +└── Invalid ``` -## Property-based law verification +Milestone 0.4 stores multiple errors in `Invalid`, but intentionally does not yet define generic Applicative-based error accumulation. That belongs after `Applicative` exists in the typeclass hierarchy. + +## Compositional instances + +A major addition in 0.4 is the ability to derive a typeclass instance for a structured type from instances for its elements. -A reusable law is independent from any specific instance: +For example: ```java -public static boolean associative( - A first, - A second, - A third, - Semigroup semigroup, - Eq eq) { +Eq> optionEq = + OptionInstances.eq(IntegerInstances.EQ); +``` - var left = semigroup.combine(semigroup.combine(first, second), third); - var right = semigroup.combine(first, semigroup.combine(second, third)); +Conceptually: - return eq.eqv(left, right); -} +```text +Eq + │ + ▼ +Eq> ``` -jqwik then generates many values: +The same idea is implemented for `Either` and `Validated`: ```java -@Property -void additionIsALawfulMonoid( - @ForAll int first, - @ForAll int second, - @ForAll int third) { +Eq> eitherEq = + EitherInstances.eq( + StringInstances.EQ, + IntegerInstances.EQ); +``` + +and: - assertTrue(MonoidLaws.associative( - first, - second, - third, - IntegerInstances.ADDITION, - IntegerInstances.EQ)); -} +```java +Show> validatedShow = + ValidatedInstances.show( + StringInstances.SHOW, + IntegerInstances.SHOW); ``` -The tests also contain an intentionally unlawful subtraction semigroup to demonstrate that merely satisfying the Java interface is not sufficient. +This is an important bridge between simple typeclasses and the higher-kinded abstractions planned for later milestones. -## Packages +## Algebraic laws -```text -src/main/java/io/codeswarm/typeclasses/ -├── algebra/ -│ ├── Monoid.java -│ └── Semigroup.java -├── core/ -│ ├── Eq.java -│ ├── Ord.java -│ └── Show.java -├── examples/ -│ ├── Person.java -│ ├── PersonInstances.java -│ └── ... -├── instances/ -│ ├── IntegerInstances.java -│ ├── ListInstances.java -│ ├── LocalDateInstances.java -│ └── StringInstances.java -└── syntax/ - ├── EqFunctions.java - ├── MonoidFunctions.java - ├── OrdFunctions.java - ├── SemigroupFunctions.java - └── ShowFunctions.java - -src/test/java/io/codeswarm/typeclasses/ -├── laws/ -│ ├── EqLaws.java -│ ├── MonoidLaws.java -│ ├── OrdLaws.java -│ ├── SemigroupLaws.java -│ └── UnlawfulInstancesTest.java -├── properties/ -│ ├── IntegerInstancesProperties.java -│ ├── ListInstancesProperties.java -│ ├── LocalDateInstancesProperties.java -│ ├── PersonInstancesProperties.java -│ └── StringInstancesProperties.java -└── ... existing focused unit tests -``` - -The law framework intentionally lives under `src/test`. It validates this project but is not yet committed as public production API. +A Java implementation matching an interface is not automatically a lawful instance of the algebraic abstraction it represents. -## Build +Milestone 0.3 introduced executable laws for: -```bash -./gradlew clean check javadoc -``` +- `Eq`, +- `Ord`, +- `Semigroup`, +- `Monoid`. -`check` runs both normal JUnit tests and jqwik properties through the JUnit Platform. +Milestone 0.4 extends property testing to composed ADT equality instances. For example, if `Eq` is lawful, `OptionInstances.eq(IntegerInstances.EQ)` is verified against the `Eq` laws as well. -## CI +See [docs/LAWS.md](docs/LAWS.md). -GitHub Actions validates Java 21, 25 and 27 with: +## Design principles -```bash -./gradlew clean check javadoc +The project applies the following principles deliberately: + +- **SRP** — ADTs, typeclasses, instances, syntax helpers and laws have separate responsibilities. +- **OCP** — new instances can be added without modifying represented domain types. +- **DIP** — generic algorithms depend on typeclass interfaces, not concrete policies. +- **KISS** — each ADT exposes only operations needed by the current milestone. +- **DRY** — compositional instance logic is centralized in dedicated instance factories. +- **YAGNI** — no HKT encoding, Monad, effect runtime or Tagless Final is introduced before the required foundations exist. + +## Example + +Run `AdtExample` from the IDE or compile and execute it manually. It demonstrates `Option`, `Either`, `Validated` and derived `Show` instances. + +Expected output: + +```text +Some(42) +Right(42) +Invalid([name is empty, age is negative]) ``` ## Documentation -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) -- [`docs/TYPECLASSES.md`](docs/TYPECLASSES.md) -- [`docs/ALGEBRA.md`](docs/ALGEBRA.md) -- [`docs/LAWS.md`](docs/LAWS.md) -- [`docs/ROADMAP.md`](docs/ROADMAP.md) -- [`CHANGELOG.md`](CHANGELOG.md) +- [Architecture](docs/ARCHITECTURE.md) +- [Typeclasses](docs/TYPECLASSES.md) +- [Algebra](docs/ALGEBRA.md) +- [Algebraic laws](docs/LAWS.md) +- [Algebraic data types](docs/ADT.md) +- [Implementation tasks](docs/IMPLEMENTATION_TASKS.md) +- [Roadmap](docs/ROADMAP.md) +- [Changelog](CHANGELOG.md) -## Out of scope for 0.3 +## Roadmap -The following are deliberately deferred: +The planned sequence after 0.4 is: -- `Option`, `Either`, `Validated`; -- higher-kinded type encoding; -- `Functor`, `Applicative`, `Monad`; -- instance registry / `summon`; -- reflection-based resolution; -- annotation processing and automatic derivation. +```text +0.5 Higher-kinded type encoding: Kind +0.6 Functor / Applicative / Monad +0.7 Effect types and natural transformations +0.8 Tagless Final with an effect parameter F +0.9 Derivation / instance-resolution experiments and API hardening +1.0 Stable educational release +``` -These belong to later milestones after the basic algebra has a trustworthy law-tested foundation. +Tagless Final is intentionally delayed until the project can express an effect constructor and the typeclasses needed to program over it meaningfully. ## License -Apache License 2.0. +Apache License 2.0. See [LICENSE](LICENSE). diff --git a/build.gradle b/build.gradle index e32f727..34936e6 100644 --- a/build.gradle +++ b/build.gradle @@ -4,13 +4,13 @@ plugins { } group = 'io.codeswarm' -version = '0.3.0' +version = '0.4.0' -description = 'Educational implementation of typeclass-oriented programming, algebraic typeclasses, and executable algebraic laws in modern Java.' +description = 'Educational implementation of typeclass-oriented programming, algebraic data types, compositional typeclass instances, and executable algebraic laws in modern Java.' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } withSourcesJar() withJavadocJar() diff --git a/docs/ADT.md b/docs/ADT.md new file mode 100644 index 0000000..422f940 --- /dev/null +++ b/docs/ADT.md @@ -0,0 +1,102 @@ +# Algebraic Data Types + +Milestone 0.4 introduces three sealed algebraic data types implemented with Java 21 sealed interfaces and records. + +## Why ADTs? + +ADTs make the possible states of a value explicit in the type system. They avoid using `null`, magic sentinel values or exceptions for ordinary branching. + +The milestone deliberately keeps each ADT small. Generic higher-kinded abstractions are deferred until later milestones. + +## Option + +`Option` represents an optional value: + +```text +Option = Some | None +``` + +Supported operations: + +- `map`, +- `fold`, +- `isDefined`, +- `isEmpty`, +- `getOrElse`, +- factories `some`, `none`, `fromNullable`. + +`Some` rejects `null`. `None` carries no value. + +## Either + +`Either` represents one of two possible values: + +```text +Either = Left | Right +``` + +By convention the right branch represents the successful path, so `map` is right-biased. `mapLeft` explicitly transforms the left branch. + +Supported operations: + +- `map`, +- `mapLeft`, +- `fold`, +- `isLeft`, +- `isRight`, +- factories `left` and `right`. + +`flatMap` is intentionally absent. It will become more meaningful when the project introduces `Monad` and can explain the relationship between a concrete method and the generic typeclass. + +## Validated + +`Validated` represents successful validation or one or more validation errors: + +```text +Validated = Valid | Invalid +``` + +`Invalid` requires a non-empty list and defensively copies it. + +Supported operations: + +- `map`, +- `fold`, +- `isValid`, +- `isInvalid`, +- factories `valid` and `invalid`. + +Although `Invalid` can store multiple errors, 0.4 does not yet implement generic error accumulation between independent validations. That behavior naturally belongs to an Applicative abstraction planned for milestone 0.6. + +## Compositional typeclass instances + +The `instances` package contains factories that derive instances for an ADT from instances for its components. + +Examples: + +```text +Eq -> Eq> +Show -> Show> +Eq + Eq -> Eq> +Show + Show -> Show> +Eq + Eq -> Eq> +Show + Show -> Show> +``` + +This is the first project milestone where typeclass instances are explicitly composed from other instances. + +## Why no Tagless Final in 0.4? + +ADTs solve a different problem from Tagless Final. ADTs model data and branching. Tagless Final abstracts programs over effect capabilities. + +Java cannot directly express Scala's `F[_]`. Introducing a pseudo-Tagless-Final API before an HKT encoding would either hard-code result types or hide the central idea. + +The planned progression is therefore: + +```text +ADTs + -> Kind + -> Functor / Applicative / Monad + -> effect types + -> Tagless Final +``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 534129c..fcdc54e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,114 +1,105 @@ -# Architecture — Milestone 0.3 +# Architecture ## Overview -Milestone 0.3 preserves the small production architecture from 0.2 and adds a separate verification layer in test scope. +Milestone 0.4 keeps the project deliberately small and explicit. The production code is divided by responsibility: ```text - Domain types - │ - │ described by - ▼ - ┌─────────────────────┐ - │ Typeclasses │ - │ │ - │ Show │ - │ Eq <- Ord │ - │ Semigroup │ - │ ^ │ - │ │ │ - │ Monoid │ - └─────────┬───────────┘ - │ implemented as explicit values - ▼ - Instances - │ - │ exercised by - ┌─────────▼───────────┐ - │ Syntax / algorithms │ - └─────────────────────┘ - -Test verification layer: - -Instances + generated values - │ - ▼ - reusable Laws - │ - ▼ - jqwik properties +core/ basic typeclass contracts +algebra/ algebraic typeclasses +syntax/ generic helper functions +instances/ concrete and compositional instances +data/ algebraic data types +examples/ executable educational examples ``` -## Production packages +Reusable law definitions remain test infrastructure under `src/test/java/.../laws`. -### `core` +## Core relationships -Defines non-algebraic/general typeclasses: `Show`, `Eq`, and `Ord`. +```text +Show +Eq <--- Ord -### `algebra` +Semigroup <--- Monoid +``` -Defines `Semigroup` and `Monoid`. +Domain and data types do not implement these interfaces. Typeclass instances are separate values. -### `instances` +## ADT layer -Contains reusable instances for JDK types. +```text +Option +├── Some +└── None -### `examples` +Either +├── Left +└── Right -Contains the `Person` record, its external instances and executable examples. +Validated +├── Valid +└── Invalid +``` -### `syntax` +The sealed hierarchy makes variants exhaustive and explicit. -Contains small generic algorithms grouped by typeclass family. This avoids a growing utility god class. +## Compositional instances -## Test packages +Milestone 0.4 introduces instance factories that depend on component instances: -### `laws` +```text +Eq ----------------------> Eq> +Show --------------------> Show> -Contains reusable executable laws. They are deliberately test infrastructure rather than production API. +Eq + Eq -------------> Eq> +Show + Show ---------> Show> -### `properties` +Eq + Eq -------------> Eq> +Show + Show ---------> Show> +``` -Contains jqwik property suites applying the laws to concrete instances. +This dependency is explicit through method parameters; there is no global registry or implicit resolution mechanism. -### existing focused unit-test packages +## SOLID / KISS / DRY / YAGNI -Retain example-based tests for direct behavior, edge cases and readability. +### SRP -## Dependency direction +- ADTs model data alternatives. +- typeclasses describe capabilities/algebra. +- instance holders provide concrete interpretations. +- syntax classes provide reusable generic operations. +- law helpers verify algebraic contracts. -Production code never depends on test laws or jqwik: +### OCP -```text -core/algebra <- instances/examples/syntax +New instances can be added without modifying the represented ADT or domain type. -production code - ▲ - │ tested by -laws + properties + JUnit tests -``` +### DIP -## SOLID / KISS / DRY / YAGNI +Generic code depends on `Eq`, `Show`, `Semigroup`, `Monoid`, etc., not on particular concrete policies. -- **SRP:** each typeclass, syntax helper, law family and property suite has one purpose. -- **OCP:** new instances can be added without modifying domain models or generic algorithms. -- **DIP:** generic algorithms and laws depend on abstractions such as `Eq` and `Semigroup`. -- **KISS:** instance passing remains explicit; no resolver or reflection is introduced. -- **DRY:** algebraic laws are encoded once and reused by many property suites. -- **YAGNI:** law helpers remain test-only until there is a demonstrated need to publish them. +### KISS -## Naming rule for instance constants +ADT APIs expose only operations needed by this milestone. -When one holder exposes several typeclass families, names include the family: +### DRY -```text -SHOW_COMPACT -EQ_NAME -ORD_AGE -``` +Equality and rendering for structured values are implemented once per ADT in instance factories. Law logic from 0.3 is reused unchanged. + +### YAGNI + +The milestone intentionally excludes: + +- HKT encoding, +- `Functor`, `Applicative`, `Monad`, +- generic `flatMap` abstractions, +- effect types, +- Tagless Final, +- reflection-based instance resolution. -This scales better than ambiguous names such as `BY_NAME`. +These concepts are sequenced later in the roadmap. -## Null policy +## Why Tagless Final is later -The project still does not impose one global null policy. Instances are tested with their intended non-null domains. Null-handling semantics may be introduced later only if there is a concrete use case. +Tagless Final becomes substantially more meaningful once Java code can represent an effect constructor `F` using `Kind`, define higher-kinded typeclasses and provide effect interpreters. Introducing it before those layers would either hard-code effects or obscure the concept. diff --git a/docs/LAWS.md b/docs/LAWS.md index e0cb9ff..6401ab5 100644 --- a/docs/LAWS.md +++ b/docs/LAWS.md @@ -94,3 +94,15 @@ Integer subtraction is not associative: Milestone 0.3 uses laws as internal verification infrastructure. Publishing them as production API would create a compatibility commitment before the project has enough experience with their shape. A future milestone may extract them into a dedicated module if users need reusable law suites. + +## ADT equality laws in milestone 0.4 + +The law helpers introduced in 0.3 are reused to verify composed equality instances for `Option`, `Either` and `Validated`. + +The project checks the same `Eq` laws: + +- reflexivity, +- symmetry, +- transitivity. + +This demonstrates an important principle: composing an instance should preserve the algebraic contract when the component instances are lawful. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3d17e59..f8885fc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,61 +1,154 @@ # Roadmap -## 0.1 — Original prototype +## 0.1 — Initial proof of concept -Initial experiment proving that the typeclass pattern can be represented in Java. +Early exploration of typeclass-like patterns in Java. ## 0.1.1 — Foundation hardening -Modern build baseline, `Show`, explicit dictionary passing, external instances, tests, CI and documentation. +- Java 21 baseline, +- explicit `Show`, +- dictionary passing, +- separated instances, +- CI and documentation cleanup. ## 0.2 — Basic algebraic typeclasses -`Eq`, `Ord`, `Semigroup`, `Monoid`, multiple instances, immutable list concatenation and focused syntax helpers. +- `Eq`, +- `Ord`, +- `Semigroup`, +- `Monoid`, +- standard instances, +- dedicated syntax helpers. -## 0.3 — Typeclass laws — current +## 0.3 — Typeclass laws -- reusable `EqLaws`, `OrdLaws`, `SemigroupLaws`, `MonoidLaws` in test scope; -- jqwik property-based testing; -- properties for Integer, String, LocalDate, List and Person instances; -- explicit demonstration of an unlawful semigroup; -- normalized Person instance naming; -- law-focused documentation. +- executable `Eq`, `Ord`, `Semigroup` and `Monoid` laws, +- jqwik property-based testing, +- lawful and intentionally unlawful examples. -## 0.4 — Algebraic data types +## 0.4 — Algebraic data types and compositional instances -Planned: +**Current milestone.** -- `Option`; -- `Either`; -- `Validated`; -- sealed interfaces and records; -- typeclass instances for the ADTs. +- `Option` / `Some` / `None`, +- `Either` / `Left` / `Right`, +- `Validated` / `Valid` / `Invalid`, +- `Eq` and `Show` instance composition, +- property tests for derived equality instances, +- ADT documentation. ## 0.5 — Higher-kinded type encoding -Planned: +Introduce an explicit encoding for Java's missing higher-kinded types: -- `Kind`; -- witness types; -- safe conversion conventions; -- documentation of Java's HKT limitation. +```text +Kind +``` + +Planned work: + +- `Kind` marker/encoding, +- witness types such as `OptionK`, `EitherK`, +- safe narrowing helpers isolated in one place, +- discussion of Java type erasure and limitations, +- laws/tests for the encoding boundary, +- adapters between existing concrete ADTs and their `Kind` representation. + +The goal is infrastructure, not new behavior. ## 0.6 — Functor, Applicative and Monad -Planned after HKT encoding is established. +Build the first higher-kinded typeclass hierarchy: + +```text +Functor + ^ +Applicative + ^ +Monad +``` + +Planned instances: + +- `Option`, +- right-biased `Either`, +- `Validated` for Applicative error accumulation where appropriate. + +Add corresponding laws and property tests. + +This milestone should also clarify why `Validated` is naturally Applicative but should not be treated as a fail-fast Monad when accumulating errors. + +## 0.7 — Effects and natural transformations + +Prepare the execution model needed by Tagless Final. + +Planned topics: + +- simple `Id`, +- lazy `Eval`, +- small educational `IO` effect, +- `FunctionK` / natural transformations, +- effect interpreters, +- stack-safety considerations where relevant, +- Monad instances and laws for supported effects. + +The goal is not to compete with Cats Effect or ZIO; it is to make effect polymorphism understandable in Java. + +## 0.8 — Tagless Final + +Introduce Tagless Final only after HKT and effects exist. + +Example direction: + +```java +interface UserRepository { + Kind find(UserId id); +} +``` + +and programs parameterized by an effect constructor: + +```text +algebra/capability interfaces + + +programs parameterized by F + + +multiple interpreters +``` + +Planned interpreters may include: + +- `Id` for pure deterministic tests, +- `Either` for explicit errors, +- `IO` for real side effects. -## 0.7 — Foldable and Traverse +Topics: -Planned generic folding and traversal abstractions. +- programs vs interpreters, +- dependency inversion without framework DI, +- testing without mocks, +- natural transformations between interpreters, +- comparison with Scala 3 / Cats Tagless Final. -## 0.8 — Instance resolution experiments +## 0.9 — Derivation, resolution and API hardening -Possible explicit registry / `summon` experiments kept outside the fundamental model. +Experiments that are useful only after the core model is stable: -## 0.9 — Automatic derivation +- derived `Eq` / `Show` for records, +- optional annotation-processing experiment, +- explicit instance registry / `summon` experiment if it remains educationally useful, +- naming and package stabilization, +- compatibility review, +- publication metadata. -Possible annotation-processing based derivation for selected typeclasses. +Any implicit-resolution mechanism must remain optional; explicit instances are the conceptual baseline. ## 1.0 — Stable educational release -A documented, law-tested API with a deliberate stability promise. +- stable public API, +- complete documentation, +- Java vs Scala/Cats comparison, +- examples spanning basic typeclasses through Tagless Final, +- law-checked standard instances, +- release/publishing workflow. diff --git a/docs/TYPECLASSES.md b/docs/TYPECLASSES.md index f21728a..2fb8710 100644 --- a/docs/TYPECLASSES.md +++ b/docs/TYPECLASSES.md @@ -52,3 +52,20 @@ This is a major difference from encoding behavior directly through inheritance o ## Current limitation Java has no native higher-kinded types. Milestone 0.3 intentionally does not attempt to solve that problem yet. HKT encoding is planned only after the first-order algebra and laws are stable. + +## Compositional instances in milestone 0.4 + +Milestone 0.4 adds the first instance constructors whose output type depends on another typeclass instance. + +For example: + +```java +Eq> optionEq = + OptionInstances.eq(IntegerInstances.EQ); +``` + +The important idea is that the project does not need to modify `Option` to give it equality semantics. Instead, equality for `Option` is derived from equality for `A`. + +The same pattern is used for `Either` and `Validated`. + +This is a stepping stone toward higher-kinded abstractions, but it remains ordinary Java generics in milestone 0.4. diff --git a/src/main/java/io/codeswarm/typeclasses/data/Either.java b/src/main/java/io/codeswarm/typeclasses/data/Either.java new file mode 100644 index 0000000..663d550 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Either.java @@ -0,0 +1,87 @@ +package io.codeswarm.typeclasses.data; + +import java.util.Objects; +import java.util.function.Function; + +/** + * Represents a value that is either a left value, commonly an error, or a right + * value, commonly a successful result. + * + *

The type is right-biased for {@link #map(Function)}: mapping transforms only + * the {@link Right} branch.

+ * + * @param left value type + * @param right value type + */ +public sealed interface Either permits Left, Right { + + /** + * Creates a left value. + * + * @param value left value + * @param left type + * @param right type + * @return left branch + */ + static Either left(final L value) { + return new Left<>(Objects.requireNonNull(value, "value")); + } + + /** + * Creates a right value. + * + * @param value right value + * @param left type + * @param right type + * @return right branch + */ + static Either right(final R value) { + return new Right<>(Objects.requireNonNull(value, "value")); + } + + /** + * Transforms the right branch while preserving the left branch. + * + * @param mapper mapping function + * @param new right type + * @return mapped either + */ + Either map(Function mapper); + + /** + * Transforms the left branch while preserving the right branch. + * + * @param mapper mapping function + * @param new left type + * @return mapped either + */ + Either mapLeft(Function mapper); + + /** + * Eliminates this ADT using one function for each branch. + * + * @param onLeft left-branch function + * @param onRight right-branch function + * @param result type + * @return branch result + */ + T fold(Function onLeft, Function onRight); + + /** + * Returns whether this value is a right branch. + * + * @return {@code true} for {@link Right} + */ + default boolean isRight() { + return fold(ignored -> false, ignored -> true); + } + + /** + * Returns whether this value is a left branch. + * + * @return {@code true} for {@link Left} + */ + default boolean isLeft() { + return !isRight(); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Invalid.java b/src/main/java/io/codeswarm/typeclasses/data/Invalid.java new file mode 100644 index 0000000..94456e0 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Invalid.java @@ -0,0 +1,47 @@ +package io.codeswarm.typeclasses.data; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Failed branch of {@link Validated}, containing one or more validation errors. + * + * @param errors immutable non-empty list of validation errors + * @param validation error type + * @param
successful value type + */ +public record Invalid(List errors) implements Validated { + + /** + * Creates an invalid validation result. + * + * @param errors non-empty error list + * @throws NullPointerException when the list or an element is {@code null} + * @throws IllegalArgumentException when the list is empty + */ + public Invalid { + Objects.requireNonNull(errors, "errors"); + if (errors.isEmpty()) { + throw new IllegalArgumentException("errors must not be empty"); + } + errors = List.copyOf(errors); + } + + /** {@inheritDoc} */ + @Override + public Validated map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Validated.invalid(errors); + } + + /** {@inheritDoc} */ + @Override + public T fold( + final Function, ? extends T> onInvalid, + final Function onValid) { + Objects.requireNonNull(onInvalid, "onInvalid"); + Objects.requireNonNull(onValid, "onValid"); + return onInvalid.apply(errors); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Left.java b/src/main/java/io/codeswarm/typeclasses/data/Left.java new file mode 100644 index 0000000..c73b50d --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Left.java @@ -0,0 +1,43 @@ +package io.codeswarm.typeclasses.data; + +import java.util.Objects; +import java.util.function.Function; + +/** + * Left branch of {@link Either}. + * + * @param value left value + * @param left type + * @param right type + */ +public record Left(L value) implements Either { + + /** Creates a left branch containing a non-null value. */ + public Left { + Objects.requireNonNull(value, "value"); + } + + /** {@inheritDoc} */ + @Override + public Either map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Either.left(value); + } + + /** {@inheritDoc} */ + @Override + public Either mapLeft(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Either.left(mapper.apply(value)); + } + + /** {@inheritDoc} */ + @Override + public T fold( + final Function onLeft, + final Function onRight) { + Objects.requireNonNull(onLeft, "onLeft"); + Objects.requireNonNull(onRight, "onRight"); + return onLeft.apply(value); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/None.java b/src/main/java/io/codeswarm/typeclasses/data/None.java new file mode 100644 index 0000000..c3fba18 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/None.java @@ -0,0 +1,30 @@ +package io.codeswarm.typeclasses.data; + +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Empty branch of {@link Option}. + * + * @param value type represented by the empty option + */ +public record None() implements Option { + + /** {@inheritDoc} */ + @Override + public Option map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Option.none(); + } + + /** {@inheritDoc} */ + @Override + public B fold( + final Supplier onNone, + final Function onSome) { + Objects.requireNonNull(onNone, "onNone"); + Objects.requireNonNull(onSome, "onSome"); + return onNone.get(); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Option.java b/src/main/java/io/codeswarm/typeclasses/data/Option.java new file mode 100644 index 0000000..f4d24b9 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Option.java @@ -0,0 +1,97 @@ +package io.codeswarm.typeclasses.data; + +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Represents an optional value without using {@code null} as part of the public API. + * + *

{@code Option} is a sealed algebraic data type with exactly two variants: + * {@link Some}, containing a value, and {@link None}, representing absence.

+ * + * @param
contained value type + */ +public sealed interface Option permits Some, None { + + /** + * Creates an option containing a non-null value. + * + * @param value value to wrap + * @param value type + * @return a {@link Some} + * @throws NullPointerException when {@code value} is {@code null} + */ + static Option some(final A value) { + return new Some<>(Objects.requireNonNull(value, "value")); + } + + /** + * Creates an empty option. + * + * @param value type + * @return an empty {@link None} + */ + static Option none() { + return new None<>(); + } + + /** + * Creates an option from a nullable reference. + * + * @param value nullable value + * @param value type + * @return {@link None} for {@code null}; otherwise {@link Some} + */ + static Option fromNullable(final A value) { + return value == null ? none() : some(value); + } + + /** + * Transforms a present value while preserving absence. + * + * @param mapper transformation applied to a present value + * @param resulting value type + * @return transformed option + */ + Option map(Function mapper); + + /** + * Eliminates this ADT by supplying one function for each variant. + * + * @param onNone computation used for an empty option + * @param onSome computation used for a present value + * @param resulting type + * @return result produced by the matching branch + */ + B fold(Supplier onNone, Function onSome); + + /** + * Returns whether this option contains a value. + * + * @return {@code true} for {@link Some}, otherwise {@code false} + */ + default boolean isDefined() { + return fold(() -> false, ignored -> true); + } + + /** + * Returns whether this option is empty. + * + * @return {@code true} for {@link None}, otherwise {@code false} + */ + default boolean isEmpty() { + return !isDefined(); + } + + /** + * Returns the contained value when present or computes a fallback. + * + * @param fallback fallback computation + * @return contained value or fallback value + */ + default A getOrElse(final Supplier fallback) { + Objects.requireNonNull(fallback, "fallback"); + return fold(fallback, Function.identity()); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Right.java b/src/main/java/io/codeswarm/typeclasses/data/Right.java new file mode 100644 index 0000000..46b6299 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Right.java @@ -0,0 +1,43 @@ +package io.codeswarm.typeclasses.data; + +import java.util.Objects; +import java.util.function.Function; + +/** + * Right branch of {@link Either}. + * + * @param value right value + * @param left type + * @param right type + */ +public record Right(R value) implements Either { + + /** Creates a right branch containing a non-null value. */ + public Right { + Objects.requireNonNull(value, "value"); + } + + /** {@inheritDoc} */ + @Override + public Either map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Either.right(mapper.apply(value)); + } + + /** {@inheritDoc} */ + @Override + public Either mapLeft(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Either.right(value); + } + + /** {@inheritDoc} */ + @Override + public T fold( + final Function onLeft, + final Function onRight) { + Objects.requireNonNull(onLeft, "onLeft"); + Objects.requireNonNull(onRight, "onRight"); + return onRight.apply(value); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Some.java b/src/main/java/io/codeswarm/typeclasses/data/Some.java new file mode 100644 index 0000000..063aa46 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Some.java @@ -0,0 +1,40 @@ +package io.codeswarm.typeclasses.data; + +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * Present-value branch of {@link Option}. + * + * @param value contained non-null value + * @param contained value type + */ +public record Some(A value) implements Option { + + /** + * Creates a present optional value. + * + * @param value contained value + */ + public Some { + Objects.requireNonNull(value, "value"); + } + + /** {@inheritDoc} */ + @Override + public Option map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Option.some(mapper.apply(value)); + } + + /** {@inheritDoc} */ + @Override + public B fold( + final Supplier onNone, + final Function onSome) { + Objects.requireNonNull(onNone, "onNone"); + Objects.requireNonNull(onSome, "onSome"); + return onSome.apply(value); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Valid.java b/src/main/java/io/codeswarm/typeclasses/data/Valid.java new file mode 100644 index 0000000..04186cb --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Valid.java @@ -0,0 +1,37 @@ +package io.codeswarm.typeclasses.data; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Successful branch of {@link Validated}. + * + * @param value successful value + * @param validation error type + * @param value type + */ +public record Valid(A value) implements Validated { + + /** Creates a successful validation branch. */ + public Valid { + Objects.requireNonNull(value, "value"); + } + + /** {@inheritDoc} */ + @Override + public Validated map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + return Validated.valid(mapper.apply(value)); + } + + /** {@inheritDoc} */ + @Override + public T fold( + final Function, ? extends T> onInvalid, + final Function onValid) { + Objects.requireNonNull(onInvalid, "onInvalid"); + Objects.requireNonNull(onValid, "onValid"); + return onValid.apply(value); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Validated.java b/src/main/java/io/codeswarm/typeclasses/data/Validated.java new file mode 100644 index 0000000..7fc0c40 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/Validated.java @@ -0,0 +1,80 @@ +package io.codeswarm.typeclasses.data; + +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Represents a validation result that is either valid or contains one or more + * validation errors. + * + *

This milestone intentionally models validation without introducing an + * Applicative typeclass. Error accumulation across independent computations is + * deferred until the project has a generic Applicative abstraction.

+ * + * @param validation error type + * @param
successful value type + */ +public sealed interface Validated permits Valid, Invalid { + + /** + * Creates a successful validation result. + * + * @param value validated value + * @param error type + * @param value type + * @return valid branch + */ + static Validated valid(final A value) { + return new Valid<>(Objects.requireNonNull(value, "value")); + } + + /** + * Creates a failed validation result from one or more errors. + * + * @param errors validation errors + * @param error type + * @param value type + * @return invalid branch + */ + static Validated invalid(final List errors) { + return new Invalid<>(errors); + } + + /** + * Transforms a valid value while preserving validation errors. + * + * @param mapper mapping function + * @param resulting value type + * @return mapped validation + */ + Validated map(Function mapper); + + /** + * Eliminates this ADT using one function for each branch. + * + * @param onInvalid invalid-branch function + * @param onValid valid-branch function + * @param result type + * @return branch result + */ + T fold(Function, ? extends T> onInvalid, Function onValid); + + /** + * Returns whether validation succeeded. + * + * @return {@code true} for {@link Valid} + */ + default boolean isValid() { + return fold(ignored -> false, ignored -> true); + } + + /** + * Returns whether validation failed. + * + * @return {@code true} for {@link Invalid} + */ + default boolean isInvalid() { + return !isValid(); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java b/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java new file mode 100644 index 0000000..e9b9f07 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java @@ -0,0 +1,36 @@ +package io.codeswarm.typeclasses.examples; + +import io.codeswarm.typeclasses.data.Either; +import io.codeswarm.typeclasses.data.Option; +import io.codeswarm.typeclasses.data.Validated; +import io.codeswarm.typeclasses.instances.EitherInstances; +import io.codeswarm.typeclasses.instances.IntegerInstances; +import io.codeswarm.typeclasses.instances.OptionInstances; +import io.codeswarm.typeclasses.instances.StringInstances; +import io.codeswarm.typeclasses.instances.ValidatedInstances; +import java.util.List; + +/** + * Demonstrates the algebraic data types introduced in milestone 0.4 and their + * compositional typeclass instances. + */ +public final class AdtExample { + + private AdtExample() { + throw new AssertionError("Example class must not be instantiated"); + } + + /** + * Runs a small demonstration of Option, Either and Validated. + * + */ + static void main() { + final Option option = Option.some(21).map(value -> value * 2); + final Either either = Either.right(21).map(value -> value * 2); + final Validated validated = Validated.invalid(List.of("name is empty", "age is negative")); + + System.out.println(OptionInstances.show(IntegerInstances.SHOW).show(option)); + System.out.println(EitherInstances.show(StringInstances.SHOW, IntegerInstances.SHOW).show(either)); + System.out.println(ValidatedInstances.show(StringInstances.SHOW, IntegerInstances.SHOW).show(validated)); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/instances/EitherInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/EitherInstances.java new file mode 100644 index 0000000..01f373a --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/instances/EitherInstances.java @@ -0,0 +1,59 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.core.Eq; +import io.codeswarm.typeclasses.core.Show; +import io.codeswarm.typeclasses.data.Either; +import java.util.Objects; + +/** + * Typeclass instances derived for {@link Either} values. + */ +public final class EitherInstances { + + private EitherInstances() { + throw new AssertionError("Instances holder must not be instantiated"); + } + + /** + * Derives equality for {@code Either} from equality instances for both + * branches. + * + * @param eqL equality for left values + * @param eqR equality for right values + * @param left type + * @param right type + * @return equality for either values + */ + public static Eq> eq( + final Eq eqL, + final Eq eqR) { + Objects.requireNonNull(eqL, "eqL"); + Objects.requireNonNull(eqR, "eqR"); + return (left, right) -> left.fold( + leftValue -> right.fold( + rightLeft -> eqL.eqv(leftValue, rightLeft), + ignored -> false), + leftValue -> right.fold( + ignored -> false, + rightValue -> eqR.eqv(leftValue, rightValue))); + } + + /** + * Derives a textual representation for {@code Either}. + * + * @param showL renderer for left values + * @param showR renderer for right values + * @param left type + * @param right type + * @return renderer for either values + */ + public static Show> show( + final Show showL, + final Show showR) { + Objects.requireNonNull(showL, "showL"); + Objects.requireNonNull(showR, "showR"); + return either -> either.fold( + value -> "Left(" + showL.show(value) + ")", + value -> "Right(" + showR.show(value) + ")"); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java new file mode 100644 index 0000000..2a39e17 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java @@ -0,0 +1,49 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.core.Eq; +import io.codeswarm.typeclasses.core.Show; +import io.codeswarm.typeclasses.data.None; +import io.codeswarm.typeclasses.data.Option; +import java.util.Objects; + +/** + * Typeclass instances derived for {@link Option} values. + */ +public final class OptionInstances { + + private OptionInstances() { + throw new AssertionError("Instances holder must not be instantiated"); + } + + /** + * Derives an equality instance for {@code Option} from an equality + * instance for {@code A}. + * + * @param eqA equality for contained values + * @param contained value type + * @return equality for options + */ + public static Eq> eq(final Eq eqA) { + Objects.requireNonNull(eqA, "eqA"); + return (left, right) -> left.fold( + () -> right instanceof None, + leftValue -> right.fold( + () -> false, + rightValue -> eqA.eqv(leftValue, rightValue))); + } + + /** + * Derives a textual representation for {@code Option} from a renderer + * for {@code A}. + * + * @param showA renderer for contained values + * @param contained value type + * @return renderer for options + */ + public static Show> show(final Show showA) { + Objects.requireNonNull(showA, "showA"); + return option -> option.fold( + () -> "None", + value -> "Some(" + showA.show(value) + ")"); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java new file mode 100644 index 0000000..9ea0e6c --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java @@ -0,0 +1,78 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.core.Eq; +import io.codeswarm.typeclasses.core.Show; +import io.codeswarm.typeclasses.data.Validated; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; + +/** + * Typeclass instances derived for {@link Validated} values. + */ +public final class ValidatedInstances { + + private ValidatedInstances() { + throw new AssertionError("Instances holder must not be instantiated"); + } + + /** + * Derives equality for validation values. Invalid branches are compared + * element-by-element and preserve error ordering. + * + * @param eqE equality for validation errors + * @param eqA equality for successful values + * @param error type + * @param success type + * @return equality for validated values + */ + public static Eq> eq( + final Eq eqE, + final Eq eqA) { + Objects.requireNonNull(eqE, "eqE"); + Objects.requireNonNull(eqA, "eqA"); + return (left, right) -> left.fold( + leftErrors -> right.fold( + rightErrors -> equalLists(leftErrors, rightErrors, eqE), + ignored -> false), + leftValue -> right.fold( + ignored -> false, + rightValue -> eqA.eqv(leftValue, rightValue))); + } + + /** + * Derives a textual representation for validation values. + * + * @param showE renderer for errors + * @param showA renderer for successful values + * @param error type + * @param success type + * @return renderer for validated values + */ + public static Show> show( + final Show showE, + final Show showA) { + Objects.requireNonNull(showE, "showE"); + Objects.requireNonNull(showA, "showA"); + return validated -> validated.fold( + errors -> "Invalid(" + errors.stream().map(showE::show).toList() + ")", + value -> "Valid(" + showA.show(value) + ")"); + } + + private static boolean equalLists( + final List left, + final List right, + final Eq eqE) { + if (left.size() != right.size()) { + return false; + } + final Iterator leftIterator = left.iterator(); + final Iterator rightIterator = right.iterator(); + while (leftIterator.hasNext()) { + if (!eqE.eqv(leftIterator.next(), rightIterator.next())) { + return false; + } + } + return true; + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java b/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java new file mode 100644 index 0000000..44f8952 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java @@ -0,0 +1,40 @@ +package io.codeswarm.typeclasses.data; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link Either}. */ +class EitherTest { + + /** Verifies right-biased mapping. */ + @Test + void shouldMapRight() { + final Either result = Either.right(21).map(value -> value * 2); + assertEquals(Either.right(42), result); + assertTrue(result.isRight()); + } + + /** Verifies that right mapping leaves the left branch unchanged. */ + @Test + void shouldPreserveLeftWhenMappingRight() { + final Either result = Either.left("boom").map(value -> value * 2); + assertEquals(Either.left("boom"), result); + assertTrue(result.isLeft()); + } + + /** Verifies explicit left mapping. */ + @Test + void shouldMapLeft() { + final Either result = Either.left(404).mapLeft(String::valueOf); + assertEquals(Either.left("404"), result); + } + + /** Verifies branch elimination. */ + @Test + void shouldFold() { + assertEquals("error: boom", Either.left("boom") + .fold(value -> "error: " + value, String::valueOf)); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java b/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java new file mode 100644 index 0000000..83985a8 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java @@ -0,0 +1,38 @@ +package io.codeswarm.typeclasses.data; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link Option}. */ +class OptionTest { + + /** Verifies mapping of a present value. */ + @Test + void shouldMapSome() { + assertEquals(Option.some(42), Option.some(21).map(value -> value * 2)); + } + + /** Verifies that mapping preserves absence. */ + @Test + void shouldPreserveNoneWhenMapping() { + assertEquals(Option.none(), Option.none().map(value -> value * 2)); + } + + /** Verifies nullable conversion. */ + @Test + void shouldCreateOptionFromNullableValue() { + assertTrue(Option.fromNullable(null).isEmpty()); + assertEquals(Option.some("value"), Option.fromNullable("value")); + } + + /** Verifies branch elimination and fallback handling. */ + @Test + void shouldFoldAndUseFallback() { + assertEquals("42", Option.some(42).fold(() -> "none", String::valueOf)); + assertEquals(7, Option.none().getOrElse(() -> 7)); + assertFalse(Option.some(1).isEmpty()); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java b/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java new file mode 100644 index 0000000..26e6651 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java @@ -0,0 +1,43 @@ +package io.codeswarm.typeclasses.data; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link Validated}. */ +class ValidatedTest { + + /** Verifies mapping of a successful validation. */ + @Test + void shouldMapValidValue() { + assertEquals(Validated.valid(42), Validated.valid(21).map(value -> value * 2)); + } + + /** Verifies that mapping preserves accumulated errors. */ + @Test + void shouldPreserveInvalidErrors() { + final Validated invalid = Validated.invalid(List.of("first", "second")); + assertEquals(Validated.invalid(List.of("first", "second")), invalid.map(value -> value * 2)); + assertTrue(invalid.isInvalid()); + } + + /** Verifies the invariant that invalid values contain at least one error. */ + @Test + void shouldRejectEmptyErrorList() { + assertThrows(IllegalArgumentException.class, () -> Validated.invalid(List.of())); + } + + /** Verifies that the invalid branch defensively copies its error list. */ + @Test + void shouldKeepErrorsImmutable() { + final List errors = new ArrayList<>(); + errors.add("first"); + final Invalid invalid = new Invalid<>(errors); + errors.add("second"); + assertEquals(List.of("first"), invalid.errors()); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/instances/EitherInstancesTest.java b/src/test/java/io/codeswarm/typeclasses/instances/EitherInstancesTest.java new file mode 100644 index 0000000..a200293 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/instances/EitherInstancesTest.java @@ -0,0 +1,29 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.data.Either; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link EitherInstances}. */ +class EitherInstancesTest { + + /** Verifies branch-aware equality derivation. */ + @Test + void shouldDeriveEquality() { + final var eq = EitherInstances.eq(StringInstances.EQ, IntegerInstances.EQ); + assertTrue(eq.eqv(Either.left("x"), Either.left("x"))); + assertTrue(eq.eqv(Either.right(42), Either.right(42))); + assertFalse(eq.eqv(Either.left("42"), Either.right(42))); + } + + /** Verifies branch-aware rendering. */ + @Test + void shouldDeriveShow() { + final var show = EitherInstances.show(StringInstances.SHOW, IntegerInstances.SHOW); + assertEquals("Left(error)", show.show(Either.left("error"))); + assertEquals("Right(42)", show.show(Either.right(42))); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/instances/OptionInstancesTest.java b/src/test/java/io/codeswarm/typeclasses/instances/OptionInstancesTest.java new file mode 100644 index 0000000..dbf071a --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/instances/OptionInstancesTest.java @@ -0,0 +1,29 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.data.Option; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link OptionInstances}. */ +class OptionInstancesTest { + + /** Verifies derived equality for present and absent options. */ + @Test + void shouldDeriveEquality() { + final var eq = OptionInstances.eq(IntegerInstances.EQ); + assertTrue(eq.eqv(Option.some(1), Option.some(1))); + assertTrue(eq.eqv(Option.none(), Option.none())); + assertFalse(eq.eqv(Option.some(1), Option.none())); + } + + /** Verifies derived rendering. */ + @Test + void shouldDeriveShow() { + final var show = OptionInstances.show(IntegerInstances.SHOW); + assertEquals("Some(42)", show.show(Option.some(42))); + assertEquals("None", show.show(Option.none())); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java b/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java new file mode 100644 index 0000000..6258d32 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java @@ -0,0 +1,30 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.data.Validated; +import java.util.List; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Unit tests for {@link ValidatedInstances}. */ +class ValidatedInstancesTest { + + /** Verifies equality for valid and invalid branches. */ + @Test + void shouldDeriveEquality() { + final var eq = ValidatedInstances.eq(StringInstances.EQ, IntegerInstances.EQ); + assertTrue(eq.eqv(Validated.valid(42), Validated.valid(42))); + assertTrue(eq.eqv(Validated.invalid(List.of("a", "b")), Validated.invalid(List.of("a", "b")))); + assertFalse(eq.eqv(Validated.invalid(List.of("a", "b")), Validated.invalid(List.of("b", "a")))); + } + + /** Verifies rendering for both validation branches. */ + @Test + void shouldDeriveShow() { + final var show = ValidatedInstances.show(StringInstances.SHOW, IntegerInstances.SHOW); + assertEquals("Valid(42)", show.show(Validated.valid(42))); + assertEquals("Invalid([bad, worse])", show.show(Validated.invalid(List.of("bad", "worse")))); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java b/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java new file mode 100644 index 0000000..00ee464 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java @@ -0,0 +1,66 @@ +package io.codeswarm.typeclasses.properties; + +import io.codeswarm.typeclasses.data.Either; +import io.codeswarm.typeclasses.data.Option; +import io.codeswarm.typeclasses.data.Validated; +import io.codeswarm.typeclasses.instances.EitherInstances; +import io.codeswarm.typeclasses.instances.IntegerInstances; +import io.codeswarm.typeclasses.instances.OptionInstances; +import io.codeswarm.typeclasses.instances.StringInstances; +import io.codeswarm.typeclasses.instances.ValidatedInstances; +import io.codeswarm.typeclasses.laws.EqLaws; +import java.util.List; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Property-based verification of composed {@code Eq} instances for the ADTs. */ +class AdtInstancesProperties { + + /** Verifies the equality laws for representative Option values. */ + @Property + void optionEqualityIsLawful(@ForAll final int first, @ForAll final int second, @ForAll final int third) { + final var eq = OptionInstances.eq(IntegerInstances.EQ); + final Option a = first % 3 == 0 ? Option.none() : Option.some(first); + final Option b = second % 3 == 0 ? Option.none() : Option.some(second); + final Option c = third % 3 == 0 ? Option.none() : Option.some(third); + assertTrue(EqLaws.reflexive(a, eq)); + assertTrue(EqLaws.symmetric(a, b, eq)); + assertTrue(EqLaws.transitive(a, b, c, eq)); + } + + /** Verifies the equality laws for representative Either values. */ + @Property + void eitherEqualityIsLawful(@ForAll final int first, @ForAll final int second, @ForAll final int third) { + final var eq = EitherInstances.eq(StringInstances.EQ, IntegerInstances.EQ); + final Either a = either(first); + final Either b = either(second); + final Either c = either(third); + assertTrue(EqLaws.reflexive(a, eq)); + assertTrue(EqLaws.symmetric(a, b, eq)); + assertTrue(EqLaws.transitive(a, b, c, eq)); + } + + /** Verifies the equality laws for representative Validated values. */ + @Property + void validatedEqualityIsLawful(@ForAll final int first, @ForAll final int second, @ForAll final int third) { + final var eq = ValidatedInstances.eq(StringInstances.EQ, IntegerInstances.EQ); + final Validated a = validated(first); + final Validated b = validated(second); + final Validated c = validated(third); + assertTrue(EqLaws.reflexive(a, eq)); + assertTrue(EqLaws.symmetric(a, b, eq)); + assertTrue(EqLaws.transitive(a, b, c, eq)); + } + + private static Either either(final int value) { + return value % 2 == 0 ? Either.right(value) : Either.left(Integer.toString(value)); + } + + private static Validated validated(final int value) { + return value % 2 == 0 + ? Validated.valid(value) + : Validated.invalid(List.of("error-" + value)); + } +}