Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github: [JunggiKim]
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,13 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht

## [Unreleased]

### Added
- `Validation` convenience default methods: `getOrElse`, `getOrNull`, `toOptional`, `onValid`, `onInvalid`, `mapError`, `recover`
- `ofOrElse(value, defaultValue)` static factory on all refined types (numeric, character, string, collection) for safe fallback creation

### Changed
- Downgraded `com.vanniktech.maven.publish` from 0.36.0 to 0.35.0 for Kotlin 1.9.x compatibility
- Kotlin extensions `getOrNull()`, `onValid()`, `onInvalid()` on `Validation` removed in favor of Java default methods with the same signatures

## [1.1.0] - 2026-03-13

Expand Down
69 changes: 41 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,19 @@ Types replace scattered `if`-checks. Invalid data cannot even reach your method.
## Quick Start

```java
import io.github.junggikim.refined.refined.numeric.PositiveInt;
import io.github.junggikim.refined.refined.string.NonBlankString;
import io.github.junggikim.refined.refined.collection.NonEmptyList;
import io.github.junggikim.refined.validation.Validation;
import io.github.junggikim.refined.violation.Violation;
import java.util.Arrays;

// of() returns Validation — never throws
Validation<Violation, PositiveInt> age = PositiveInt.of(18);
Validation<Violation, NonBlankString> name = NonBlankString.of("Ada");
Validation<Violation, NonEmptyList<String>> tags =
NonEmptyList.of(Arrays.asList("java", "fp")); // or List.of() on Java 9+

// combine results
Validation<Violation, String> summary =
name.zip(age, (n, a) -> n.value() + " (" + a.value() + ")");

// unsafeOf() throws on invalid input — use at trusted boundaries
// Trusted data — just unwrap
PositiveInt confirmedAge = PositiveInt.unsafeOf(18);

// Untrusted input — safe fallback
PositiveInt safeAge = PositiveInt.ofOrElse(userInput, 1);
```

```java
// Branch on success/failure
EmailString.of(email).fold(
error -> badRequest(error.message()),
valid -> ok(register(valid))
);
```

## Installation
Expand Down Expand Up @@ -96,24 +90,42 @@ Optional module with Kotlin-idiomatic extensions:
```kotlin
import io.github.junggikim.refined.kotlin.*

// Scalar extensions
val name = "Ada".toNonBlankStringOrThrow()
val tags = listOf("java", "fp").toNonEmptyListOrThrow()

// Validation extensions
val age = PositiveInt.ofOrElse(input, 1)
val age = PositiveInt.of(input).getOrNull() ?: PositiveInt.unsafeOf(1)
val result: Result<PositiveInt> = PositiveInt.of(input).toResult()
```

## Error Handling
## Usage Patterns

`Validation<E, A>` is fail-fast — stops at the first error. Use it for single-field validation.
`Validated<E, A>` accumulates all errors into a list. Use it when you need every failure at once.
```java
// validate and get — most common
PositiveInt age = PositiveInt.ofOrElse(input, 1);
```

```java
// fail-fast
Validation<Violation, NonBlankString> bad = NonBlankString.of(" ");
String message = bad.fold(
v -> "invalid: " + v.code() + " - " + v.message(),
ok -> "ok: " + ok.value()
);
// to Optional
Optional<PositiveInt> maybeAge = PositiveInt.of(input).toOptional();
```

```java
// transform error type
Validation<String, PositiveInt> mapped = PositiveInt.of(input)
.mapError(Violation::message);
```

// error-accumulating
```java
// recover from error
Validation<Violation, PositiveInt> recovered = PositiveInt.of(input)
.recover(err -> PositiveInt.unsafeOf(1));
```

