From d35d709769ddbf503e30499a6e22d88abfbf0662 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 21:58:41 +0200 Subject: [PATCH 01/14] Updates gradle configuration --- build.gradle | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 34936e6..bef3db5 100644 --- a/build.gradle +++ b/build.gradle @@ -4,13 +4,13 @@ plugins { } group = 'io.codeswarm' -version = '0.4.0' +version = '0.4.1' -description = 'Educational implementation of typeclass-oriented programming, algebraic data types, compositional typeclass instances, and executable algebraic laws in modern Java.' +description = 'Educational implementation of typeclass-oriented programming, algebraic data types, smart constructors, domain invariants, compositional instances, and executable algebraic laws in modern Java.' java { toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion = JavaLanguageVersion.of(21) } withSourcesJar() withJavadocJar() From 1f09a4f94f7c848c7e4b23f812ce57560dfa769a Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:01:10 +0200 Subject: [PATCH 02/14] Updates ADT interoperability --- .../io/codeswarm/typeclasses/data/Either.java | 56 ++++++++ .../codeswarm/typeclasses/data/Invalid.java | 19 ++- .../typeclasses/data/NonEmptyList.java | 127 ++++++++++++++++++ .../io/codeswarm/typeclasses/data/Option.java | 17 +++ .../io/codeswarm/typeclasses/data/Valid.java | 3 +- .../codeswarm/typeclasses/data/Validated.java | 51 +++++-- .../typeclasses/data/EitherTest.java | 17 +++ .../typeclasses/data/NonEmptyListTest.java | 51 +++++++ .../typeclasses/data/OptionTest.java | 7 + .../typeclasses/data/ValidatedTest.java | 25 ++-- 10 files changed, 337 insertions(+), 36 deletions(-) create mode 100644 src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java create mode 100644 src/test/java/io/codeswarm/typeclasses/data/NonEmptyListTest.java diff --git a/src/main/java/io/codeswarm/typeclasses/data/Either.java b/src/main/java/io/codeswarm/typeclasses/data/Either.java index 663d550..233c4a1 100644 --- a/src/main/java/io/codeswarm/typeclasses/data/Either.java +++ b/src/main/java/io/codeswarm/typeclasses/data/Either.java @@ -1,7 +1,9 @@ package io.codeswarm.typeclasses.data; import java.util.Objects; +import java.util.Optional; import java.util.function.Function; +import java.util.function.Supplier; /** * Represents a value that is either a left value, commonly an error, or a right @@ -39,6 +41,60 @@ static Either right(final R value) { return new Right<>(Objects.requireNonNull(value, "value")); } + /** + * Creates a right value when {@code condition} is true and a left value + * otherwise. Suppliers are evaluated lazily. + * + * @param condition branch condition + * @param right right-value supplier + * @param left left-value supplier + * @param left type + * @param right type + * @return selected branch + */ + static Either cond( + final boolean condition, + final Supplier right, + final Supplier left) { + Objects.requireNonNull(right, "right"); + Objects.requireNonNull(left, "left"); + return condition ? Either.right(right.get()) : Either.left(left.get()); + } + + /** + * Converts a nullable value into an {@code Either}. + * + * @param value nullable right value + * @param ifNull left-value supplier used for {@code null} + * @param left type + * @param right type + * @return right for a non-null value, otherwise left + */ + static Either fromNullable( + final R value, + final Supplier ifNull) { + Objects.requireNonNull(ifNull, "ifNull"); + return value == null ? Either.left(ifNull.get()) : Either.right(value); + } + + /** + * Converts a JDK {@link Optional} into an {@code Either}. + * + * @param optional optional right value + * @param ifEmpty left-value supplier used for an empty optional + * @param left type + * @param right type + * @return right for a present value, otherwise left + */ + static Either fromOptional( + final Optional optional, + final Supplier ifEmpty) { + Objects.requireNonNull(optional, "optional"); + Objects.requireNonNull(ifEmpty, "ifEmpty"); + return optional.>map(Either::right) + .orElseGet(() -> Either.left(ifEmpty.get())); + } + /** * Transforms the right branch while preserving the left branch. * diff --git a/src/main/java/io/codeswarm/typeclasses/data/Invalid.java b/src/main/java/io/codeswarm/typeclasses/data/Invalid.java index 94456e0..c875b9e 100644 --- a/src/main/java/io/codeswarm/typeclasses/data/Invalid.java +++ b/src/main/java/io/codeswarm/typeclasses/data/Invalid.java @@ -1,31 +1,28 @@ 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 + *

The non-empty invariant is represented by {@link NonEmptyList} rather than + * checked dynamically in this record.

+ * + * @param errors non-empty validation errors * @param validation error type * @param successful value type */ -public record Invalid(List errors) implements Validated { +public record Invalid(NonEmptyList 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 + * @param errors non-empty error collection + * @throws NullPointerException when {@code errors} is null */ public Invalid { Objects.requireNonNull(errors, "errors"); - if (errors.isEmpty()) { - throw new IllegalArgumentException("errors must not be empty"); - } - errors = List.copyOf(errors); } /** {@inheritDoc} */ @@ -38,7 +35,7 @@ public Validated map(final Function mapper) { /** {@inheritDoc} */ @Override public T fold( - final Function, ? extends T> onInvalid, + final Function, ? extends T> onInvalid, final Function onValid) { Objects.requireNonNull(onInvalid, "onInvalid"); Objects.requireNonNull(onValid, "onValid"); diff --git a/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java b/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java new file mode 100644 index 0000000..450a22a --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java @@ -0,0 +1,127 @@ +package io.codeswarm.typeclasses.data; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * Immutable list that is guaranteed to contain at least one value. + * + *

The non-empty invariant is encoded structurally by storing the first + * element separately from the remaining tail. This avoids representing an + * invalid empty state and makes the type useful for validation errors and + * other algebraic structures that have no lawful empty value.

+ * + * @param head first element + * @param tail immutable remaining elements + * @param
element type + */ +public record NonEmptyList(A head, List tail) implements Iterable { + + /** + * Creates a non-empty list from a head and tail. + * + * @param head first element + * @param tail remaining elements + * @throws NullPointerException when the head, tail or a tail element is null + */ + public NonEmptyList { + Objects.requireNonNull(head, "head"); + Objects.requireNonNull(tail, "tail"); + tail = List.copyOf(tail); + } + + /** + * Creates a single-element non-empty list. + * + * @param value only element + * @param element type + * @return non-empty list containing {@code value} + */ + public static NonEmptyList one(final A value) { + return new NonEmptyList<>(Objects.requireNonNull(value, "value"), List.of()); + } + + /** + * Creates a non-empty list from a mandatory first value and optional rest. + * + * @param head first element + * @param rest remaining elements + * @param element type + * @return non-empty list + */ + @SafeVarargs + public static NonEmptyList of(final A head, final A... rest) { + Objects.requireNonNull(rest, "rest"); + final List tail = new ArrayList<>(rest.length); + for (final A value : rest) { + tail.add(Objects.requireNonNull(value, "rest element")); + } + return new NonEmptyList<>(head, tail); + } + + /** + * Returns all values as an immutable ordinary list. + * + * @return immutable list preserving element order + */ + public List toList() { + final List values = new ArrayList<>(1 + tail.size()); + values.add(head); + values.addAll(tail); + return List.copyOf(values); + } + + /** + * Returns the number of elements. + * + * @return size, always at least one + */ + public int size() { + return 1 + tail.size(); + } + + /** + * Transforms every element while preserving the non-empty invariant. + * + * @param mapper element mapping function + * @param resulting element type + * @return mapped non-empty list + */ + public NonEmptyList map(final Function mapper) { + Objects.requireNonNull(mapper, "mapper"); + final B mappedHead = Objects.requireNonNull(mapper.apply(head), "mapped head"); + final List mappedTail = tail.stream() + .map(mapper) + .map(value -> Objects.requireNonNull(value, "mapped tail element")) + .toList(); + return new NonEmptyList<>(mappedHead, mappedTail); + } + + /** + * Concatenates this list with another non-empty list. + * + * @param other list appended after this list + * @return concatenated non-empty list + */ + public NonEmptyList concat(final NonEmptyList other) { + Objects.requireNonNull(other, "other"); + final List newTail = new ArrayList<>(tail.size() + other.size()); + newTail.addAll(tail); + newTail.add(other.head()); + newTail.addAll(other.tail()); + return new NonEmptyList<>(head, newTail); + } + + /** + * Returns an iterator over the head followed by the tail. + * + * @return iterator preserving list order + */ + @Override + public Iterator iterator() { + return toList().iterator(); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/data/Option.java b/src/main/java/io/codeswarm/typeclasses/data/Option.java index f4d24b9..0040a6f 100644 --- a/src/main/java/io/codeswarm/typeclasses/data/Option.java +++ b/src/main/java/io/codeswarm/typeclasses/data/Option.java @@ -47,6 +47,23 @@ static Option fromNullable(final A value) { return value == null ? none() : some(value); } + /** + * Creates a present option only when the condition is true. + * + *

The supplier is evaluated lazily and only for the true branch.

+ * + * @param condition condition deciding whether a value is created + * @param supplier value supplier used when the condition is true + * @param
value type + * @return present or empty option + */ + static Option when( + final boolean condition, + final Supplier supplier) { + Objects.requireNonNull(supplier, "supplier"); + return condition ? some(supplier.get()) : none(); + } + /** * Transforms a present value while preserving absence. * diff --git a/src/main/java/io/codeswarm/typeclasses/data/Valid.java b/src/main/java/io/codeswarm/typeclasses/data/Valid.java index 04186cb..ebf6fc1 100644 --- a/src/main/java/io/codeswarm/typeclasses/data/Valid.java +++ b/src/main/java/io/codeswarm/typeclasses/data/Valid.java @@ -1,6 +1,5 @@ package io.codeswarm.typeclasses.data; -import java.util.List; import java.util.Objects; import java.util.function.Function; @@ -28,7 +27,7 @@ public Validated map(final Function mapper) { /** {@inheritDoc} */ @Override public T fold( - final Function, ? extends T> onInvalid, + final Function, ? extends T> onInvalid, final Function onValid) { Objects.requireNonNull(onInvalid, "onInvalid"); Objects.requireNonNull(onValid, "onValid"); diff --git a/src/main/java/io/codeswarm/typeclasses/data/Validated.java b/src/main/java/io/codeswarm/typeclasses/data/Validated.java index 7fc0c40..bdae70a 100644 --- a/src/main/java/io/codeswarm/typeclasses/data/Validated.java +++ b/src/main/java/io/codeswarm/typeclasses/data/Validated.java @@ -1,5 +1,6 @@ package io.codeswarm.typeclasses.data; +import java.util.ArrayList; import java.util.List; import java.util.Objects; import java.util.function.Function; @@ -8,9 +9,9 @@ * 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.

+ *

The invalid branch stores a {@link NonEmptyList}, making an empty failure + * state unrepresentable. Generic Applicative-based accumulation remains outside + * this milestone and is planned after the higher-kinded abstractions exist.

* * @param validation error type * @param
successful value type @@ -30,15 +31,47 @@ static Validated valid(final A value) { } /** - * Creates a failed validation result from one or more errors. + * Creates a failed validation result from a non-empty error collection. * - * @param errors validation errors + * @param errors non-empty validation errors * @param error type * @param value type * @return invalid branch */ - static Validated invalid(final List errors) { - return new Invalid<>(errors); + static Validated invalid(final NonEmptyList errors) { + return new Invalid<>(Objects.requireNonNull(errors, "errors")); + } + + /** + * Creates a failed validation result containing a single error. + * + * @param error validation error + * @param error type + * @param value type + * @return invalid branch + */ + static Validated invalid(final E error) { + return invalid(NonEmptyList.one(error)); + } + + /** + * Creates a failed validation result containing one required error and + * optional additional errors. + * + * @param first first validation error + * @param rest additional validation errors + * @param error type + * @param value type + * @return invalid branch + */ + @SafeVarargs + static Validated invalid(final E first, final E... rest) { + Objects.requireNonNull(rest, "rest"); + final List tail = new ArrayList<>(rest.length); + for (final E error : rest) { + tail.add(Objects.requireNonNull(error, "rest error")); + } + return invalid(new NonEmptyList<>(first, tail)); } /** @@ -58,7 +91,9 @@ static Validated invalid(final List errors) { * @param result type * @return branch result */ - T fold(Function, ? extends T> onInvalid, Function onValid); + T fold( + Function, ? extends T> onInvalid, + Function onValid); /** * Returns whether validation succeeded. diff --git a/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java b/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java index 44f8952..8542c41 100644 --- a/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java +++ b/src/test/java/io/codeswarm/typeclasses/data/EitherTest.java @@ -1,5 +1,6 @@ package io.codeswarm.typeclasses.data; +import java.util.Optional; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -37,4 +38,20 @@ void shouldFold() { assertEquals("error: boom", Either.left("boom") .fold(value -> "error: " + value, String::valueOf)); } + /** Verifies conditional construction. */ + @Test + void shouldConstructConditionally() { + assertEquals(Either.right(42), Either.cond(true, () -> 42, () -> "error")); + assertEquals(Either.left("error"), Either.cond(false, () -> 42, () -> "error")); + } + + /** Verifies nullable and Optional adapters. */ + @Test + void shouldAdaptJavaOptionalValues() { + assertEquals(Either.right(42), Either.fromNullable(42, () -> "missing")); + assertEquals(Either.left("missing"), Either.fromNullable(null, () -> "missing")); + assertEquals(Either.right(42), Either.fromOptional(Optional.of(42), () -> "missing")); + assertEquals(Either.left("missing"), Either.fromOptional(Optional.empty(), () -> "missing")); + } + } diff --git a/src/test/java/io/codeswarm/typeclasses/data/NonEmptyListTest.java b/src/test/java/io/codeswarm/typeclasses/data/NonEmptyListTest.java new file mode 100644 index 0000000..ced9864 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/data/NonEmptyListTest.java @@ -0,0 +1,51 @@ +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; + +/** Unit tests for {@link NonEmptyList}. */ +class NonEmptyListTest { + + /** Verifies construction and immutable list conversion. */ + @Test + void shouldCreateNonEmptyList() { + final NonEmptyList values = NonEmptyList.of(1, 2, 3); + assertEquals(List.of(1, 2, 3), values.toList()); + assertEquals(3, values.size()); + } + + /** Verifies that the tail is defensively copied. */ + @Test + void shouldCopyTail() { + final List tail = new ArrayList<>(List.of(2)); + final NonEmptyList values = new NonEmptyList<>(1, tail); + tail.add(3); + assertEquals(List.of(1, 2), values.toList()); + } + + /** Verifies mapping while preserving the non-empty invariant. */ + @Test + void shouldMapAllElements() { + assertEquals( + NonEmptyList.of(2, 4, 6), + NonEmptyList.of(1, 2, 3).map(value -> value * 2)); + } + + /** Verifies ordered concatenation. */ + @Test + void shouldConcatenate() { + assertEquals( + NonEmptyList.of(1, 2, 3, 4), + NonEmptyList.of(1, 2).concat(NonEmptyList.of(3, 4))); + } + + /** Verifies null rejection. */ + @Test + void shouldRejectNullHead() { + assertThrows(NullPointerException.class, () -> new NonEmptyList<>(null, List.of())); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java b/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java index 83985a8..6a0cf86 100644 --- a/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java +++ b/src/test/java/io/codeswarm/typeclasses/data/OptionTest.java @@ -35,4 +35,11 @@ void shouldFoldAndUseFallback() { assertEquals(7, Option.none().getOrElse(() -> 7)); assertFalse(Option.some(1).isEmpty()); } + /** Verifies conditional lazy construction. */ + @Test + void shouldCreateOptionWhenConditionIsTrue() { + assertEquals(Option.some(42), Option.when(true, () -> 42)); + assertEquals(Option.none(), Option.when(false, () -> 42)); + } + } diff --git a/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java b/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java index 26e6651..127bbfc 100644 --- a/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java +++ b/src/test/java/io/codeswarm/typeclasses/data/ValidatedTest.java @@ -1,11 +1,8 @@ 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}. */ @@ -20,24 +17,22 @@ void shouldMapValidValue() { /** 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)); + final Validated invalid = Validated.invalid("first", "second"); + assertEquals(Validated.invalid("first", "second"), invalid.map(value -> value * 2)); assertTrue(invalid.isInvalid()); } - /** Verifies the invariant that invalid values contain at least one error. */ + /** Verifies single-error convenience construction. */ @Test - void shouldRejectEmptyErrorList() { - assertThrows(IllegalArgumentException.class, () -> Validated.invalid(List.of())); + void shouldCreateSingleErrorInvalid() { + final Invalid invalid = (Invalid) Validated.invalid("error"); + assertEquals(NonEmptyList.one("error"), invalid.errors()); } - /** Verifies that the invalid branch defensively copies its error list. */ + /** Verifies that an invalid branch always contains at least one error structurally. */ @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()); + void shouldExposeNonEmptyErrors() { + final Invalid invalid = new Invalid<>(NonEmptyList.of("first", "second")); + assertEquals(2, invalid.errors().size()); } } From 599e1eb67f2cd78217ce45c31d3d6d28dcf9acef Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:05:14 +0200 Subject: [PATCH 03/14] Updates ADT interoperability --- src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java b/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java index 450a22a..ab10ad1 100644 --- a/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java +++ b/src/main/java/io/codeswarm/typeclasses/data/NonEmptyList.java @@ -5,6 +5,7 @@ import java.util.List; import java.util.Objects; import java.util.function.Function; +import java.util.stream.Collectors; /** * Immutable list that is guaranteed to contain at least one value. @@ -96,7 +97,7 @@ public NonEmptyList map(final Function mapper) { final List mappedTail = tail.stream() .map(mapper) .map(value -> Objects.requireNonNull(value, "mapped tail element")) - .toList(); + .collect(Collectors.toUnmodifiableList()); return new NonEmptyList<>(mappedHead, mappedTail); } From 70be4a85564d0f3f4fb3076ef2330afe21605ee7 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:07:11 +0200 Subject: [PATCH 04/14] Creates typed validation errors --- .../typeclasses/domain/BlankPersonName.java | 10 ++++++++++ .../typeclasses/domain/BlankUserId.java | 10 ++++++++++ .../codeswarm/typeclasses/domain/InvalidAge.java | 14 ++++++++++++++ .../typeclasses/domain/PersonNameTooLong.java | 16 ++++++++++++++++ .../typeclasses/domain/ValidationError.java | 16 ++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/BlankPersonName.java create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/BlankUserId.java create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/InvalidAge.java create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/PersonNameTooLong.java create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/ValidationError.java diff --git a/src/main/java/io/codeswarm/typeclasses/domain/BlankPersonName.java b/src/main/java/io/codeswarm/typeclasses/domain/BlankPersonName.java new file mode 100644 index 0000000..26d021f --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/BlankPersonName.java @@ -0,0 +1,10 @@ +package io.codeswarm.typeclasses.domain; + +/** Validation error indicating that a person's name was blank. */ +public record BlankPersonName() implements ValidationError { + /** {@inheritDoc} */ + @Override + public String message() { + return "Person name must not be blank"; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/domain/BlankUserId.java b/src/main/java/io/codeswarm/typeclasses/domain/BlankUserId.java new file mode 100644 index 0000000..16544db --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/BlankUserId.java @@ -0,0 +1,10 @@ +package io.codeswarm.typeclasses.domain; + +/** Validation error indicating that a user identifier was blank. */ +public record BlankUserId() implements ValidationError { + /** {@inheritDoc} */ + @Override + public String message() { + return "UserId must not be blank"; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/domain/InvalidAge.java b/src/main/java/io/codeswarm/typeclasses/domain/InvalidAge.java new file mode 100644 index 0000000..1694c35 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/InvalidAge.java @@ -0,0 +1,14 @@ +package io.codeswarm.typeclasses.domain; + +/** + * Validation error indicating that an age was outside the supported range. + * + * @param value rejected age + */ +public record InvalidAge(int value) implements ValidationError { + /** {@inheritDoc} */ + @Override + public String message() { + return "Age must be between 0 and 130: " + value; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/domain/PersonNameTooLong.java b/src/main/java/io/codeswarm/typeclasses/domain/PersonNameTooLong.java new file mode 100644 index 0000000..c042a78 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/PersonNameTooLong.java @@ -0,0 +1,16 @@ +package io.codeswarm.typeclasses.domain; + +/** + * Validation error indicating that a person's name exceeded the supported + * maximum length. + * + * @param actualLength rejected name length + * @param maximumLength maximum accepted length + */ +public record PersonNameTooLong(int actualLength, int maximumLength) implements ValidationError { + /** {@inheritDoc} */ + @Override + public String message() { + return "Person name length " + actualLength + " exceeds maximum " + maximumLength; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/domain/ValidationError.java b/src/main/java/io/codeswarm/typeclasses/domain/ValidationError.java new file mode 100644 index 0000000..42dd6a7 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/ValidationError.java @@ -0,0 +1,16 @@ +package io.codeswarm.typeclasses.domain; + +/** + * Closed hierarchy of validation failures used by the smart-constructor + * examples in milestone 0.4.1. + */ +public sealed interface ValidationError + permits BlankUserId, BlankPersonName, PersonNameTooLong, InvalidAge { + + /** + * Returns a human-readable description of this validation failure. + * + * @return validation message + */ + String message(); +} From a05a22685f334f2dc9bfdafe94257a613cdb8bea Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:07:59 +0200 Subject: [PATCH 05/14] Creates invariant-safe value objects --- .../io/codeswarm/typeclasses/domain/Age.java | 51 +++++++++++++++++ .../typeclasses/domain/PersonName.java | 56 +++++++++++++++++++ .../codeswarm/typeclasses/domain/UserId.java | 54 ++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/Age.java create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/PersonName.java create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/UserId.java diff --git a/src/main/java/io/codeswarm/typeclasses/domain/Age.java b/src/main/java/io/codeswarm/typeclasses/domain/Age.java new file mode 100644 index 0000000..ccb4cdd --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/Age.java @@ -0,0 +1,51 @@ +package io.codeswarm.typeclasses.domain; + +import io.codeswarm.typeclasses.data.Either; + +/** + * Validated age constrained to the inclusive range 0..130. + */ +public final class Age { + + /** Largest accepted age. */ + public static final int MAX_VALUE = 130; + + private final int value; + + private Age(final int value) { + this.value = value; + } + + /** + * Validates and creates an age. + * + * @param raw raw age + * @return right containing a valid age or left containing the error + */ + public static Either from(final int raw) { + return Either.cond( + raw >= 0 && raw <= MAX_VALUE, + () -> new Age(raw), + () -> new InvalidAge(raw)); + } + + /** Returns the validated age value. */ + public int value() { + return value; + } + + @Override + public boolean equals(final Object other) { + return this == other || other instanceof Age age && value == age.value; + } + + @Override + public int hashCode() { + return Integer.hashCode(value); + } + + @Override + public String toString() { + return "Age(" + value + ")"; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/domain/PersonName.java b/src/main/java/io/codeswarm/typeclasses/domain/PersonName.java new file mode 100644 index 0000000..92f0240 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/PersonName.java @@ -0,0 +1,56 @@ +package io.codeswarm.typeclasses.domain; + +import io.codeswarm.typeclasses.data.Either; +import java.util.Objects; + +/** + * Validated person name with a non-blank, bounded-length invariant. + */ +public final class PersonName { + + /** Maximum accepted person-name length. */ + public static final int MAX_LENGTH = 100; + + private final String value; + + private PersonName(final String value) { + this.value = value; + } + + /** + * Validates and creates a person name. + * + * @param raw raw name + * @return right containing a valid name or left containing the first error + */ + public static Either from(final String raw) { + if (raw == null || raw.isBlank()) { + return Either.left(new BlankPersonName()); + } + final String normalized = raw.trim(); + if (normalized.length() > MAX_LENGTH) { + return Either.left(new PersonNameTooLong(normalized.length(), MAX_LENGTH)); + } + return Either.right(new PersonName(normalized)); + } + + /** Returns the validated name. */ + public String value() { + return value; + } + + @Override + public boolean equals(final Object other) { + return this == other || other instanceof PersonName name && value.equals(name.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "PersonName(" + value + ")"; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/domain/UserId.java b/src/main/java/io/codeswarm/typeclasses/domain/UserId.java new file mode 100644 index 0000000..906d69a --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/UserId.java @@ -0,0 +1,54 @@ +package io.codeswarm.typeclasses.domain; + +import io.codeswarm.typeclasses.data.Either; +import java.util.Objects; + +/** + * Validated user identifier that cannot be blank when created through the + * public API. + */ +public final class UserId { + + private final String value; + + private UserId(final String value) { + this.value = value; + } + + /** + * Validates and creates a user identifier. + * + * @param raw raw identifier + * @return right containing a valid identifier or left containing the error + */ + public static Either from(final String raw) { + if (raw == null || raw.isBlank()) { + return Either.left(new BlankUserId()); + } + return Either.right(new UserId(raw.trim())); + } + + /** + * Returns the validated identifier value. + * + * @return identifier string + */ + public String value() { + return value; + } + + @Override + public boolean equals(final Object other) { + return this == other || other instanceof UserId userId && value.equals(userId.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return "UserId(" + value + ")"; + } +} From 528160394159126cdcc61cd149cb228ce6b64762 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:08:41 +0200 Subject: [PATCH 06/14] Creates composed domain object --- .../io/codeswarm/typeclasses/domain/User.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/User.java diff --git a/src/main/java/io/codeswarm/typeclasses/domain/User.java b/src/main/java/io/codeswarm/typeclasses/domain/User.java new file mode 100644 index 0000000..0ceaa53 --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/User.java @@ -0,0 +1,20 @@ +package io.codeswarm.typeclasses.domain; + +import java.util.Objects; + +/** + * Domain user composed exclusively from already validated value objects. + * + * @param id validated user identifier + * @param name validated person name + * @param age validated age + */ +public record User(UserId id, PersonName name, Age age) { + + /** Ensures that all domain components are present. */ + public User { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(age, "age"); + } +} From 98fa7dfaf8f899f2ed9f1f573801a2304b7b5ce3 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:09:49 +0200 Subject: [PATCH 07/14] Creates accumulated validation example --- .../typeclasses/domain/UserValidator.java | 61 +++++++++++++++++++ .../typeclasses/domain/UserValidatorTest.java | 29 +++++++++ 2 files changed, 90 insertions(+) create mode 100644 src/main/java/io/codeswarm/typeclasses/domain/UserValidator.java create mode 100644 src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java diff --git a/src/main/java/io/codeswarm/typeclasses/domain/UserValidator.java b/src/main/java/io/codeswarm/typeclasses/domain/UserValidator.java new file mode 100644 index 0000000..a84e5cf --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/domain/UserValidator.java @@ -0,0 +1,61 @@ +package io.codeswarm.typeclasses.domain; + +import io.codeswarm.typeclasses.data.Either; +import io.codeswarm.typeclasses.data.NonEmptyList; +import io.codeswarm.typeclasses.data.Validated; +import java.util.ArrayList; +import java.util.List; + +/** + * Domain-specific validator demonstrating error accumulation before a generic + * {@code Applicative} exists. + * + *

This class is intentionally concrete and small. Generic validation + * composition is deferred to the Applicative milestone.

+ */ +public final class UserValidator { + + private UserValidator() { + throw new AssertionError("Utility class must not be instantiated"); + } + + /** + * Validates all user fields and accumulates every independent failure. + * + * @param rawId raw user identifier + * @param rawName raw person name + * @param rawAge raw age + * @return valid user or non-empty accumulated validation errors + */ + public static Validated validate( + final String rawId, + final String rawName, + final int rawAge) { + final Either id = UserId.from(rawId); + final Either name = PersonName.from(rawName); + final Either age = Age.from(rawAge); + + final List errors = new ArrayList<>(); + id.fold(error -> { errors.add(error); return null; }, ignored -> null); + name.fold(error -> { errors.add(error); return null; }, ignored -> null); + age.fold(error -> { errors.add(error); return null; }, ignored -> null); + + if (!errors.isEmpty()) { + return Validated.invalid(toNonEmptyList(errors)); + } + + final UserId validId = id.fold(UserValidator::unexpected, value -> value); + final PersonName validName = name.fold(UserValidator::unexpected, value -> value); + final Age validAge = age.fold(UserValidator::unexpected, value -> value); + return Validated.valid(new User(validId, validName, validAge)); + } + + private static NonEmptyList toNonEmptyList(final List errors) { + final ValidationError head = errors.getFirst(); + return new NonEmptyList<>(head, errors.subList(1, errors.size())); + } + + private static
A unexpected(final ValidationError error) { + throw new IllegalStateException("Validated branch unexpectedly failed: " + error.message()); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java new file mode 100644 index 0000000..e690374 --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java @@ -0,0 +1,29 @@ +package io.codeswarm.typeclasses.domain; + +import io.codeswarm.typeclasses.data.NonEmptyList; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests accumulated domain validation. */ +class UserValidatorTest { + + /** Verifies successful creation of a fully validated user. */ + @Test + void shouldCreateValidUser() { + final var result = UserValidator.validate("user-1", "Alice", 42); + assertTrue(result.isValid()); + assertEquals("user-1", result.fold(errors -> "bad", user -> user.id().value())); + } + + /** Verifies accumulation of independent validation failures. */ + @Test + void shouldAccumulateAllErrors() { + final var result = UserValidator.validate("", "", -1); + assertTrue(result.isInvalid()); + assertEquals(3, Optional.ofNullable(result.fold(NonEmptyList::size, ignored -> 0)).get()); + } +} From c418573e2e44012c51a858353bd20be265ad5f36 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:10:50 +0200 Subject: [PATCH 08/14] Creates executable examples --- .../typeclasses/examples/AdtExample.java | 8 +++---- .../typeclasses/examples/EqExample.java | 3 ++- .../typeclasses/examples/MonoidExample.java | 3 ++- .../typeclasses/examples/OrdExample.java | 3 ++- .../examples/SemigroupExample.java | 3 ++- .../typeclasses/examples/ShowExample.java | 3 ++- .../examples/SmartConstructorExample.java | 22 +++++++++++++++++++ .../examples/ValidatedUserExample.java | 17 ++++++++++++++ 8 files changed, 53 insertions(+), 9 deletions(-) create mode 100644 src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java create mode 100644 src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java diff --git a/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java b/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java index e9b9f07..1196eee 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java @@ -8,10 +8,9 @@ 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 + * Demonstrates the algebraic data types introduced in milestone 0.4.x and their * compositional typeclass instances. */ public final class AdtExample { @@ -23,11 +22,12 @@ private AdtExample() { /** * Runs a small demonstration of Option, Either and Validated. * + * @param args ignored command-line arguments */ - static void main() { + public static void main(final String[] args) { 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")); + final Validated validated = Validated.invalid("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)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java b/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java index a54445e..1491b8b 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java @@ -12,8 +12,9 @@ private EqExample() { /** * Runs the equality example. * + * @param args command-line arguments; ignored */ - static void main() { + public static void main(final String[] args) { final var alice30 = new Person("Alice", 30); final var alice40 = new Person("ALICE", 40); System.out.println(EqFunctions.equal(alice30, alice40, PersonInstances.EQ_ALL_FIELDS)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java b/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java index 1ca724f..3799d5c 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java @@ -15,8 +15,9 @@ private MonoidExample() { /** * Runs the monoid example. * + * @param args command-line arguments; ignored */ - static void main() { + public static void main(final String[] args) { final var numbers = List.of(1, 2, 3, 4); System.out.println(MonoidFunctions.combineAll(numbers, IntegerInstances.ADDITION)); System.out.println(MonoidFunctions.combineAll(numbers, IntegerInstances.MULTIPLICATION)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java b/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java index d4605e7..660af7c 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java @@ -12,8 +12,9 @@ private OrdExample() { /** * Runs the ordering example. * + * @param args command-line arguments; ignored */ - static void main() { + public static void main(final String[] args) { final var alice = new Person("Alice", 35); final var bob = new Person("Bob", 27); System.out.println(OrdFunctions.min(alice, bob, PersonInstances.ORD_AGE)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java b/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java index 31594e4..bfba518 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java @@ -15,8 +15,9 @@ private SemigroupExample() { /** * Runs the semigroup example. * + * @param args command-line arguments; ignored */ - static void main() { + public static void main(final String[] args) { final Semigroup maximum = Integer::max; System.out.println(SemigroupFunctions.combineAll(List.of(4, 9, 2, 7), maximum)); } diff --git a/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java b/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java index 1ae0e23..ed32e20 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java @@ -16,8 +16,9 @@ private ShowExample() { /** * Runs the show example. * + * @param args command-line arguments; ignored */ - static void main() { + public static void main(final String[] args) { final var person = new Person("Alice", 30); System.out.println(ShowFunctions.show(person, PersonInstances.SHOW_COMPACT)); System.out.println(ShowFunctions.show(person, PersonInstances.SHOW_VERBOSE)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java b/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java new file mode 100644 index 0000000..9299f2d --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java @@ -0,0 +1,22 @@ +package io.codeswarm.typeclasses.examples; + +import io.codeswarm.typeclasses.domain.Age; +import io.codeswarm.typeclasses.domain.PersonName; +import io.codeswarm.typeclasses.domain.UserId; + +/** Demonstrates domain smart constructors that return explicit validation ADTs. */ +public final class SmartConstructorExample { + + private SmartConstructorExample() { + throw new AssertionError("Example class must not be instantiated"); + } + + /** Runs the smart-constructor example. */ + public static void main(final String[] args) { + System.out.println(UserId.from("user-42")); + System.out.println(UserId.from(" ")); + System.out.println(PersonName.from("Alice")); + System.out.println(Age.from(42)); + System.out.println(Age.from(-1)); + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java b/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java new file mode 100644 index 0000000..27b7cad --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java @@ -0,0 +1,17 @@ +package io.codeswarm.typeclasses.examples; + +import io.codeswarm.typeclasses.domain.UserValidator; + +/** Demonstrates domain validation with accumulated typed errors. */ +public final class ValidatedUserExample { + + private ValidatedUserExample() { + throw new AssertionError("Example class must not be instantiated"); + } + + /** Runs valid and invalid user-validation examples. */ + public static void main(final String[] args) { + System.out.println(UserValidator.validate("user-1", "Alice", 42)); + System.out.println(UserValidator.validate("", "", -10)); + } +} From d61b77da88b3286b90dd09a7271bd350c0ebf2a0 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:11:57 +0200 Subject: [PATCH 09/14] Hardens ADT's --- .../io/codeswarm/typeclasses/core/Show.java | 2 +- .../instances/LocalDateInstances.java | 4 +- .../instances/NonEmptyListInstances.java | 70 +++++++++++++++++++ .../instances/OptionInstances.java | 1 + .../instances/ValidatedInstances.java | 12 ++-- .../domain/SmartConstructorsTest.java | 33 +++++++++ .../instances/NonEmptyListInstancesTest.java | 36 ++++++++++ .../instances/ValidatedInstancesTest.java | 7 +- .../properties/AdtInstancesProperties.java | 2 +- .../NonEmptyListInstancesProperties.java | 38 ++++++++++ .../syntax/MonoidFunctionsTest.java | 4 +- .../syntax/SemigroupFunctionsTest.java | 2 +- 12 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 src/main/java/io/codeswarm/typeclasses/instances/NonEmptyListInstances.java create mode 100644 src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java create mode 100644 src/test/java/io/codeswarm/typeclasses/instances/NonEmptyListInstancesTest.java create mode 100644 src/test/java/io/codeswarm/typeclasses/properties/NonEmptyListInstancesProperties.java diff --git a/src/main/java/io/codeswarm/typeclasses/core/Show.java b/src/main/java/io/codeswarm/typeclasses/core/Show.java index 5e7e3cb..0c607a8 100644 --- a/src/main/java/io/codeswarm/typeclasses/core/Show.java +++ b/src/main/java/io/codeswarm/typeclasses/core/Show.java @@ -5,7 +5,7 @@ * textual representation. * *

{@code Show} demonstrates the central idea of the typeclass pattern: - * behavior is defined independently of the domain type. A domain class does + * behaviour is defined independently from the domain type. A domain class does * not have to implement this interface and more than one {@code Show} instance * may exist for the same type.

* diff --git a/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java index 1d361f5..f48a7ea 100644 --- a/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java +++ b/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java @@ -10,14 +10,14 @@ /** * Standard typeclass instances for {@link LocalDate} values. * - *

This class demonstrates that typeclass behavior can be supplied for a + *

This class demonstrates that typeclass behaviour can be supplied for a * JDK type without modifying or extending that type.

*/ public final class LocalDateInstances { /** Renders dates in ISO-8601 local-date format. */ public static final Show ISO_SHOW = - DateTimeFormatter.ISO_LOCAL_DATE::format; + date -> DateTimeFormatter.ISO_LOCAL_DATE.format(date); /** Compares dates using standard value equality. */ public static final Eq EQ = LocalDate::equals; diff --git a/src/main/java/io/codeswarm/typeclasses/instances/NonEmptyListInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/NonEmptyListInstances.java new file mode 100644 index 0000000..093e6bb --- /dev/null +++ b/src/main/java/io/codeswarm/typeclasses/instances/NonEmptyListInstances.java @@ -0,0 +1,70 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.algebra.Semigroup; +import io.codeswarm.typeclasses.core.Eq; +import io.codeswarm.typeclasses.core.Show; +import io.codeswarm.typeclasses.data.NonEmptyList; +import java.util.Iterator; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * Typeclass instances for {@link NonEmptyList} derived from element instances. + */ +public final class NonEmptyListInstances { + + private NonEmptyListInstances() { + throw new AssertionError("Instances holder must not be instantiated"); + } + + /** + * Derives equality for non-empty lists from equality for their elements. + * + * @param eqA element equality + * @param
element type + * @return list equality preserving order + */ + public static Eq> eq(final Eq eqA) { + Objects.requireNonNull(eqA, "eqA"); + return (left, right) -> { + if (left.size() != right.size()) { + return false; + } + final Iterator leftIterator = left.iterator(); + final Iterator rightIterator = right.iterator(); + while (leftIterator.hasNext()) { + if (!eqA.eqv(leftIterator.next(), rightIterator.next())) { + return false; + } + } + return true; + }; + } + + /** + * Derives a textual representation for non-empty lists. + * + * @param showA element renderer + * @param element type + * @return non-empty-list renderer + */ + public static Show> show(final Show showA) { + Objects.requireNonNull(showA, "showA"); + return values -> values.toList().stream() + .map(showA::show) + .collect(Collectors.joining(", ", "NonEmptyList(", ")")); + } + + /** + * Returns the natural semigroup for non-empty lists: concatenation. + * + *

There is intentionally no corresponding monoid because a non-empty + * list has no lawful empty identity value.

+ * + * @param
element type + * @return concatenation semigroup + */ + public static Semigroup> concatenation() { + return NonEmptyList::concat; + } +} diff --git a/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java index 2a39e17..2fce08b 100644 --- a/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java +++ b/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java @@ -4,6 +4,7 @@ import io.codeswarm.typeclasses.core.Show; import io.codeswarm.typeclasses.data.None; import io.codeswarm.typeclasses.data.Option; +import io.codeswarm.typeclasses.data.Some; import java.util.Objects; /** diff --git a/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java index 9ea0e6c..87511bb 100644 --- a/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java +++ b/src/main/java/io/codeswarm/typeclasses/instances/ValidatedInstances.java @@ -2,9 +2,9 @@ import io.codeswarm.typeclasses.core.Eq; import io.codeswarm.typeclasses.core.Show; +import io.codeswarm.typeclasses.data.NonEmptyList; import io.codeswarm.typeclasses.data.Validated; import java.util.Iterator; -import java.util.List; import java.util.Objects; /** @@ -33,7 +33,7 @@ public static Eq> eq( Objects.requireNonNull(eqA, "eqA"); return (left, right) -> left.fold( leftErrors -> right.fold( - rightErrors -> equalLists(leftErrors, rightErrors, eqE), + rightErrors -> equalNonEmptyLists(leftErrors, rightErrors, eqE), ignored -> false), leftValue -> right.fold( ignored -> false, @@ -55,13 +55,13 @@ public static Show> show( Objects.requireNonNull(showE, "showE"); Objects.requireNonNull(showA, "showA"); return validated -> validated.fold( - errors -> "Invalid(" + errors.stream().map(showE::show).toList() + ")", + errors -> "Invalid(" + errors.toList().stream().map(showE::show).toList() + ")", value -> "Valid(" + showA.show(value) + ")"); } - private static boolean equalLists( - final List left, - final List right, + private static boolean equalNonEmptyLists( + final NonEmptyList left, + final NonEmptyList right, final Eq eqE) { if (left.size() != right.size()) { return false; diff --git a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java new file mode 100644 index 0000000..908dddf --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java @@ -0,0 +1,33 @@ +package io.codeswarm.typeclasses.domain; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Tests domain smart constructors and invariant-safe value objects. */ +class SmartConstructorsTest { + + /** Verifies UserId normalization and blank rejection. */ + @Test + void shouldValidateUserId() { + assertEquals("user-1", UserId.from(" user-1 ").fold(error -> "bad", id -> id.value())); + assertTrue(UserId.from(" ").isLeft()); + } + + /** Verifies person-name invariants. */ + @Test + void shouldValidatePersonName() { + assertEquals("Alice", PersonName.from(" Alice ").fold(error -> "bad", PersonName::value)); + assertTrue(PersonName.from("").isLeft()); + assertTrue(PersonName.from("x".repeat(PersonName.MAX_LENGTH + 1)).isLeft()); + } + + /** Verifies age range validation. */ + @Test + void shouldValidateAge() { + assertEquals(42, Age.from(42).fold(error -> -1, Age::value)); + assertTrue(Age.from(-1).isLeft()); + assertTrue(Age.from(Age.MAX_VALUE + 1).isLeft()); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/instances/NonEmptyListInstancesTest.java b/src/test/java/io/codeswarm/typeclasses/instances/NonEmptyListInstancesTest.java new file mode 100644 index 0000000..0c79f7f --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/instances/NonEmptyListInstancesTest.java @@ -0,0 +1,36 @@ +package io.codeswarm.typeclasses.instances; + +import io.codeswarm.typeclasses.data.NonEmptyList; +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 NonEmptyListInstances}. */ +class NonEmptyListInstancesTest { + + /** Verifies derived equality. */ + @Test + void shouldCompareElementsWithProvidedEq() { + final var eq = NonEmptyListInstances.eq(IntegerInstances.EQ); + assertTrue(eq.eqv(NonEmptyList.of(1, 2), NonEmptyList.of(1, 2))); + assertFalse(eq.eqv(NonEmptyList.of(1, 2), NonEmptyList.of(2, 1))); + } + + /** Verifies derived rendering. */ + @Test + void shouldRenderElementsWithProvidedShow() { + final var show = NonEmptyListInstances.show(IntegerInstances.SHOW); + assertEquals("NonEmptyList(1, 2, 3)", show.show(NonEmptyList.of(1, 2, 3))); + } + + /** Verifies concatenation semigroup. */ + @Test + void shouldConcatenateNonEmptyLists() { + final var semigroup = NonEmptyListInstances.concatenation(); + assertEquals( + NonEmptyList.of(1, 2, 3), + semigroup.combine(NonEmptyList.of(1, 2), NonEmptyList.one(3))); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java b/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java index 6258d32..43f07e3 100644 --- a/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java +++ b/src/test/java/io/codeswarm/typeclasses/instances/ValidatedInstancesTest.java @@ -1,7 +1,6 @@ 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; @@ -16,8 +15,8 @@ class ValidatedInstancesTest { 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")))); + assertTrue(eq.eqv(Validated.invalid("a", "b"), Validated.invalid("a", "b"))); + assertFalse(eq.eqv(Validated.invalid("a", "b"), Validated.invalid("b", "a"))); } /** Verifies rendering for both validation branches. */ @@ -25,6 +24,6 @@ void shouldDeriveEquality() { 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")))); + assertEquals("Invalid([bad, worse])", show.show(Validated.invalid("bad", "worse"))); } } diff --git a/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java b/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java index 00ee464..5a98094 100644 --- a/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java +++ b/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java @@ -61,6 +61,6 @@ private static Either either(final int value) { private static Validated validated(final int value) { return value % 2 == 0 ? Validated.valid(value) - : Validated.invalid(List.of("error-" + value)); + : Validated.invalid("error-" + value); } } diff --git a/src/test/java/io/codeswarm/typeclasses/properties/NonEmptyListInstancesProperties.java b/src/test/java/io/codeswarm/typeclasses/properties/NonEmptyListInstancesProperties.java new file mode 100644 index 0000000..7d05dbe --- /dev/null +++ b/src/test/java/io/codeswarm/typeclasses/properties/NonEmptyListInstancesProperties.java @@ -0,0 +1,38 @@ +package io.codeswarm.typeclasses.properties; + +import io.codeswarm.typeclasses.data.NonEmptyList; +import io.codeswarm.typeclasses.instances.IntegerInstances; +import io.codeswarm.typeclasses.instances.NonEmptyListInstances; +import io.codeswarm.typeclasses.laws.EqLaws; +import io.codeswarm.typeclasses.laws.SemigroupLaws; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Property-based laws for {@link NonEmptyList} typeclass instances. */ +class NonEmptyListInstancesProperties { + + /** Verifies associativity of non-empty-list concatenation. */ + @Property + void concatenationShouldBeAssociative( + @ForAll final int first, + @ForAll final int second, + @ForAll final int third) { + final var semigroup = NonEmptyListInstances.concatenation(); + final var eq = NonEmptyListInstances.eq(IntegerInstances.EQ); + assertTrue(SemigroupLaws.associative( + NonEmptyList.one(first), + NonEmptyList.one(second), + NonEmptyList.one(third), + semigroup, + eq)); + } + + /** Verifies reflexivity of derived non-empty-list equality. */ + @Property + void equalityShouldBeReflexive(@ForAll final int value) { + final var list = NonEmptyList.of(value, value + 1); + assertTrue(EqLaws.reflexive(list, NonEmptyListInstances.eq(IntegerInstances.EQ))); + } +} diff --git a/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java b/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java index 67bea7b..7c30369 100644 --- a/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java @@ -21,7 +21,7 @@ void shouldUseSelectedMonoid() { /** Verifies that an empty iterable evaluates to the selected identity. */ @Test void shouldReturnIdentityForEmptyIterable() { - assertEquals(0, MonoidFunctions.combineAll(List.of(), IntegerInstances.ADDITION)); - assertEquals(1, MonoidFunctions.combineAll(List.of(), IntegerInstances.MULTIPLICATION)); + assertEquals(0, MonoidFunctions.combineAll(List.of(), IntegerInstances.ADDITION)); + assertEquals(1, MonoidFunctions.combineAll(List.of(), IntegerInstances.MULTIPLICATION)); } } diff --git a/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java b/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java index f2ae092..e37fc0e 100644 --- a/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java @@ -23,6 +23,6 @@ void shouldCombineNonEmptyIterable() { @Test void shouldRejectEmptyIterable() { assertThrows(IllegalArgumentException.class, - () -> SemigroupFunctions.combineAll(List.of(), MAXIMUM)); + () -> SemigroupFunctions.combineAll(List.of(), MAXIMUM)); } } From b4b5731ed1c28b2eda1a46196198c75d3648ed23 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:20:44 +0200 Subject: [PATCH 10/14] Refactors ADT's implementation --- .../java/io/codeswarm/typeclasses/examples/AdtExample.java | 3 +-- .../java/io/codeswarm/typeclasses/examples/EqExample.java | 3 +-- .../io/codeswarm/typeclasses/examples/MonoidExample.java | 3 +-- .../java/io/codeswarm/typeclasses/examples/OrdExample.java | 3 +-- .../io/codeswarm/typeclasses/examples/SemigroupExample.java | 3 +-- .../java/io/codeswarm/typeclasses/examples/ShowExample.java | 3 +-- .../typeclasses/examples/SmartConstructorExample.java | 2 +- .../typeclasses/examples/ValidatedUserExample.java | 2 +- .../codeswarm/typeclasses/instances/LocalDateInstances.java | 2 +- .../io/codeswarm/typeclasses/instances/OptionInstances.java | 1 - .../codeswarm/typeclasses/domain/SmartConstructorsTest.java | 6 +++--- .../io/codeswarm/typeclasses/domain/UserValidatorTest.java | 2 +- .../typeclasses/properties/AdtInstancesProperties.java | 1 - .../codeswarm/typeclasses/syntax/MonoidFunctionsTest.java | 4 ++-- .../typeclasses/syntax/SemigroupFunctionsTest.java | 2 +- 15 files changed, 16 insertions(+), 24 deletions(-) diff --git a/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java b/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java index 1196eee..f4836ee 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/AdtExample.java @@ -22,9 +22,8 @@ private AdtExample() { /** * Runs a small demonstration of Option, Either and Validated. * - * @param args ignored command-line arguments */ - public static void main(final String[] args) { + 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("name is empty", "age is negative"); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java b/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java index 1491b8b..a54445e 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/EqExample.java @@ -12,9 +12,8 @@ private EqExample() { /** * Runs the equality example. * - * @param args command-line arguments; ignored */ - public static void main(final String[] args) { + static void main() { final var alice30 = new Person("Alice", 30); final var alice40 = new Person("ALICE", 40); System.out.println(EqFunctions.equal(alice30, alice40, PersonInstances.EQ_ALL_FIELDS)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java b/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java index 3799d5c..1ca724f 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/MonoidExample.java @@ -15,9 +15,8 @@ private MonoidExample() { /** * Runs the monoid example. * - * @param args command-line arguments; ignored */ - public static void main(final String[] args) { + static void main() { final var numbers = List.of(1, 2, 3, 4); System.out.println(MonoidFunctions.combineAll(numbers, IntegerInstances.ADDITION)); System.out.println(MonoidFunctions.combineAll(numbers, IntegerInstances.MULTIPLICATION)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java b/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java index 660af7c..d4605e7 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/OrdExample.java @@ -12,9 +12,8 @@ private OrdExample() { /** * Runs the ordering example. * - * @param args command-line arguments; ignored */ - public static void main(final String[] args) { + static void main() { final var alice = new Person("Alice", 35); final var bob = new Person("Bob", 27); System.out.println(OrdFunctions.min(alice, bob, PersonInstances.ORD_AGE)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java b/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java index bfba518..31594e4 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/SemigroupExample.java @@ -15,9 +15,8 @@ private SemigroupExample() { /** * Runs the semigroup example. * - * @param args command-line arguments; ignored */ - public static void main(final String[] args) { + static void main() { final Semigroup maximum = Integer::max; System.out.println(SemigroupFunctions.combineAll(List.of(4, 9, 2, 7), maximum)); } diff --git a/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java b/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java index ed32e20..1ae0e23 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/ShowExample.java @@ -16,9 +16,8 @@ private ShowExample() { /** * Runs the show example. * - * @param args command-line arguments; ignored */ - public static void main(final String[] args) { + static void main() { final var person = new Person("Alice", 30); System.out.println(ShowFunctions.show(person, PersonInstances.SHOW_COMPACT)); System.out.println(ShowFunctions.show(person, PersonInstances.SHOW_VERBOSE)); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java b/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java index 9299f2d..e14f1ad 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/SmartConstructorExample.java @@ -12,7 +12,7 @@ private SmartConstructorExample() { } /** Runs the smart-constructor example. */ - public static void main(final String[] args) { + static void main() { System.out.println(UserId.from("user-42")); System.out.println(UserId.from(" ")); System.out.println(PersonName.from("Alice")); diff --git a/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java b/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java index 27b7cad..73d4c58 100644 --- a/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java +++ b/src/main/java/io/codeswarm/typeclasses/examples/ValidatedUserExample.java @@ -10,7 +10,7 @@ private ValidatedUserExample() { } /** Runs valid and invalid user-validation examples. */ - public static void main(final String[] args) { + static void main() { System.out.println(UserValidator.validate("user-1", "Alice", 42)); System.out.println(UserValidator.validate("", "", -10)); } diff --git a/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java index f48a7ea..583c01d 100644 --- a/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java +++ b/src/main/java/io/codeswarm/typeclasses/instances/LocalDateInstances.java @@ -17,7 +17,7 @@ public final class LocalDateInstances { /** Renders dates in ISO-8601 local-date format. */ public static final Show ISO_SHOW = - date -> DateTimeFormatter.ISO_LOCAL_DATE.format(date); + DateTimeFormatter.ISO_LOCAL_DATE::format; /** Compares dates using standard value equality. */ public static final Eq EQ = LocalDate::equals; diff --git a/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java b/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java index 2fce08b..2a39e17 100644 --- a/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java +++ b/src/main/java/io/codeswarm/typeclasses/instances/OptionInstances.java @@ -4,7 +4,6 @@ import io.codeswarm.typeclasses.core.Show; import io.codeswarm.typeclasses.data.None; import io.codeswarm.typeclasses.data.Option; -import io.codeswarm.typeclasses.data.Some; import java.util.Objects; /** diff --git a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java index 908dddf..0f1d9cd 100644 --- a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java @@ -11,14 +11,14 @@ class SmartConstructorsTest { /** Verifies UserId normalization and blank rejection. */ @Test void shouldValidateUserId() { - assertEquals("user-1", UserId.from(" user-1 ").fold(error -> "bad", id -> id.value())); + assertEquals("user-1", UserId.from(" user-1 ").fold(_ -> "bad", UserId::value)); assertTrue(UserId.from(" ").isLeft()); } /** Verifies person-name invariants. */ @Test void shouldValidatePersonName() { - assertEquals("Alice", PersonName.from(" Alice ").fold(error -> "bad", PersonName::value)); + assertEquals("Alice", PersonName.from(" Alice ").fold(_ -> "bad", PersonName::value)); assertTrue(PersonName.from("").isLeft()); assertTrue(PersonName.from("x".repeat(PersonName.MAX_LENGTH + 1)).isLeft()); } @@ -26,7 +26,7 @@ void shouldValidatePersonName() { /** Verifies age range validation. */ @Test void shouldValidateAge() { - assertEquals(42, Age.from(42).fold(error -> -1, Age::value)); + assertEquals(42, Age.from(42).fold(_ -> -1, Age::value)); assertTrue(Age.from(-1).isLeft()); assertTrue(Age.from(Age.MAX_VALUE + 1).isLeft()); } diff --git a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java index e690374..d980320 100644 --- a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java +++ b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java @@ -16,7 +16,7 @@ class UserValidatorTest { void shouldCreateValidUser() { final var result = UserValidator.validate("user-1", "Alice", 42); assertTrue(result.isValid()); - assertEquals("user-1", result.fold(errors -> "bad", user -> user.id().value())); + assertEquals("user-1", result.fold(_ -> "bad", user -> user.id().value())); } /** Verifies accumulation of independent validation failures. */ diff --git a/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java b/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java index 5a98094..c6beff9 100644 --- a/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java +++ b/src/test/java/io/codeswarm/typeclasses/properties/AdtInstancesProperties.java @@ -9,7 +9,6 @@ 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; diff --git a/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java b/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java index 7c30369..67bea7b 100644 --- a/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/syntax/MonoidFunctionsTest.java @@ -21,7 +21,7 @@ void shouldUseSelectedMonoid() { /** Verifies that an empty iterable evaluates to the selected identity. */ @Test void shouldReturnIdentityForEmptyIterable() { - assertEquals(0, MonoidFunctions.combineAll(List.of(), IntegerInstances.ADDITION)); - assertEquals(1, MonoidFunctions.combineAll(List.of(), IntegerInstances.MULTIPLICATION)); + assertEquals(0, MonoidFunctions.combineAll(List.of(), IntegerInstances.ADDITION)); + assertEquals(1, MonoidFunctions.combineAll(List.of(), IntegerInstances.MULTIPLICATION)); } } diff --git a/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java b/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java index e37fc0e..f2ae092 100644 --- a/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/syntax/SemigroupFunctionsTest.java @@ -23,6 +23,6 @@ void shouldCombineNonEmptyIterable() { @Test void shouldRejectEmptyIterable() { assertThrows(IllegalArgumentException.class, - () -> SemigroupFunctions.combineAll(List.of(), MAXIMUM)); + () -> SemigroupFunctions.combineAll(List.of(), MAXIMUM)); } } From 9a5fb5a9840d3a126907a5b56f273bba416e596b Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:21:06 +0200 Subject: [PATCH 11/14] Updates documentation --- CHANGELOG.md | 41 +++++++ README.md | 228 ++++++++++++++++--------------------- build.gradle | 2 +- docs/ADT.md | 106 +++++------------ docs/ALGEBRA.md | 14 +++ docs/ARCHITECTURE.md | 134 ++++++++-------------- docs/LAWS.md | 14 ++- docs/ROADMAP.md | 197 +++++++++++++------------------- docs/SMART_CONSTRUCTORS.md | 62 ++++++++++ docs/TYPECLASSES.md | 21 +++- 10 files changed, 405 insertions(+), 414 deletions(-) create mode 100644 docs/SMART_CONSTRUCTORS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 186ba9e..a574257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to this educational project are documented here. +## 0.4.1 + +### Added + +- immutable `NonEmptyList` with `map`, `concat`, `toList` and size operations, +- derived `Eq>` and `Show>`, +- concatenation `Semigroup>`, +- jqwik law checks for non-empty-list equality and associativity, +- `Option.when`, +- `Either.cond`, `Either.fromNullable` and `Either.fromOptional`, +- sealed typed `ValidationError` hierarchy, +- invariant-safe `UserId`, `PersonName` and `Age` smart constructors, +- validated `User` domain record, +- domain-level `UserValidator` accumulating errors into `Validated`, +- `SmartConstructorExample` and `ValidatedUserExample`, +- `docs/SMART_CONSTRUCTORS.md`. + +### Changed + +- project version updated from `0.4.0` to `0.4.1`, +- `Invalid` now stores `NonEmptyList` instead of `List`, +- `Validated.invalid` now requires at least one error structurally, +- `Validated.fold` exposes `NonEmptyList` on the invalid branch, +- `ValidatedInstances` updated for the stronger error representation, +- README, ADT, architecture, roadmap and implementation tasks updated. + +### Removed / intentionally omitted + +- removed the runtime empty-list validation from `Invalid`; the type now guarantees non-emptiness, +- no `Monoid>` is provided because no lawful empty identity exists, +- no `flatMap`, HKT encoding, generic `Applicative` validation, effects or Tagless Final code is introduced yet, +- no obsolete scripts or generated build outputs are retained. + +### Design notes + +- public smart constructors model expected failures with ADTs instead of exceptions, +- invalid domain states cannot be created through the public APIs of `UserId`, `PersonName` and `Age`, +- typed validation errors replace stringly-typed domain errors, +- explicit domain-level validation accumulation is temporary and intentionally precedes the generic Applicative solution, +- the milestone continues to follow SOLID, KISS, DRY and YAGNI. + ## 0.4.0 ### Added diff --git a/README.md b/README.md index d31a885..2bf709f 100644 --- a/README.md +++ b/README.md @@ -2,41 +2,37 @@ An educational implementation of typeclass-oriented functional programming in modern Java. -Milestone **0.4.0** extends the algebra and executable laws introduced in earlier releases with algebraic data types (ADTs) and compositional typeclass instances. +Milestone **0.4.1** hardens the ADT layer introduced in 0.4.0 with non-empty collections, smart constructors, typed validation errors and domain invariants. ## Goals -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. +The project demonstrates how concepts associated with Scala, Cats and Haskell can be represented explicitly in Java while keeping the implementation small and understandable. The project currently covers: -- explicit typeclass instances, -- dictionary passing, -- multiple instances for the same domain type, +- explicit typeclass instances and dictionary passing, - `Eq`, `Ord`, `Show`, `Semigroup` and `Monoid`, -- executable algebraic laws, -- property-based testing with jqwik, -- sealed algebraic data types, -- composition of typeclass instances. +- executable algebraic laws and jqwik property tests, +- sealed ADTs: `Option`, `Either` and `Validated`, +- compositional `Eq` and `Show` instances, +- `NonEmptyList`, +- smart constructors and invariant-safe domain values, +- typed validation errors, +- domain-level error accumulation with `Validated`. -The project deliberately does **not** yet introduce higher-kinded type encoding, `Functor`, `Applicative`, `Monad`, effect types or Tagless Final. +The project deliberately does **not** yet introduce HKT encoding, `Functor`, `Applicative`, `Monad`, `Traverse`, effects, Kleisli, Tagless Final, Free Monad or optics. ## Requirements - Java 21 or newer, -- Gradle 9.8.0 through the project wrapper configuration. +- Gradle 9.8.0 through the wrapper configuration. -CI is intended to verify the project on Java 21, 25 and 27. +CI is intended to verify Java 21, 25 and 27. ## Build ```bash ./gradlew clean check -``` - -Generate Javadoc: - -```bash ./gradlew javadoc ``` @@ -52,168 +48,135 @@ src/main/java/io/codeswarm/typeclasses/ │ ├── Ord.java │ └── Show.java ├── data/ +│ ├── Option.java +│ ├── Some.java +│ ├── None.java │ ├── Either.java -│ ├── Invalid.java │ ├── Left.java -│ ├── None.java -│ ├── Option.java │ ├── Right.java -│ ├── Some.java +│ ├── Validated.java │ ├── Valid.java -│ └── Validated.java +│ ├── Invalid.java +│ └── NonEmptyList.java +├── domain/ +│ ├── ValidationError.java +│ ├── BlankUserId.java +│ ├── BlankPersonName.java +│ ├── PersonNameTooLong.java +│ ├── InvalidAge.java +│ ├── UserId.java +│ ├── PersonName.java +│ ├── Age.java +│ ├── User.java +│ └── UserValidator.java ├── examples/ ├── instances/ └── syntax/ ``` -Reusable law helpers remain test infrastructure: - -```text -src/test/java/io/codeswarm/typeclasses/laws/ -``` +Reusable law helpers remain in `src/test/java/io/codeswarm/typeclasses/laws/`. -This keeps the production API focused while the project is still pre-1.0. +## NonEmptyList -## Basic typeclass example +`NonEmptyList` encodes non-emptiness structurally: ```java -Eq integerEq = IntegerInstances.EQ; -boolean same = integerEq.eqv(42, 42); +NonEmptyList values = NonEmptyList.of(1, 2, 3); ``` -Typeclass behavior remains separate from the represented type. A domain object does not have to implement the typeclass interface. - -## Algebraic data types - -### Option - -`Option` models presence or absence without exposing `null` as part of normal program flow. +There is no public way to create an empty value. This lets `Invalid` represent failed validation without a separate runtime `isEmpty()` guard. ```java -Option result = - Option.some(21) - .map(value -> value * 2); +public record Invalid(NonEmptyList errors) + implements Validated { +} ``` -The ADT is sealed and has two variants: +`NonEmptyList` has a natural concatenation `Semigroup` but intentionally no `Monoid`: ```text -Option -├── Some -└── None +List -> Monoid> +NonEmptyList -> Semigroup> ``` -### Either +A monoid requires an identity element; an empty `NonEmptyList` cannot exist. -`Either` models two alternatives and is right-biased for `map`. +## Smart constructors -```java -Either result = - Either.right(21) - .map(value -> value * 2); -``` +Domain values expose factories that validate raw input and return an ADT instead of throwing for expected validation failures. -```text -Either -├── Left -└── Right +```java +Either id = UserId.from(" user-42 "); +Either age = Age.from(42); ``` -### Validated - -`Validated` models a successful value or one or more validation errors. +Invalid input is explicit: ```java -Validated result = - Validated.invalid( - List.of("name is empty", "age is negative")); -``` - -```text -Validated -├── Valid -└── Invalid +Age.from(-1); // Left(InvalidAge[-1]) +UserId.from(" "); // Left(BlankUserId[]) ``` -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. +The constructors of `UserId`, `PersonName` and `Age` are private, so invalid states cannot be created through their public APIs. -For example: +See [docs/SMART_CONSTRUCTORS.md](docs/SMART_CONSTRUCTORS.md). -```java -Eq> optionEq = - OptionInstances.eq(IntegerInstances.EQ); -``` +## Typed validation errors -Conceptually: +Expected validation failures are modeled as a closed ADT: ```text -Eq - │ - ▼ -Eq> +ValidationError +├── BlankUserId +├── BlankPersonName +├── PersonNameTooLong +└── InvalidAge ``` -The same idea is implemented for `Either` and `Validated`: +This is preferable to using arbitrary strings when callers need to inspect or pattern-match on error categories. -```java -Eq> eitherEq = - EitherInstances.eq( - StringInstances.EQ, - IntegerInstances.EQ); -``` +## Validated and error accumulation -and: +`UserValidator` demonstrates accumulation of independent errors: ```java -Show> validatedShow = - ValidatedInstances.show( - StringInstances.SHOW, - IntegerInstances.SHOW); +Validated result = + UserValidator.validate("", "", -10); ``` -This is an important bridge between simple typeclasses and the higher-kinded abstractions planned for later milestones. - -## Algebraic laws +The result contains three typed errors in a `NonEmptyList`. -A Java implementation matching an interface is not automatically a lawful instance of the algebraic abstraction it represents. +This implementation is intentionally domain-specific. Generic validation composition is deferred until the project has `Applicative`. -Milestone 0.3 introduced executable laws for: +## ADT helper factories -- `Eq`, -- `Ord`, -- `Semigroup`, -- `Monoid`. +Milestone 0.4.1 adds small Java-interoperability helpers: -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. +```java +Option.when(condition, supplier); +Either.cond(condition, rightSupplier, leftSupplier); +Either.fromNullable(value, leftSupplier); +Either.fromOptional(optional, leftSupplier); +``` -See [docs/LAWS.md](docs/LAWS.md). +No `flatMap` is added yet; monadic operations will be introduced together with the generic `Monad` milestone. ## Design principles -The project applies the following principles deliberately: +- **SRP** — ADTs, domain validation, typeclasses, instances, syntax and laws remain separate. +- **OCP** — new instances and domain validators do not require modifying typeclasses. +- **DIP** — generic functions continue to depend on typeclass abstractions. +- **KISS** — smart constructors use ordinary Java and explicit ADTs. +- **DRY** — law helpers and compositional instances are reused rather than duplicated. +- **YAGNI** — no HKT, generic Applicative validation or effect system is introduced early. -- **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. +## Examples -## Example +`AdtExample` demonstrates `Option`, `Either`, `Validated` and composed `Show` instances. -Run `AdtExample` from the IDE or compile and execute it manually. It demonstrates `Option`, `Either`, `Validated` and derived `Show` instances. +`SmartConstructorExample` demonstrates validated domain values. -Expected output: - -```text -Some(42) -Right(42) -Invalid([name is empty, age is negative]) -``` +`ValidatedUserExample` demonstrates accumulated typed validation errors. ## Documentation @@ -222,25 +185,26 @@ Invalid([name is empty, age is negative]) - [Algebra](docs/ALGEBRA.md) - [Algebraic laws](docs/LAWS.md) - [Algebraic data types](docs/ADT.md) +- [Smart constructors](docs/SMART_CONSTRUCTORS.md) - [Implementation tasks](docs/IMPLEMENTATION_TASKS.md) - [Roadmap](docs/ROADMAP.md) - [Changelog](CHANGELOG.md) ## Roadmap -The planned sequence after 0.4 is: - ```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 +0.5 HKT encoding: Kind +0.6 Functor / Applicative / Monad / MonadError +0.7 Foldable / Traverse / Bifunctor +0.8 Id / Eval / IO / FunctionK +0.9 Kleisli and effectful composition +0.10 Tagless Final +0.11 Free Monad +0.12 Lens / Prism / Optics +0.13+ advanced typeclasses, derivation and API hardening +1.0 stable educational release ``` -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. See [LICENSE](LICENSE). diff --git a/build.gradle b/build.gradle index bef3db5..513dc7b 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ description = 'Educational implementation of typeclass-oriented programming, alg java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } withSourcesJar() withJavadocJar() diff --git a/docs/ADT.md b/docs/ADT.md index 422f940..3bd9375 100644 --- a/docs/ADT.md +++ b/docs/ADT.md @@ -1,102 +1,50 @@ -# Algebraic Data Types +# Algebraic data types -Milestone 0.4 introduces three sealed algebraic data types implemented with Java 21 sealed interfaces and records. +Milestone 0.4 introduced Java 21 sealed ADTs. Milestone 0.4.1 strengthens them with `NonEmptyList` and domain invariants. -## Why ADTs? +## Sum types -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: +`Option`, `Either` and `Validated` are modeled as sealed sum types: ```text -Option = Some | None +Option Either Validated +├── Some ├── Left ├── Valid +└── None └── Right └── Invalid ``` -Supported operations: +## Product types -- `map`, -- `fold`, -- `isDefined`, -- `isEmpty`, -- `getOrElse`, -- factories `some`, `none`, `fromNullable`. +Records are used where all components can safely be exposed through canonical construction, for example `User` and `NonEmptyList`. -`Some` rejects `null`. `None` carries no value. +## Non-empty structures -## Either - -`Either` represents one of two possible values: +`NonEmptyList` stores a mandatory `head` and an immutable `tail`. ```text -Either = Left | Right +NonEmptyList + head: A + tail: List ``` -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`. +This structurally rules out the empty state. -`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` therefore stores `NonEmptyList` rather than `List`. -`Invalid` requires a non-empty list and defensively copies it. +## Minimal operations -Supported operations: +The project intentionally keeps concrete ADT operations small: -- `map`, -- `fold`, -- `isValid`, -- `isInvalid`, -- factories `valid` and `invalid`. +- `Option`: `map`, `fold`, `getOrElse`, branch checks, +- `Either`: `map`, `mapLeft`, `fold`, branch checks, +- `Validated`: `map`, `fold`, branch checks, +- `NonEmptyList`: `map`, `concat`, `toList`. -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. +`flatMap`, generic `traverse`, Applicative composition and effectful operations are deferred until their corresponding abstractions exist. -## Compositional typeclass instances +## Smart constructors and invariants -The `instances` package contains factories that derive instances for an ADT from instances for its components. +ADT results are also used to create domain values safely. See [SMART_CONSTRUCTORS.md](SMART_CONSTRUCTORS.md). -Examples: +## Algebraic connection -```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 -``` +`NonEmptyList` has a lawful concatenation `Semigroup` but no lawful empty identity. An ordinary `List` can have a concatenation `Monoid` because `List.of()` is an identity element. diff --git a/docs/ALGEBRA.md b/docs/ALGEBRA.md index 1fbf81c..231104a 100644 --- a/docs/ALGEBRA.md +++ b/docs/ALGEBRA.md @@ -44,3 +44,17 @@ Law checking does not hard-code `Object.equals`. The law receives an `Eq` ins ## Laws as executable contracts The interfaces remain tiny. Their semantic contracts are verified in test scope through `SemigroupLaws` and `MonoidLaws` plus jqwik property suites. + +## NonEmptyList semigroup (0.4.1) + +`NonEmptyList` has a natural associative concatenation operation: + +```text +NonEmptyList(1,2) <> NonEmptyList(3,4) += +NonEmptyList(1,2,3,4) +``` + +Therefore it forms a `Semigroup` under concatenation. + +It deliberately does **not** form a `Monoid` under the same representation because there is no empty `NonEmptyList` to serve as the identity value. This contrasts with ordinary `List`, whose empty list is the concatenation identity. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fcdc54e..038e374 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,105 +1,69 @@ # Architecture -## Overview - -Milestone 0.4 keeps the project deliberately small and explicit. The production code is divided by responsibility: +## Layers ```text -core/ basic typeclass contracts -algebra/ algebraic typeclasses -syntax/ generic helper functions -instances/ concrete and compositional instances -data/ algebraic data types -examples/ executable educational examples +Domain values + UserId / PersonName / Age / User + | + | smart constructors / validation + v +Data / ADT layer + Option / Either / Validated / NonEmptyList + | + | described by external instances + v +Typeclasses + Show / Eq / Ord / Semigroup / Monoid + | + v +Instances + syntax helpers + | + v +Laws and property tests (test-only) ``` -Reusable law definitions remain test infrastructure under `src/test/java/.../laws`. - -## Core relationships +## Separation of responsibilities -```text -Show -Eq <--- Ord +- `core` contains basic typeclass contracts. +- `algebra` contains algebraic contracts. +- `data` contains immutable ADTs. +- `domain` demonstrates invariant-safe domain modeling. +- `instances` derives behavior externally from data types. +- `syntax` contains generic helper functions. +- test-only `laws` contains reusable executable laws. -Semigroup <--- Monoid -``` - -Domain and data types do not implement these interfaces. Typeclass instances are separate values. - -## ADT layer +## NonEmptyList ```text -Option -├── Some -└── None - -Either -├── Left -└── Right - -Validated -├── Valid -└── Invalid +List + runtime non-empty check (0.4.0) + | + v +NonEmptyList encoded invariant (0.4.1) ``` -The sealed hierarchy makes variants exhaustive and explicit. - -## Compositional instances +This removes one invalid representable state from `Invalid`. -Milestone 0.4 introduces instance factories that depend on component instances: +## Domain validation ```text -Eq ----------------------> Eq> -Show --------------------> Show> - -Eq + Eq -------------> Eq> -Show + Show ---------> Show> - -Eq + Eq -------------> Eq> -Show + Show ---------> Show> +raw fields + | + +--> UserId.from ------> Either + +--> PersonName.from --> Either + `--> Age.from ---------> Either + | + v + UserValidator + | + v + Validated ``` -This dependency is explicit through method parameters; there is no global registry or implicit resolution mechanism. - -## SOLID / KISS / DRY / YAGNI - -### SRP - -- ADTs model data alternatives. -- typeclasses describe capabilities/algebra. -- instance holders provide concrete interpretations. -- syntax classes provide reusable generic operations. -- law helpers verify algebraic contracts. - -### OCP - -New instances can be added without modifying the represented ADT or domain type. - -### DIP - -Generic code depends on `Eq`, `Show`, `Semigroup`, `Monoid`, etc., not on particular concrete policies. - -### KISS - -ADT APIs expose only operations needed by this milestone. - -### DRY - -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. +The accumulation in `UserValidator` is intentionally concrete. A generic solution will be introduced with `Applicative`. -These concepts are sequenced later in the roadmap. +## Dependency direction -## Why Tagless Final is later +Domain examples depend on ADTs. ADTs do not depend on domain classes. Typeclasses do not depend on either domain examples or specific instances. -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. +This preserves DIP and keeps the educational abstractions reusable. diff --git a/docs/LAWS.md b/docs/LAWS.md index 6401ab5..0b03e52 100644 --- a/docs/LAWS.md +++ b/docs/LAWS.md @@ -95,7 +95,7 @@ Milestone 0.3 uses laws as internal verification infrastructure. Publishing them A future milestone may extract them into a dedicated module if users need reusable law suites. -## ADT equality laws in milestone 0.4 +## ADT equality laws introduced in milestone 0.4 The law helpers introduced in 0.3 are reused to verify composed equality instances for `Option`, `Either` and `Validated`. @@ -106,3 +106,15 @@ The project checks the same `Eq` laws: - transitivity. This demonstrates an important principle: composing an instance should preserve the algebraic contract when the component instances are lawful. + +## NonEmptyList laws in milestone 0.4.1 + +The concatenation instance for `NonEmptyList` reuses the existing `SemigroupLaws.associative` helper. Derived `Eq>` also reuses the existing `EqLaws` harness. + +This demonstrates the intended layering: + +```text +ADT -> typeclass instance -> reusable law -> property-based verification +``` + +No special-purpose law framework is added for `NonEmptyList`; existing laws remain the single source of truth. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f8885fc..86ba288 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -6,149 +6,116 @@ Early exploration of typeclass-like patterns in Java. ## 0.1.1 — Foundation hardening -- Java 21 baseline, -- explicit `Show`, -- dictionary passing, -- separated instances, -- CI and documentation cleanup. +Java 21 baseline, explicit `Show`, dictionary passing, separated instances and documentation cleanup. ## 0.2 — Basic algebraic typeclasses -- `Eq`, -- `Ord`, -- `Semigroup`, -- `Monoid`, -- standard instances, -- dedicated syntax helpers. +`Eq`, `Ord`, `Semigroup`, `Monoid`, standard instances and syntax helpers. ## 0.3 — Typeclass laws -- executable `Eq`, `Ord`, `Semigroup` and `Monoid` laws, -- jqwik property-based testing, -- lawful and intentionally unlawful examples. +Executable laws, jqwik property testing and lawful/unlawful examples. -## 0.4 — Algebraic data types and compositional instances +## 0.4 — Algebraic data types -**Current milestone.** - -- `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 - -Introduce an explicit encoding for Java's missing higher-kinded types: - -```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 +`Option`, `Either`, `Validated`, composed `Eq` / `Show` instances and ADT property tests. -Build the first higher-kinded typeclass hierarchy: +## 0.4.1 — ADT hardening and smart constructors -```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 +**Current milestone.** -Prepare the execution model needed by Tagless Final. +- `NonEmptyList`, +- `Invalid` backed by `NonEmptyList`, +- `Semigroup>`, +- smart constructors, +- typed validation errors, +- invariant-safe domain values, +- Java interoperability helpers for `Option` / `Either`, +- domain-level accumulated validation. -Planned topics: +## 0.5 — Higher-kinded type encoding -- simple `Id`, -- lazy `Eval`, -- small educational `IO` effect, -- `FunctionK` / natural transformations, +- `Kind`, +- witness types (`OptionK`, `EitherK`, `ValidatedK`), +- isolated narrowing/widening helpers, +- adapters from existing concrete ADTs, +- documentation of Java type-erasure limitations. + +## 0.6 — Functor / Applicative / Monad + +- `Functor`, +- `Applicative`, +- `Monad`, +- `MonadError` where justified, +- instances for `Option` and right-biased `Either`, +- Applicative validation accumulation for `Validated`, +- Functor / Applicative / Monad laws. + +## 0.7 — Foldable / Traverse / Bifunctor + +- `Foldable`, +- `Traverse`, +- `Bifunctor`, +- `sequence` / `traverse`, +- examples such as `List> -> Option>`, +- corresponding laws. + +## 0.8 — Effects and natural transformations + +- `Id`, +- `Eval`, +- educational `IO`, +- `FunctionK`, - 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. +- stack-safety discussion. -## 0.8 — Tagless Final +## 0.9 — Kleisli and effectful composition -Introduce Tagless Final only after HKT and effects exist. +- `Kleisli`, +- composition of effectful functions, +- Reader-style dependency passing where useful, +- workflow examples. -Example direction: +## 0.10 — Tagless Final -```java -interface UserRepository { - Kind find(UserId id); -} -``` +- algebras/capabilities parameterized by `F`, +- programs expressed only in terms of algebras and typeclasses, +- interpreters for `Id`, `Either` and `IO`, +- natural transformations between interpreters, +- comparison with Scala/Cats Tagless Final. -and programs parameterized by an effect constructor: +## 0.11 — Free Monad -```text -algebra/capability interfaces - + -programs parameterized by F - + -multiple interpreters -``` +- `Free`, +- algebra ASTs, +- interpreters via natural transformations, +- explicit comparison: Tagless Final vs Free Monad. -Planned interpreters may include: +## 0.12 — Optics -- `Id` for pure deterministic tests, -- `Either` for explicit errors, -- `IO` for real side effects. +- `Lens`, +- `Prism`, +- optional focus / traversal concepts, +- composition for immutable nested updates. -Topics: +## 0.13 — Advanced typeclasses -- programs vs interpreters, -- dependency inversion without framework DI, -- testing without mocks, -- natural transformations between interpreters, -- comparison with Scala 3 / Cats Tagless Final. +Candidates include `SemigroupK`, `MonoidK`, `Alternative`, richer error typeclasses and advanced validation composition. -## 0.9 — Derivation, resolution and API hardening +## 0.14 — Derivation and resolution experiments -Experiments that are useful only after the core model is stable: +- generated `Eq` / `Show` for records, +- optional annotation processing, +- optional explicit registry / `summon` experiment, +- API naming and package stabilization. -- 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. +## 0.15 — Performance and ergonomics -Any implicit-resolution mechanism must remain optional; explicit instances are the conceptual baseline. +- JMH where meaningful, +- allocation analysis, +- API simplification, +- compatibility review. ## 1.0 — Stable educational release -- stable public API, -- complete documentation, -- Java vs Scala/Cats comparison, -- examples spanning basic typeclasses through Tagless Final, -- law-checked standard instances, -- release/publishing workflow. +Stable API, complete docs, Java-vs-Scala/Cats comparisons, examples from first-order typeclasses through Tagless Final / Free / optics, and law-checked standard instances. diff --git a/docs/SMART_CONSTRUCTORS.md b/docs/SMART_CONSTRUCTORS.md new file mode 100644 index 0000000..0e06632 --- /dev/null +++ b/docs/SMART_CONSTRUCTORS.md @@ -0,0 +1,62 @@ +# Smart constructors + +## Purpose + +A smart constructor validates raw data before a domain value becomes observable. The public API returns an ADT describing success or expected failure instead of exposing a permissive constructor. + +```text +raw String + | + v +UserId.from(...) + | + +--> Left + | + `--> Right +``` + +The goal is to make invalid domain states difficult or impossible to construct through the public API. + +## Why not throw? + +Exceptions remain appropriate for programming errors and broken invariants. Expected validation failures are ordinary outcomes and are modeled explicitly with `Either` or `Validated`. + +## Value objects + +Milestone 0.4.1 includes: + +- `UserId` — non-blank, normalized identifier, +- `PersonName` — non-blank and at most 100 characters, +- `Age` — range 0..130. + +Their constructors are private. Construction goes through `from(...)`. + +## Typed errors + +Validation uses a sealed hierarchy rather than plain strings: + +```text +ValidationError +├── BlankUserId +├── BlankPersonName +├── PersonNameTooLong +└── InvalidAge +``` + +Typed errors are inspectable, testable and composable. + +## Either vs Validated + +`Either` is used for a single smart constructor because each value object has one local validation flow. + +`Validated` is used by `UserValidator` to demonstrate accumulation of independent field failures. + +The current validator performs this accumulation explicitly. Generic `Applicative` composition belongs to the later Applicative milestone. + +## Records vs final classes + +Records are ideal when their canonical constructor may be public and all constructor arguments already satisfy the invariant. + +For `UserId`, `PersonName` and `Age`, a private constructor is important. Ordinary final classes therefore communicate the invariant more clearly than public records. + +`User` is a record because all of its components are already validated value objects. diff --git a/docs/TYPECLASSES.md b/docs/TYPECLASSES.md index 2fb8710..8033f81 100644 --- a/docs/TYPECLASSES.md +++ b/docs/TYPECLASSES.md @@ -68,4 +68,23 @@ The important idea is that the project does not need to modify `Option` to give 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. +This is a stepping stone toward higher-kinded abstractions, but it remains ordinary Java generics throughout the 0.4.x milestones. + +## NonEmptyList instances in milestone 0.4.1 + +`NonEmptyList` demonstrates how the shape of a data type constrains the lawful algebra that can be provided for it. + +Given `Eq` and `Show`, the project derives: + +```text +Eq> +Show> +``` + +Independently, non-empty-list concatenation provides: + +```text +Semigroup> +``` + +There is intentionally no `Monoid>`, because no lawful empty identity can inhabit the type. From 74a9166f4d4adcd222c81fd370b407dc21e96700 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:22:34 +0200 Subject: [PATCH 12/14] Updates gradle --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 513dc7b..bef3db5 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ description = 'Educational implementation of typeclass-oriented programming, alg java { toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion = JavaLanguageVersion.of(21) } withSourcesJar() withJavadocJar() From 0dc86f519c029ba33a66d13ff5b1f90221b369fb Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:26:14 +0200 Subject: [PATCH 13/14] Updates tests --- .../codeswarm/typeclasses/domain/SmartConstructorsTest.java | 6 +++--- .../io/codeswarm/typeclasses/domain/UserValidatorTest.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java index 0f1d9cd..908dddf 100644 --- a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java @@ -11,14 +11,14 @@ class SmartConstructorsTest { /** Verifies UserId normalization and blank rejection. */ @Test void shouldValidateUserId() { - assertEquals("user-1", UserId.from(" user-1 ").fold(_ -> "bad", UserId::value)); + assertEquals("user-1", UserId.from(" user-1 ").fold(error -> "bad", id -> id.value())); assertTrue(UserId.from(" ").isLeft()); } /** Verifies person-name invariants. */ @Test void shouldValidatePersonName() { - assertEquals("Alice", PersonName.from(" Alice ").fold(_ -> "bad", PersonName::value)); + assertEquals("Alice", PersonName.from(" Alice ").fold(error -> "bad", PersonName::value)); assertTrue(PersonName.from("").isLeft()); assertTrue(PersonName.from("x".repeat(PersonName.MAX_LENGTH + 1)).isLeft()); } @@ -26,7 +26,7 @@ void shouldValidatePersonName() { /** Verifies age range validation. */ @Test void shouldValidateAge() { - assertEquals(42, Age.from(42).fold(_ -> -1, Age::value)); + assertEquals(42, Age.from(42).fold(error -> -1, Age::value)); assertTrue(Age.from(-1).isLeft()); assertTrue(Age.from(Age.MAX_VALUE + 1).isLeft()); } diff --git a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java index d980320..e690374 100644 --- a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java +++ b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java @@ -16,7 +16,7 @@ class UserValidatorTest { void shouldCreateValidUser() { final var result = UserValidator.validate("user-1", "Alice", 42); assertTrue(result.isValid()); - assertEquals("user-1", result.fold(_ -> "bad", user -> user.id().value())); + assertEquals("user-1", result.fold(errors -> "bad", user -> user.id().value())); } /** Verifies accumulation of independent validation failures. */ From c3f96efb9431f282468b7d43eaf9cb26871199a7 Mon Sep 17 00:00:00 2001 From: Michal Stawarski Date: Wed, 30 Sep 2026 22:26:14 +0200 Subject: [PATCH 14/14] Updates two tests --- .../codeswarm/typeclasses/domain/SmartConstructorsTest.java | 6 +++--- .../io/codeswarm/typeclasses/domain/UserValidatorTest.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java index 0f1d9cd..908dddf 100644 --- a/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java +++ b/src/test/java/io/codeswarm/typeclasses/domain/SmartConstructorsTest.java @@ -11,14 +11,14 @@ class SmartConstructorsTest { /** Verifies UserId normalization and blank rejection. */ @Test void shouldValidateUserId() { - assertEquals("user-1", UserId.from(" user-1 ").fold(_ -> "bad", UserId::value)); + assertEquals("user-1", UserId.from(" user-1 ").fold(error -> "bad", id -> id.value())); assertTrue(UserId.from(" ").isLeft()); } /** Verifies person-name invariants. */ @Test void shouldValidatePersonName() { - assertEquals("Alice", PersonName.from(" Alice ").fold(_ -> "bad", PersonName::value)); + assertEquals("Alice", PersonName.from(" Alice ").fold(error -> "bad", PersonName::value)); assertTrue(PersonName.from("").isLeft()); assertTrue(PersonName.from("x".repeat(PersonName.MAX_LENGTH + 1)).isLeft()); } @@ -26,7 +26,7 @@ void shouldValidatePersonName() { /** Verifies age range validation. */ @Test void shouldValidateAge() { - assertEquals(42, Age.from(42).fold(_ -> -1, Age::value)); + assertEquals(42, Age.from(42).fold(error -> -1, Age::value)); assertTrue(Age.from(-1).isLeft()); assertTrue(Age.from(Age.MAX_VALUE + 1).isLeft()); } diff --git a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java index d980320..e690374 100644 --- a/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java +++ b/src/test/java/io/codeswarm/typeclasses/domain/UserValidatorTest.java @@ -16,7 +16,7 @@ class UserValidatorTest { void shouldCreateValidUser() { final var result = UserValidator.validate("user-1", "Alice", 42); assertTrue(result.isValid()); - assertEquals("user-1", result.fold(_ -> "bad", user -> user.id().value())); + assertEquals("user-1", result.fold(errors -> "bad", user -> user.id().value())); } /** Verifies accumulation of independent validation failures. */