The Code That Lives in Every Java Codebase
Every Java developer has written this code. You have written this code. Probably this week. A method starts with five lines of null checks, blank checks, and range checks before a single line of business logic executes. The validation is scattered, duplicated, and â worst of all â invisible at the API boundary.
public void createUser(String name, int age, List<String> roles) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("name must not be blank");
}
if (age <= 0) {
throw new IllegalArgumentException("age must be positive");
}
if (roles == null || roles.isEmpty()) {
throw new IllegalArgumentException("roles must not be empty");
}
// finally, the actual logic...
}
You know the problems. The checks get copy-pasted across service layers. Someone forgets one in a new method. A bug slips through because the validation was in the controller but not in the domain service. The types lie â String promises nothing about content, int promises nothing about sign, List promises nothing about emptiness.
What if the types themselves could guarantee validity?
public void createUser(NonBlankString name, PositiveInt age, NonEmptyList<String> roles) {
// no validation needed â the types guarantee it
}
This is what refinement types give you. And now they are available in Java 8+ with zero runtime dependencies.
What Are Refinement Types?
Refinement types are types constrained by a predicate. A PositiveInt is not just an int â it is an int that has been proven positive at construction time. A NonBlankString is not just a String â it is a String that has been proven non-blank. The constraint is encoded in the type system, so the compiler helps you enforce it.
The concept is well-established in other language ecosystems:
- Scala has refined (~1.6k GitHub stars), which uses compile-time macros to enforce predicates on literal values.
- Rust has nutype (~1.6k GitHub stars), which uses derive macros to generate validated newtype wrappers.
-
Haskell has
Refinedin therefinedpackage with compile-time and runtime checking.
Java has had... nothing comparable. Until now.
java-refined brings refinement types to Java 8+ with 204 ready-to-use refined types covering numerics, strings, characters, collections, and control flow â all with zero runtime dependencies, 100% test coverage, and 95%+ mutation testing scores.
Installation
java-refined is published on Maven Central. Choose your build tool:
Gradle (Kotlin DSL)
dependencies {
implementation("io.github.junggikim:java-refined:1.1.0")
// Optional: Kotlin extensions
implementation("io.github.junggikim:java-refined-kotlin:1.1.0")
}
Gradle (Groovy DSL)
dependencies {
implementation 'io.github.junggikim:java-refined:1.1.0'
// Optional: Kotlin extensions
implementation 'io.github.junggikim:java-refined-kotlin:1.1.0'
}
Maven
<dependency>
<groupId>io.github.junggikim</groupId>
<artifactId>java-refined</artifactId>
<version>1.1.0</version>
</dependency>
<!-- Optional: Kotlin extensions -->
<dependency>
<groupId>io.github.junggikim</groupId>
<artifactId>java-refined-kotlin</artifactId>
<version>1.1.0</version>
</dependency>
That is it. No annotation processors, no code generation plugins, no transitive dependencies.
Example 1: Type-Safe User Creation
Let us revisit the user creation scenario. The goal is to make invalid states unrepresentable at the method signature level, so callers cannot even invoke the method with bad data.
Constructing Refined Types
Every refined type provides two factory methods:
-
of(value)returns aValidation<Violation, T>â a result type that never throws. You pattern-match on success or failure. -
unsafeOf(value)throws aRefinementExceptionon invalid input. Use this when you are certain the value is valid or when you want fail-fast behavior.
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;
// Safe construction â never throws
Validation<Violation, NonBlankString> nameResult = NonBlankString.of("Alice");
Validation<Violation, PositiveInt> ageResult = PositiveInt.of(30);
// Pattern matching with fold
String message = nameResult.fold(
violation -> "invalid: " + violation.code() + " - " + violation.message(),
name -> "ok: " + name.value()
);
// message = "ok: Alice"
When the input is invalid, you get a Violation with structured error information:
Validation<Violation, NonBlankString> result = NonBlankString.of(" ");
String msg = result.fold(
v -> "invalid: " + v.code() + " - " + v.message(),
ok -> "ok: " + ok.value()
);
// msg = "invalid: ..." (violation details)
The Violation object exposes .code(), .message(), and .metadata(), giving you everything you need for structured error responses without string parsing.
The Domain Method
public class UserService {
public User createUser(NonBlankString name, PositiveInt age, NonEmptyList<String> roles) {
// No validation code. The types guarantee:
// - name is non-null and non-blank
// - age is a positive integer
// - roles is a non-null, non-empty list
return new User(name.value(), age.value(), roles);
}
}
Notice that NonEmptyList<T> implements List<T> â you can pass it directly to any API that expects a standard JDK List. The collection types are not wrappers that force you to unwrap; they ARE the JDK interfaces.
The Controller / Entry Point
public Response handleCreateUser(CreateUserRequest request) {
Validation<Violation, NonBlankString> name = NonBlankString.of(request.getName());
Validation<Violation, PositiveInt> age = PositiveInt.of(request.getAge());
// ... validate and call service
}
Validation happens once, at the boundary. Every layer below the boundary works with proven-valid types.
Example 2: API Input Validation
java-refined includes 48 string types covering common domain formats. These types validate structure at construction time, so downstream code can trust the format without re-checking.
import io.github.junggikim.refined.refined.string.EmailString;
import io.github.junggikim.refined.refined.string.UuidString;
import io.github.junggikim.refined.refined.string.Ipv4String;
// Email validation
Validation<Violation, EmailString> email = EmailString.of("user@example.com");
// valid
Validation<Violation, EmailString> badEmail = EmailString.of("not-an-email");
// invalid â Violation with code, message, metadata
// UUID validation
Validation<Violation, UuidString> uuid = UuidString.of("550e8400-e29b-41d4-a716-446655440000");
// valid
// IPv4 validation
Validation<Violation, Ipv4String> ip = Ipv4String.of("192.168.1.1");
// valid
Additional string types include JsonString, JwtString, and many more â 48 string types total, each with rigorous validation and zero external dependencies.
Why Not Just Use Regex?
You could validate an email with a regex. But a regex gives you a String back. The next method that receives it has no type-level evidence that the string was validated. With EmailString, the type itself is the proof. The compiler enforces it. Code review catches misuse. Refactoring is safe.
Example 3: Error Accumulation with Validated
The Validation type is fail-fast â it stops at the first error. For form validation and API responses, you often want to collect ALL errors at once. That is what Validated is for.
Validated accumulates errors across multiple validations using .zip(). This lets you report every invalid field in a single response rather than forcing the user to fix errors one at a time.
import io.github.junggikim.refined.validation.Validated;
import java.util.Arrays;
import java.util.List;
// Simulate two invalid fields
Validated<String, Integer> ageValidation = Validated.invalid(Arrays.asList("age must be positive"));
Validated<String, Integer> nameValidation = Validated.invalid(Arrays.asList("name must not be blank"));
// Accumulate errors
List<String> errors = ageValidation.zip(nameValidation, Integer::sum).getErrors();
// errors = ["age must be positive", "name must not be blank"]
This pattern is especially powerful in REST APIs where you want to return a complete list of validation errors in a single 400 response:
// In a controller
Validated<String, NonBlankString> name = validateName(request.getName());
Validated<String, EmailString> email = validateEmail(request.getEmail());
Validated<String, PositiveInt> age = validateAge(request.getAge());
Validated<String, CreateUserCommand> command = name
.zip(email, (n, e) -> new NameAndEmail(n, e))
.zip(age, (ne, a) -> new CreateUserCommand(ne.name(), ne.email(), a));
if (command.isInvalid()) {
return Response.badRequest(command.getErrors());
}
Bean Validation vs Refinement Types
Many Java developers reach for Bean Validation (JSR 380 / Hibernate Validator) for input validation. Here is how the two approaches compare:
| Aspect | Bean Validation | Refinement Types (java-refined) |
|---|---|---|
| Where validation happens | Runtime, triggered by framework | Construction time, enforced by type |
| Type safety |
String remains String after validation |
NonBlankString is a distinct type |
| Compile-time guarantees | None â annotations are metadata | Method signatures prevent invalid calls |
| Dependencies | Hibernate Validator + jakarta.validation + EL | Zero dependencies |
| Java version | Java 8+ (with Jakarta migration headaches) | Java 8+ (no migration concerns) |
| Framework coupling | Requires Spring/Jakarta EE context | Works anywhere â plain Java |
| Error accumulation | Built-in via ConstraintViolation set |
Built-in via Validated.zip()
|
| Custom validators | Annotation + Validator class + registration | Extend Refined<P, T> with a predicate |
| Testing | Need framework context for integration tests | Pure unit tests, no context needed |
| Coverage | Varies by project | 100% test coverage, 95%+ mutation testing |
Bean Validation is a good tool for its job. But it operates at a different level: it validates objects after construction. Refinement types prevent invalid objects from existing in the first place. The two can coexist â use Bean Validation for framework-level DTO validation, and refinement types for domain-level type safety.
Why java-refined?
Here is what sets this library apart:
- 204 refined types out of the box: 46 numeric, 7 character, 48 string, 10 collection, 5 control, and 55+ predicates. You probably do not need to write custom types for common cases.
- Zero runtime dependencies. The JAR depends on nothing but the JDK. No classpath conflicts. No transitive dependency hell.
- Java 8+ compatible. Works in legacy codebases. No module system requirements.
- 100% line coverage, 95%+ mutation testing. This is not just "tests pass" â it is "the tests actually catch bugs." Mutation testing verifies that your test suite detects injected faults.
-
JDK interface compatibility.
NonEmptyList<T>implementsList<T>. You can pass refined collections to any existing code that expects standard JDK interfaces. -
Kotlin module. First-class Kotlin support with idiomatic extensions via the
java-refined-kotlinmodule. - MIT License. Use it however you want.
Frequently Asked Questions
What Java version does java-refined require?
java-refined requires Java 8 or later. It has no dependency on Java modules, Jakarta EE, or any framework. It works in any Java 8+ environment â Spring Boot, Quarkus, Micronaut, plain Java, Android (with desugaring), or anything else.
Does java-refined have any runtime dependencies?
No. java-refined has zero runtime dependencies. The compiled JAR depends only on the JDK standard library. This eliminates transitive dependency conflicts and keeps your dependency tree clean.
How does java-refined compare to Scala's refined library?
Scala's refined uses compile-time macros to verify literal values at compilation. java-refined operates at runtime because Java lacks macro support. The tradeoff: Scala refined catches errors at compile time for literals, while java-refined catches errors at construction time for all values (including dynamic input). Both encode the constraint in the type system.
Can I use java-refined with Spring Boot / Bean Validation?
Yes. The two are complementary. Use Bean Validation for DTO-level annotation-based validation in your controllers, and use java-refined for domain-level type safety in your service and domain layers. Refined types can be constructed from validated DTOs at the boundary.
How do I create custom refined types?
Extend the base Refined class with your own predicate. The library provides the Validation, Violation, and predicate infrastructure. See the GitHub repository for examples.
Is the Kotlin module required?
No. The core java-refined module works perfectly in Kotlin. The java-refined-kotlin module adds idiomatic Kotlin extensions (operator overloads, extension functions, etc.) for a more natural Kotlin experience.
Get Started
Add the dependency. Replace one String parameter with NonBlankString. Run your tests. See how the type system starts working for you instead of against you.
GitHub: https://github.com/JunggiKim/java-refined
Maven Central:
io.github.junggikim:java-refined:1.1.0
If java-refined saves you from a bug or simplifies your validation code, consider giving the project a star on GitHub. Feedback, issues, and contributions are welcome.
java-refined is created and maintained by Junggi Kim. Licensed under MIT.
Top comments (0)