```java
// error-accumulating (multiple fields at once)
Validated<String, Integer> left = Validated.invalid(Arrays.asList("age"));
Validated<String, Integer> right = Validated.invalid(Arrays.asList("name"));
List<String> errors = left.zip(right, Integer::sum).getErrors();
Expand All @@ -126,6 +138,7 @@ All refined wrappers follow the same pattern:

- `of(value)` — returns `Validation<Violation, T>`, never throws
- `unsafeOf(value)` — throws `RefinementException` on invalid input
- `ofOrElse(value, default)` — returns validated instance or falls back to `default`
- `ofStream(stream)` — collection wrappers only

Collection refined types implement JDK interfaces directly — `NonEmptyList<T>` is a `List<T>`, `NonEmptyMap<K,V>` is a `Map<K,V>`. No unwrapping needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,53 @@ import io.github.junggikim.refined.validation.Validated
import io.github.junggikim.refined.validation.Validation
import io.github.junggikim.refined.violation.Violation

fun <E, A> Validation<E, A>.getOrNull(): A? =
if (isValid) get() else null

// getOrNull(), onValid(), onInvalid(), getOrElse(Function), recover(Function), mapError(Function)
// are now Java default methods on Validation<E, A>.
// Kotlin callers invoke the Java defaults directly via SAM conversion.
// Only Kotlin-specific extensions (errorOrNull, getOrThrow, getOrDefault, toResult) remain here.

/**
* Returns the error if invalid, otherwise `null`.
*
* Mirrors `kotlin.Result.exceptionOrNull`.
*/
fun <E, A> Validation<E, A>.errorOrNull(): E? =
if (isInvalid) error else null

/**
* Returns the value if valid, otherwise throws [RefinementException].
*/
fun <A> Validation<Violation, A>.getOrThrow(): A =
if (isValid) get() else throw RefinementException(error)

fun <E, A> Validation<E, A>.onValid(block: (A) -> Unit): Validation<E, A> {
if (isValid) {
block(get())
}
return this
}

fun <E, A> Validation<E, A>.onInvalid(block: (E) -> Unit): Validation<E, A> {
if (isInvalid) {
block(error)
}
return this
}

/**
* Returns the value if valid, otherwise returns [default].
*
* Kotlin-idiomatic alias for Java `Validation.getOrElse(A)`.
* Mirrors `kotlin.Result.getOrDefault`.
*/
fun <E, A> Validation<E, A>.getOrDefault(default: A): A =
if (isValid) get() else default

/**
* Converts this Validation to a `kotlin.Result`.
*
* Valid becomes `Result.success`, Invalid becomes `Result.failure` with [RefinementException].
*/
fun <A> Validation<Violation, A>.toResult(): Result<A> =
if (isValid) Result.success(get())
else Result.failure(RefinementException(error))

/**
* Returns the value if valid, otherwise `null`.
*
* Mirrors `kotlin.Result.getOrNull`.
*/
fun <E, A> Validated<E, A>.valueOrNull(): A? =
if (isValid) get() else null

/**
* Returns the error list if invalid, otherwise `null`.
*/
fun <E, A> Validated<E, A>.errorsOrNull(): List<E>? =
if (isInvalid) errors else null
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package io.github.junggikim.refined.kotlin

import io.github.junggikim.refined.core.RefinementException
import io.github.junggikim.refined.validation.Validation
import io.github.junggikim.refined.violation.Violation
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class ValidationExtensionsTest {

private val violation: Violation = Violation.of("test-error", "test error message")
private val valid: Validation<Violation, String> = Validation.valid("hello")
private val invalid: Validation<Violation, String> = Validation.invalid(violation)

// -- errorOrNull (Kotlin extension) --

@Test
fun errorOrNullReturnsNullWhenValid() {
assertNull(valid.errorOrNull())
}

@Test
fun errorOrNullReturnsErrorWhenInvalid() {
assertEquals("test-error", invalid.errorOrNull()!!.code)
}

// -- getOrThrow (Kotlin extension) --

@Test
fun getOrThrowReturnsValueWhenValid() {
assertEquals("hello", valid.getOrThrow())
}

@Test
fun getOrThrowThrowsWhenInvalid() {
val e = assertThrows<RefinementException> { invalid.getOrThrow() }
assertEquals("test-error", e.violation().code)
}

// -- getOrDefault (Kotlin extension) --

@Test
fun getOrDefaultReturnsValueWhenValid() {
assertEquals("hello", valid.getOrDefault("fallback"))
}

@Test
fun getOrDefaultReturnsDefaultWhenInvalid() {
assertEquals("fallback", invalid.getOrDefault("fallback"))
}

// -- toResult (Kotlin extension) --

@Test
fun toResultReturnsSuccessWhenValid() {
val result = valid.toResult()
assertTrue(result.isSuccess)
assertEquals("hello", result.getOrNull())
}

@Test
fun toResultReturnsFailureWhenInvalid() {
val result = invalid.toResult()
assertTrue(result.isFailure)
val exception = result.exceptionOrNull()
assertIs<RefinementException>(exception)
assertEquals("test-error", exception.violation().code)
}

// -- Java default methods accessible from Kotlin (smoke tests) --

@Test
fun javaGetOrElseValueOverloadWorksFromKotlin() {
assertEquals("hello", valid.getOrElse("fallback"))
assertEquals("fallback", invalid.getOrElse("fallback"))
}

@Test
fun javaGetOrElseLambdaOverloadWorksFromKotlin() {
assertEquals("hello", valid.getOrElse { "fallback" })
assertEquals("fallback", invalid.getOrElse { "fallback" })
}

@Test
fun javaRecoverWorksFromKotlin() {
assertTrue(valid.recover { "recovered" }.isValid)
val recovered = invalid.recover { "recovered" }
assertTrue(recovered.isValid)
assertEquals("recovered", recovered.get())
}

@Test
fun javaMapErrorWorksFromKotlin() {
val passThrough: Validation<String, String> = valid.mapError { it.code }
assertTrue(passThrough.isValid)
assertEquals("hello", passThrough.get())

val mapped: Validation<String, String> = invalid.mapError { it.code }
assertTrue(mapped.isInvalid)
assertEquals("test-error", mapped.error)
}

@Test
fun javaOnValidOnInvalidWorksFromKotlin() {
var validSeen = false
var invalidSeen = false

valid.onValid { validSeen = true }.onInvalid { invalidSeen = true }
assertTrue(validSeen)
assertTrue(!invalidSeen)

validSeen = false
invalid.onValid { validSeen = true }.onInvalid { invalidSeen = true }
assertTrue(!validSeen)
assertTrue(invalidSeen)
}

@Test
fun javaGetOrNullWorksFromKotlin() {
assertEquals("hello", valid.getOrNull())
assertNull(invalid.getOrNull())
}

@Test
fun javaToOptionalWorksFromKotlin() {
assertTrue(valid.toOptional().isPresent)
assertFalse(invalid.toOptional().isPresent)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ public static Validation<Violation, DigitChar> of(Character value) {
public static DigitChar unsafeOf(Character value) {
return RefinedSupport.unsafeRefine(value, CONSTRAINT, DigitChar::new);
}

/**
* Returns a validated instance, or falls back to {@code defaultValue} if invalid.
*
* @param value input to validate
* @param defaultValue fallback (must itself be valid)
* @return refined instance
* @throws io.github.junggikim.refined.core.RefinementException if defaultValue is also invalid
*/
@org.jetbrains.annotations.NotNull
public static DigitChar ofOrElse(@org.jetbrains.annotations.Nullable Character value, @org.jetbrains.annotations.NotNull Character defaultValue) {
Validation<Violation, DigitChar> result = of(value);
return result.isValid() ? result.get() : unsafeOf(defaultValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ public static Validation<Violation, LetterChar> of(Character value) {
public static LetterChar unsafeOf(Character value) {
return RefinedSupport.unsafeRefine(value, CONSTRAINT, LetterChar::new);
}

/**
* Returns a validated instance, or falls back to {@code defaultValue} if invalid.
*
* @param value input to validate
* @param defaultValue fallback (must itself be valid)
* @return refined instance
* @throws io.github.junggikim.refined.core.RefinementException if defaultValue is also invalid
*/
@org.jetbrains.annotations.NotNull
public static LetterChar ofOrElse(@org.jetbrains.annotations.Nullable Character value, @org.jetbrains.annotations.NotNull Character defaultValue) {
Validation<Violation, LetterChar> result = of(value);
return result.isValid() ? result.get() : unsafeOf(defaultValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ public static Validation<Violation, LetterOrDigitChar> of(Character value) {
public static LetterOrDigitChar unsafeOf(Character value) {
return RefinedSupport.unsafeRefine(value, CONSTRAINT, LetterOrDigitChar::new);
}

/**
* Returns a validated instance, or falls back to {@code defaultValue} if invalid.
*
* @param value input to validate
* @param defaultValue fallback (must itself be valid)
* @return refined instance
* @throws io.github.junggikim.refined.core.RefinementException if defaultValue is also invalid
*/
@org.jetbrains.annotations.NotNull
public static LetterOrDigitChar ofOrElse(@org.jetbrains.annotations.Nullable Character value, @org.jetbrains.annotations.NotNull Character defaultValue) {
Validation<Violation, LetterOrDigitChar> result = of(value);
return result.isValid() ? result.get() : unsafeOf(defaultValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,18 @@ public static Validation<Violation, LowerCaseChar> of(Character value) {
public static LowerCaseChar unsafeOf(Character value) {
return RefinedSupport.unsafeRefine(value, CONSTRAINT, LowerCaseChar::new);
}

/**
* Returns a validated instance, or falls back to {@code defaultValue} if invalid.
*
* @param value input to validate
* @param defaultValue fallback (must itself be valid)
* @return refined instance
* @throws io.github.junggikim.refined.core.RefinementException if defaultValue is also invalid
*/
@org.jetbrains.annotations.NotNull
public static LowerCaseChar ofOrElse(@org.jetbrains.annotations.Nullable Character value, @org.jetbrains.annotations.NotNull Character defaultValue) {
Validation<Violation, LowerCaseChar> result = of(value);
return result.isValid() ? result.get() : unsafeOf(defaultValue);
}
}
Loading